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:
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 == []
|
||||
Reference in new issue
Block a user