diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py index efa0a2c9..f46cfe3e 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py @@ -1157,6 +1157,8 @@ class TomoQueueMixin: self.progress["estimated_finish_time"] = None self.progress["accumulated_idle_time"] = 0.0 self.progress["heartbeat"] = None + # flOMNI: a completed scan is not resumed (tomo_scan_resume); reopened here on purpose + self.progress["tomo_completed"] = False job_id = job["id"] self._tomo_queue_proxy.update_by_id(job_id, status="incomplete") diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index fc2768a1..3f175ba5 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -82,7 +82,8 @@ class FlomniInitStagesMixin: sensor_voltage_target = dev.ftransy.user_parameter.get("sensor_voltage") sensor_voltage = float(dev.ftransy.controller.socket_put_and_receive("MG@AN[1]").strip()) - if not np.isclose(sensor_voltage, sensor_voltage_target, 0.25): + # absolute tolerances (V, mm): the third positional argument of np.isclose is rtol + if not np.isclose(sensor_voltage, sensor_voltage_target, rtol=0, atol=0.25): print( f"Sensor voltage of the gripper is {sensor_voltage}, while target from config is {sensor_voltage_target}" ) @@ -410,7 +411,7 @@ class FlomniSampleTransferMixin: self.ensure_osa_back() fsamx_in = dev.fsamx.user_parameter.get("in") - if not np.isclose(dev.fsamx.readback.get(), fsamx_in, 0.2): + if not np.isclose(dev.fsamx.readback.get(), fsamx_in, rtol=0, atol=0.2): raise FlomniError("fsamx not in position. Aborting.") fheater_in = dev.fheater.user_parameter.get("in") @@ -525,7 +526,7 @@ class FlomniSampleTransferMixin: """Interactive tweak of the two laser tracking target positions. Arrow keys nudge the tracking targets (realtime_system tracking_target_y / _z) by a fixed step of - 0.01 (absolute set of current +/- step): + 0.02 (absolute set of current +/- step): left / right : target y -/+ up / down : target z +/- Keys: @@ -724,7 +725,7 @@ class FlomniSampleTransferMixin: self.flomnigui_raise() target_pos = -162 - if np.isclose(dev.fsamx.readback.get(), target_pos, 0.01): + if np.isclose(dev.fsamx.readback.get(), target_pos, rtol=0, atol=0.5): return umv(dev.fsamroy, 0) @@ -764,7 +765,7 @@ class FlomniSampleTransferMixin: sensor_voltage_target = dev.ftransy.user_parameter.get("sensor_voltage") sensor_voltage = float(dev.ftransy.controller.socket_put_and_receive("MG@AN[1]").strip()) - if not np.isclose(sensor_voltage, sensor_voltage_target, 0.5): + if not np.isclose(sensor_voltage, sensor_voltage_target, rtol=0, atol=0.5): raise FlomniError(f"Sensor voltage is {sensor_voltage}, indicates an error. Aborting.") def ftransfer_get_sample(self, position: int): @@ -1643,6 +1644,8 @@ class _ProgressProxy: "estimated_finish_time": None, "heartbeat": None, "accumulated_idle_time": 0.0, + # set when tomo_scan() reached its end: tomo_scan_resume() then has nothing to resume + "tomo_completed": False, } def __init__(self, client): @@ -1718,7 +1721,7 @@ class Flomni( self.device_manager = client.device_manager self.special_angles = [] self.special_angle_repeats = 20 - self.special_angle_tolerance = 20 + self.special_angle_tolerance = 0.5 self._current_special_angles = [] self._beam_is_okay = True self._progress_proxy = _ProgressProxy(self.client) @@ -1850,7 +1853,7 @@ class Flomni( self.align.align(keep_shutter_open) except KeyboardInterrupt as exc: fsamx_in = self._get_user_param_safe(dev.fsamx, "in") - if np.isclose(fsamx_in, dev.fsamx.readback.get(), 0.5): + if np.isclose(fsamx_in, dev.fsamx.readback.get(), rtol=0, atol=0.5): print("Stopping alignment. Returning to fsamx in position.") self.feedback_disable() umv(dev.fsamx, fsamx_in) @@ -1911,10 +1914,14 @@ class Flomni( def set_special_angles(self, angles: list, repeats: int = 20, tolerance: float = 0.5): """Set the special angles for a tomo + The first projection of the tomogram within `tolerance` of a special angle is acquired + `repeats` times; each special angle is used once per tomogram (in any order), also across + tomo_scan_resume() in the same session. + Args: angles (list): List of special angles repeats (int, optional): Number of repeats at a special angle. Defaults to 20. - tolerance (float, optional): Number of repeats at a special angle. Defaults to 0.5. + tolerance (float, optional): Angle tolerance in degrees. Defaults to 0.5. """ self.special_angles = angles @@ -2228,6 +2235,8 @@ class Flomni( self.write_alignment_scan_numbers(bec.queue.next_scan_number) start_angle = 0 alignment_scan_numbers = [] + # first scan number of the accepted attempt per angle (a retry shifts the scan numbers) + alignment_first_scans = [] angle_end = start_angle + 180 for angle in np.linspace(start_angle, angle_end, num=int(180 / 45) + 1, endpoint=True): @@ -2245,16 +2254,24 @@ class Flomni( bec.queue.request_queue_reset() time.sleep(2) error_caught = True + print(f"Timeout at angle {angle}, repeating it (Ctrl-C to stop).") else: raise exc # todo here was if blchk success, then setting to success true - successful = True + # repeat the angle after a timeout (it was skipped silently before, 2026-10-24) + successful = not error_caught end_scan_number = bec.queue.next_scan_number + alignment_first_scans.append(start_scan_number) for scan_nr in range(start_scan_number, end_scan_number): # self._write_tomo_scan_number(scan_nr, angle, 0) alignment_scan_numbers.append(scan_nr) + consecutive = [alignment_first_scans[0] + k for k in range(len(alignment_first_scans))] + if alignment_first_scans != consecutive: + # a retry (or stitching) shifted the scan numbers: rewrite the file with the accepted scans + self.write_alignment_scan_numbers(alignment_first_scans[0], scans=alignment_first_scans) + umv(dev.fsamroy, 0) self.OMNYTools.printgreenbold( "\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit by flomni.read_alignment_offset() ." @@ -2274,13 +2291,14 @@ class Flomni( print(scilog_content) self._scilog_write(scilog_content, "alignmentscan") - def write_alignment_scan_numbers(self, first_scan): + def write_alignment_scan_numbers(self, first_scan, scans=None): import os file = os.path.expanduser("~/data/raw/logs/ptychotomoalign_scannum.txt") os.makedirs(os.path.dirname(file), exist_ok=True) - scans = [first_scan + k for k in range(5)] + if scans is None: + scans = [first_scan + k for k in range(5)] angles = [0, 45, 90, 135, 180] x_vals = [] @@ -2572,10 +2590,16 @@ class Flomni( # succeeded, so a retried attempt still sees the same special # angle and repeat count. num_repeats = 1 - is_special_angle = bool( - self._current_special_angles - and np.isclose(angle, self._current_special_angles[0], atol=0.5) + # any remaining special angle within the tolerance (not only the next one in the list) + special_index = next( + ( + k + for k, special in enumerate(self._current_special_angles) + if np.isclose(angle, special, rtol=0, atol=self.special_angle_tolerance) + ), + None, ) + is_special_angle = special_index is not None if is_special_angle: num_repeats = self.special_angle_repeats start_scan_number = bec.queue.next_scan_number @@ -2585,7 +2609,7 @@ class Flomni( self._at_each_angle(angle, **overrides) projection_duration = time.perf_counter() - projection_start if is_special_angle: - self._current_special_angles.pop(0) + self._current_special_angles.pop(special_index) end_scan_number = bec.queue.next_scan_number for scan_nr in range(start_scan_number, end_scan_number): @@ -2733,7 +2757,9 @@ class Flomni( bec.builtin_actors.scan_interlock.trigger_setting = "restart_scan" bec.builtin_actors.scan_interlock.enabled = True - self._current_special_angles = self.special_angles.copy() + if is_new_scan or not self.special_angles: + # on a resume keep the special angles not yet acquired (they were taken again before) + self._current_special_angles = list(self.special_angles) if is_new_scan: # pylint: disable=undefined-variable # Always attempt registration (even for an empty/test account) @@ -2762,6 +2788,7 @@ class Flomni( # self.progress.reset() above, before the gates -- only the actual # start time is set here, once the scan is genuinely committed to. self.progress["tomo_start_time"] = datetime.datetime.now().isoformat() + self.progress["tomo_completed"] = False if file_angles is not None: self._init_angle_file_progress(file_angles, projection_number) self.collect_empty_frames() @@ -2938,6 +2965,8 @@ class Flomni( # (120s / 90s) after a tomogram that's actually already done. self.progress["heartbeat"] = None + # all projections acquired: nothing left for tomo_scan_resume() (also if a step below fails) + self.progress["tomo_completed"] = True self.progress["projection"] = self.progress["total_projections"] self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"] self._print_progress() @@ -2991,6 +3020,10 @@ class Flomni( if self.progress.get("tomo_start_time") is None: print("No tomo scan in progress to resume -- nothing to do.") return + if self.progress.get("tomo_completed"): + # it re-acquired the last projection (and repeated the end-of-scan steps) before, 2026-10-24 + print("The last tomo scan was completed -- nothing to resume.") + return if not from_queue and not self._confirm_resume_outside_queue(): print("Aborted.") diff --git a/tests/tests_bec_ipython_client/test_flomni_review_fixes.py b/tests/tests_bec_ipython_client/test_flomni_review_fixes.py new file mode 100644 index 00000000..cb3bd58c --- /dev/null +++ b/tests/tests_bec_ipython_client/test_flomni_review_fixes.py @@ -0,0 +1,141 @@ +"""Fixes from the flomni.py review 2026-10-24: tomo_scan_resume() after a completed scan, special angles kept +on resume, the alignment scan repeats an angle after a timeout, absolute np.isclose tolerances.""" + +import builtins +import types + +import pytest + +import csaxs_bec.bec_ipython_client.plugins.flomni.flomni as flomni_module +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni, FlomniError, _ProgressProxy + + +class _FakeClient: + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +def _make_flomni(): + obj = object.__new__(Flomni) + obj.client = _FakeClient() + obj._progress_proxy = _ProgressProxy(obj.client) + return obj + + +def test_resume_after_completed_scan_does_nothing(monkeypatch): + flomni = _make_flomni() + flomni.progress["tomo_start_time"] = "2026-10-24T10:00:00" + flomni.progress["tomo_completed"] = True + monkeypatch.setattr(Flomni, "tomo_type", 1, raising=False) + flomni.tomo_scan = lambda **kw: pytest.fail("tomo_scan must not be called") + flomni._confirm_resume_outside_queue = lambda: True + + flomni.tomo_scan_resume() + + +def test_resume_of_interrupted_scan_still_resumes(monkeypatch): + flomni = _make_flomni() + flomni.progress["tomo_start_time"] = "2026-10-24T10:00:00" + flomni.progress["subtomo"] = 3 + flomni.progress["angle"] = 45.0 + monkeypatch.setattr(Flomni, "tomo_type", 1, raising=False) + calls = [] + flomni.tomo_scan = lambda **kw: calls.append(kw) + flomni._confirm_resume_outside_queue = lambda: True + + flomni.tomo_scan_resume() + + assert calls == [{"subtomo_start": 3, "start_angle": 45.0}] + + +def test_progress_reset_clears_completed(): + flomni = _make_flomni() + flomni.progress["tomo_completed"] = True + flomni.progress.reset() + assert flomni.progress.get("tomo_completed") is False + + +class _Timeout(flomni_module.AlarmBase): + def __init__(self): + Exception.__init__(self, "timeout") + self.alarm_type = "TimeoutError" + + +class _Queue: + def __init__(self): + self.next_scan_number = 100 + self.resets = 0 + + def request_queue_reset(self): + self.resets += 1 + + +def test_alignment_scan_repeats_an_angle_after_timeout(monkeypatch, tmp_path): + flomni = _make_flomni() + queue = _Queue() + fake_dev = types.SimpleNamespace( + rtx=types.SimpleNamespace( + controller=types.SimpleNamespace(feedback_is_running=lambda: True) + ), + fsamroy=object(), + ) + monkeypatch.setitem(builtins.__dict__, "bec", types.SimpleNamespace(queue=queue)) + monkeypatch.setitem(builtins.__dict__, "dev", fake_dev) + monkeypatch.setattr(flomni_module, "umv", lambda *a: None, raising=False) + monkeypatch.setattr(flomni_module, "filters_out_of_beam", lambda dev: (True, [])) + monkeypatch.setattr(flomni_module.time, "sleep", lambda s: None) + monkeypatch.setattr(Flomni, "sample_name", "test", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + flomni.get_alignment_offset = lambda angle: (1.0, 0.0, 0.0) + flomni._check_eye_out_and_optics_in = lambda: True + flomni.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None) + flomni._scilog_write = lambda *a: True + angles = [] + + def projection(angle, _internal=False): + angles.append(angle) + queue.next_scan_number += 1 # one scan per attempt (the timed-out one included) + if angle == 90 and angles.count(90) == 1: + raise _Timeout() + + flomni.tomo_scan_projection = projection + + flomni.tomo_alignment_scan() + + assert angles == [0, 45, 90, 90, 135, 180] + assert queue.resets == 1 + lines = (tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt").read_text().splitlines() + assert lines[0] == "100 101 103 104 105" # the accepted scan per angle, not 100..104 + + +@pytest.mark.parametrize( + "readback, ok", [(-1.14, True), (-1.30, True), (-1.40, False), (-0.80, False)] +) +def test_heater_down_needs_fsamx_in_within_0_2_mm(monkeypatch, readback, ok): + flomni = _make_flomni() + fsamx = types.SimpleNamespace( + user_parameter={"in": -1.14}, readback=types.SimpleNamespace(get=lambda: readback) + ) + fheater = types.SimpleNamespace( + _config={"deviceConfig": {"axis_Id": "H"}}, user_parameter={"in": 5} + ) + monkeypatch.setitem( + builtins.__dict__, "dev", types.SimpleNamespace(fsamx=fsamx, fheater=fheater) + ) + monkeypatch.setattr(flomni_module, "dev", builtins.__dict__["dev"], raising=False) + moved = [] + monkeypatch.setattr(flomni_module, "umv", lambda *a: moved.append(a), raising=False) + flomni.ensure_osa_back = lambda: None + if ok: + flomni.move_fheater_down() + assert moved + else: + with pytest.raises(FlomniError): + flomni.move_fheater_down() + assert not moved diff --git a/tests/tests_bec_ipython_client/test_flomni_special_angles.py b/tests/tests_bec_ipython_client/test_flomni_special_angles.py index 7cfab251..4a5781f5 100644 --- a/tests/tests_bec_ipython_client/test_flomni_special_angles.py +++ b/tests/tests_bec_ipython_client/test_flomni_special_angles.py @@ -32,6 +32,7 @@ def _make_flomni(monkeypatch): obj.tomo_angle_range = 180 obj.special_angles = [] obj.special_angle_repeats = 3 + obj.special_angle_tolerance = 0.5 obj._current_special_angles = [] obj._print_progress = lambda: None obj._format_duration = lambda seconds: "0s" @@ -86,3 +87,34 @@ def test_special_angle_repeats_survive_a_retry(monkeypatch): assert len(calls) == 1 + 3, "the retry must still repeat the special angle" assert flomni._current_special_angles == [] + + +def test_special_angle_tolerance_is_used(monkeypatch): + """special_angle_tolerance was ignored (hard-coded 0.5) before 2026-10-24.""" + flomni = _make_flomni(monkeypatch) + flomni.special_angles = [90.0] + flomni._current_special_angles = [90.0] + flomni.special_angle_tolerance = 2.0 + calls = [] + flomni._at_each_angle = lambda angle: calls.append(angle) + + flomni._tomo_scan_at_angle(91.5, 1) + + assert calls == [91.5] * 3 + assert flomni._current_special_angles == [] + + +def test_special_angles_match_in_any_order(monkeypatch): + """An angle not reached first (e.g. a reverse sub-tomogram) no longer blocks the later ones.""" + flomni = _make_flomni(monkeypatch) + flomni.special_angles = [30.0, 120.0] + flomni._current_special_angles = [30.0, 120.0] + calls = [] + flomni._at_each_angle = lambda angle: calls.append(angle) + + flomni._tomo_scan_at_angle(120.0, 1) + flomni._tomo_scan_at_angle(120.0, 1) # used once only + flomni._tomo_scan_at_angle(30.0, 1) + + assert calls == [120.0] * 3 + [120.0] + [30.0] * 3 + assert flomni._current_special_angles == []