fix(flomni): review fixes: absolute isclose tolerances, alignment retry, special angles, resume after completion

- np.isclose(a, b, x) took x as rtol: fsamx out (+-1.6 mm), gripper sensor voltage (+-0.55 V / +-1.1 V at
  -2.2 V), fsamx in checks now use atol (0.2 / 0.5 mm, 0.25 / 0.5 V, fsamx out 0.5 mm = Galil tolerance)
- tomo_alignment_scan: an angle with a TimeoutError is repeated (successful was set unconditionally, the
  angle was skipped); ptychotomoalign_scannum.txt is rewritten with the accepted scans if numbers shifted
- special angles: special_angle_tolerance is used (was hard-coded 0.5; init value 20 -> 0.5), any remaining
  special angle matches (not only the first in the list), kept on tomo_scan_resume in the same session
- tomo_scan_resume after a completed scan does nothing (progress tomo_completed; re-acquired the last
  projection and repeated the end-of-scan steps before); tomo_queue_reacquire clears the flag
- laser_parameters_tweak_tracking docstring: step 0.02

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019aWcwG89wi7oVyrefukXX6
This commit is contained in:
Claude committed 2026-10-24 06:38:54 +00:00
1 parent 7c0a15115d
commit 1a5b53c031
4 files changed
+224 -16

No files matched your search

@@ -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")
@@ -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.")
@@ -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
@@ -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 == []