feat(omny): port collect_empty_frames() from flomni
CI for csaxs_bec / test (push) Successful in 2m15s

10 flat-field/empty-frame images at angle 0, sample shifted out of the
beam by fovx/2 + margin, taken once at the start of every new
tomo_scan() (not on resume) -- mirrors flomni's own collect_empty_frames()
structure and formula, using OMNY's own device names.

Took a few corrections to land on the right design: an initial staged
rtx+osamx move (based on a paraphrase of the real algorithm) was
discarded once Mirko surfaced the actual legacy SPEC macro, which does
a single position-threshold recenter instead. A second implementation
of that (RtOMNYController.move_samx_to_scan_region(), matching a name
already declared but unimplemented in USER_ACCESS) was itself
discarded on realizing OMNY already has this exact mechanism, working
and already wired into every real projection: dev.osamx
.omny_osamx_to_scan_center() (omny_fermat_scan.py's
prepare_setup_part2()). collect_empty_frames() reuses that directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
This commit is contained in:
x01dc
2026-09-03 16:50:46 +02:00
co-authored by Claude Sonnet 5
parent 78f0c8fa3b
commit 71eb9d858f
5 changed files with 268 additions and 1 deletions
@@ -1,4 +1,4 @@
# OMNY open issues (as of 2026-09-03)
# OMNY open issues (as of 2026-09-05)
Tracks the live-testing effort for bringing OMNY up to flomni's/BEC's current standards on
branch `update-omny`: bugs found (fixed and unfixed) and what's still untested. Written so a
@@ -705,6 +705,63 @@ and (2) whether "shift out of the beam" for OMNY means moving `osamx` directly (
movable coarse stage, confirmed) or just passing a deliberately offset `cenx` into
`omny_fermat_scan()`. Not guessed at -- next step is asking Mirko for these two specifics.
## collect_empty_frames() ported to OMNY
Mirko: flomni's `collect_empty_frames()` (10 flat-field/empty-frame images at angle 0, sample
shifted out of the beam by fovx/2, taken once at the start of every new `tomo_scan()`) had no OMNY
equivalent. Working out the right port took several rounds, each correcting the last:
1. **First attempt (discarded)**: a bespoke "150 microns single-move, 300 combined" staged
rtx+osamx dance, based on Mirko's own paraphrase ("move rtx within 150 microns range, if more
range needed, move rtx to 150, then osamx 150..."). Confirmed via `AskUserQuestion` that rtx's
own reading stays put (doesn't reset) across the osamx move -- but this whole model turned out
to be an approximation of something else entirely (see below), and was never committed as final
-- superseded before being finished.
2. **Second attempt (also discarded)**: while drafting, Mirko volunteered the *actual* legacy SPEC
macro for OMNY's projection scan, which recenters `osamx` with a single position-threshold
check (0.025mm/25um), not a staged multi-move dance:
```
if (fabs(A[osamx]-(_omny_samx_in+$3/1000)) > 0.025) { ...move osamx to _omny_samx_in+$3/1000... }
```
Cross-checking `RtOMNYController.USER_ACCESS` (`rt_omny_ophyd.py`) found `"move_samx_to_scan_
region"` declared there -- exactly flomni's own mechanism's name -- but **never actually
implemented** for OMNY (only `RtFlomniController.move_samx_to_scan_region()`, flomni's real
version, exists, and it's a PID-voltage-based iterative correction Mirko confirmed OMNY has no
equivalent readback for). Implemented a new `RtOMNYController.move_samx_to_scan_region()` from
the SPEC algorithm to fill this apparent gap.
3. **Correction, before committing anything (the actual fix)**: Mirko recalled that in flomni this
recenter logic lives inside the *scan class itself* (`flomni_fermat_scan.py`), not the plugin,
and asked whether OMNY's own scan class should get the same treatment for consistency. Checking
`omny_fermat_scan.py`'s `prepare_setup_part2()` found **`self.dev.osamx.omny_osamx_to_scan_
center(self.cenx)` already there** -- a real, already-implemented, already-wired-into-every-
projection port of the exact same SPEC algorithm, just living on `OMNYGalilMotor`
(`ogalil_ophyd.py`) instead of `RtOMNYController`. The "gap" from step 2 didn't actually exist;
`move_samx_to_scan_region` (declared in `USER_ACCESS`, never implemented) was simply the wrong,
unused name to have gone looking under. **Removed** the step-2 addition entirely (replaced with
a comment pointing at the real mechanism, so nobody else goes down the same path) rather than
leave two parallel "recenter osamx" implementations that don't call each other.
**Final implementation** (`omny.py`): `collect_empty_frames()`, structurally mirroring flomni's own
(same 10-frame count, same `subtomo_number=0` logging convention, same
"shift by fovx/2 + 20um margin, direction away from the normal alignment offset" formula) but using
OMNY's own device names (`osamroy` not `fsamroy`) and reusing the *existing*
`dev.osamx.omny_osamx_to_scan_center()` for the recenter step -- no new device-driver code needed
in the end. Wired into `tomo_scan()`'s "a new tomo scan was started" branch (same placement as
flomni's), so it fires once per fresh scan across all 5 tomo types, never on a resume.
Regression tests: `test_tomo_scan_collects_empty_frames_on_new_scan_not_on_resume`,
`test_collect_empty_frames_rotates_to_zero_when_not_already_there`,
`test_collect_empty_frames_shifts_x_away_from_normal_offset_and_recenters_osamx`
(`test_omny_tomo_scan.py`) -- the last one checks the exact shifted-x value, that
`omny_osamx_to_scan_center()` is called with that same value, and that exactly one scan number
gets logged with `subtomo_number=0`. `make_omny_for_tomo_scan()`'s shared fixture gained a
`collect_empty_frames = lambda: None` stub (needed by every other `tomo_scan()` test once this
was wired in). Full suite: 773 passed.
**Not verified against real hardware** -- next step is Mirko trying a real `tomo_scan()` and
confirming: the rotation-skip-if-already-at-0 branch, the direction/magnitude of the x shift, and
that `omny_osamx_to_scan_center()`'s recenter fires (or correctly doesn't) as expected.
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
@@ -56,6 +56,18 @@ the broader OMNY live-testing effort this is part of.
flomni/lamni only) -- toggle it for a type-4/5 job specifically and check the queued job's
tooltip shows it (regression guard for a hardcoded `!= 1` bug caught while wiring this up).
## collect_empty_frames() (new, never tested on real hardware)
- [ ] Start a fresh `tomo_scan()` (any tomo type) -- confirm it rotates to 0 (or prints "No
rotation required" if already there), shifts x away from the normal alignment offset by
fovx/2 + 20um, and successfully acquires 10 empty frames before the real angular grid starts.
- [ ] Confirm `dev.osamx.omny_osamx_to_scan_center()`'s recenter fires when it should (osamx more
than 25um from where it needs to be for the shifted x) and is a no-op otherwise.
- [ ] Confirm this does *not* run on `tomo_scan_resume()`/a mid-scan resume -- only on a genuinely
fresh scan.
- [ ] Check `tomography_scannumbers.txt` gets one new line per empty frame, `subtomo_number=0`,
distinguishable from the real angular grid (subtomo_number 1+).
## Smaller fixes
- [ ] `tomography_scannumbers.txt` and the PDF report land under `~/data/raw/...` (not the old
@@ -1373,6 +1373,7 @@ class OMNY(
self.progress["estimated_finish_time"] = None
self.progress["accumulated_idle_time"] = 0.0
self.progress["heartbeat"] = None
self.collect_empty_frames()
try:
with scans.dataset_id_on_hold:
@@ -1784,6 +1785,64 @@ class OMNY(
f"{scan_number} {angle} {dev.osamroy.read()['osamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n"
)
def collect_empty_frames(self):
"""Acquire 10 empty-frame (flat-field) images at angle 0, with osamx/rtx
shifted out of the beam by half the field of view (fovx/2), in the
direction opposite the normal alignment x-offset at angle 0.
Called once at the start of a new tomo_scan() (not on resume). Mirrors
flomni's collect_empty_frames() (same purpose, same 10-frame count,
same subtomo_number=0 logging convention), adapted for OMNY's own
device names: rtx is moved directly to the shifted-out x offset, then
dev.osamx.omny_osamx_to_scan_center() recenters osamx too if it has
drifted far enough from where it should sit for this offset -- the
same recenter check every real projection already goes through
(wired into omny_fermat_scan.py's prepare_setup_part2(), not
anything specific to this method), reused as-is rather than
duplicated.
These frames are deliberately not passed through tomo_reconstruct()
-- they are flat fields, not ptycho projections, and must not be
added to the reconstruction queue. Logged to
tomography_scannumbers.txt with subtomo_number=0 so they remain
traceable but are clearly distinguished from the angular grid.
"""
scans = builtins.__dict__.get("scans")
angle = 0
# --- rotation ---
osamroy_current_setpoint = dev.osamroy.user_setpoint.get()
if angle != osamroy_current_setpoint:
umv(dev.osamroy, angle)
else:
print("No rotation required")
# --- alignment offset at angle 0, then push x out of the beam by
# fovx/2, in the direction opposite the normal offset ---
offsets = self.get_alignment_offset(angle)
normal_offset_x = offsets[0] - self.compute_additional_correction_x(angle)
normal_offset_y = (
offsets[1]
- self.compute_additional_correction_y(angle)
- self.compute_additional_correction_y_2(angle)
)
normal_offset_z = offsets[2]
direction = -1 if normal_offset_x >= 0 else 1
sum_offset_x = normal_offset_x + direction * (self.fovx / 2) + direction * 20
self.laser_tracker_on()
umv(dev.rtx, sum_offset_x, dev.rty, normal_offset_y, dev.rtz, normal_offset_z)
self.laser_tracker_check_and_wait_for_signalstrength()
dev.osamx.omny_osamx_to_scan_center(sum_offset_x)
print("Acquiring 10 empty frames at angle 0, samx/rtx shifted out of the beam.")
start_scan_number = bec.queue.next_scan_number
scans.acquire(exp_time=self.tomo_countingtime, frames_per_trigger=10)
end_scan_number = bec.queue.next_scan_number
for scan_nr in range(start_scan_number, end_scan_number):
self._write_tomo_scan_number(scan_nr, angle, subtomo_number=0)
def tomo_scan_projection(self, angle: float):
scans = builtins.__dict__.get("scans")
@@ -680,6 +680,14 @@ class RtOMNYController(Controller):
print("Failed to reach laser target position.")
raise RtError("Failed to reach laser target position.")
# move_samx_to_scan_region is declared in USER_ACCESS above but was never
# implemented here -- checked while wiring up OMNY.collect_empty_frames()
# (2026-09-05): the real, already-working equivalent for OMNY is
# OMNYGalilMotor.omny_osamx_to_scan_center() (ogalil_ophyd.py), called
# from omny_fermat_scan.py's prepare_setup_part2() on every projection
# -- ported from the same original SPEC macro this docstring would have
# quoted, already wired into regular scanning. Not duplicating it here.
def laser_tracker_print_intensity_for_otrack_tweaking(self):
# self.laser_update_tracker_info()
# _laser_tracker_intensity = self.tracker_info["tracker_intensity"]
@@ -71,6 +71,7 @@ def make_omny_for_tomo_scan(monkeypatch, tomo_angle_stepsize: float, active_acco
obj._current_special_angles = []
obj._at_each_angle_hooks = {} # normally set by _init_tomo_queue()
obj.write_pdf_report = lambda: None
obj.collect_empty_frames = lambda: None
obj.sub_tomo_scan = lambda subtomo_number, n_subtomos=2, start_angle=None: None
obj.omnygui_show_progress = lambda: None
obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None)
@@ -122,6 +123,24 @@ def test_tomo_scan_registers_sample_with_plain_string_account(monkeypatch):
assert omny.tomo_id == 42
def test_tomo_scan_collects_empty_frames_on_new_scan_not_on_resume(monkeypatch):
"""collect_empty_frames() (flat-field acquisition at angle 0, sample
shifted out of the beam) belongs to starting a fresh tomo_scan(), not to
resuming an interrupted one -- mirrors flomni's own placement, inside
the same "a new tomo scan was started" branch as the sample-database
registration and progress-field reset just above it."""
omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
calls = []
omny.collect_empty_frames = lambda: calls.append("collected")
omny.tomo_scan()
assert calls == ["collected"]
calls.clear()
omny.tomo_scan(subtomo_start=2, start_angle=90.0)
assert calls == []
def test_tomo_scan_shows_progress_gui(monkeypatch):
"""tomo_scan() must open/show the progress GUI unconditionally at the
start of every scan -- previously only shown per-angle inside
@@ -460,3 +479,115 @@ def test_write_tomo_scan_number_creates_missing_directory(monkeypatch, tmp_path)
written = (tmp_path / "data" / "raw" / "logs" / "tomography_scannumbers.txt").read_text()
assert written == "844 12.5 12.500 1 1 0 test_sample\n"
# --- collect_empty_frames(): flat-field acquisition at scan start, ported
# from flomni's own collect_empty_frames() for OMNY's device names and its
# already-existing osamx recenter (dev.osamx.omny_osamx_to_scan_center(),
# the same one every real projection already goes through via
# omny_fermat_scan.py's prepare_setup_part2() -- not anything bespoke). ----
def _make_omny_for_collect_empty_frames(monkeypatch, osamroy_setpoint=45.0):
"""Bare OMNY instance with collect_empty_frames()'s dependencies stubbed:
a fake device tree (osamroy/rtx/rty/rtz/osamx), umv(), alignment-offset
math, and the laser-tracker/recenter calls -- everything real hardware
control, nothing scan-content-computation, so the test only checks the
sequence/values collect_empty_frames() itself is responsible for."""
calls = []
fake_osamx = types.SimpleNamespace(
omny_osamx_to_scan_center=lambda cenx: calls.append(("recenter", cenx))
)
fake_dev = types.SimpleNamespace(
osamroy=types.SimpleNamespace(
user_setpoint=types.SimpleNamespace(get=lambda: osamroy_setpoint),
read=lambda: {"osamroy": {"value": 0.0}},
),
rtx=object(),
rty=object(),
rtz=object(),
osamx=fake_osamx,
)
monkeypatch.setattr(omny_module, "dev", fake_dev, raising=False)
monkeypatch.setattr(
omny_module, "umv", lambda *args: calls.append(("umv", args)), raising=False
)
class _FakeQueue:
def __init__(self):
self.next_scan_number = 100
fake_bec = types.SimpleNamespace(queue=_FakeQueue())
monkeypatch.setattr(omny_module, "bec", fake_bec, raising=False)
def _fake_acquire(**kwargs):
calls.append(("acquire", kwargs))
fake_bec.queue.next_scan_number += 1
fake_scans = types.SimpleNamespace(acquire=_fake_acquire)
monkeypatch.setitem(builtins.__dict__, "scans", fake_scans)
omny = object.__new__(OMNY)
omny.client = FakeGlobalVarClient()
omny.tomo_id = 1
omny.tomo_countingtime = 0.2
omny.fovx = 40.0
omny.get_alignment_offset = lambda angle: (5.0, 1.0, 0.0)
omny.compute_additional_correction_x = lambda angle: 0.0
omny.compute_additional_correction_y = lambda angle: 0.0
omny.compute_additional_correction_y_2 = lambda angle: 0.0
omny.laser_tracker_on = lambda: calls.append(("tracker_on",))
omny.laser_tracker_check_and_wait_for_signalstrength = lambda: calls.append(("tracker_check",))
written_scan_numbers = []
omny._write_tomo_scan_number = (
lambda scan_nr, angle, subtomo_number: written_scan_numbers.append(
(scan_nr, angle, subtomo_number)
)
)
return omny, calls, written_scan_numbers
def test_collect_empty_frames_rotates_to_zero_when_not_already_there(monkeypatch):
omny, calls, _ = _make_omny_for_collect_empty_frames(monkeypatch, osamroy_setpoint=45.0)
omny.collect_empty_frames()
rotation_calls = [c for c in calls if c[0] == "umv" and c[1] == (omny_module.dev.osamroy, 0)]
assert len(rotation_calls) == 1
def test_collect_empty_frames_shifts_x_away_from_normal_offset_and_recenters_osamx(monkeypatch):
"""direction is chosen to move AWAY from the normal alignment offset at
angle 0 (normal_offset_x=5.0 here -- positive, so direction=-1), by
fovx/2 + a 20um margin, matching flomni's own formula exactly. osamx
recentering reuses the real dev.osamx.omny_osamx_to_scan_center(), not a
bespoke mechanism -- called with that same shifted x value."""
omny, calls, written_scan_numbers = _make_omny_for_collect_empty_frames(
monkeypatch, osamroy_setpoint=0.0
)
omny.collect_empty_frames()
# no rotation umv call: already at angle 0
assert [c for c in calls if c[0] == "umv" and len(c[1]) == 1] == []
expected_offset_x = (
5.0 + (-1) * (40.0 / 2) + (-1) * 20
) # normal_offset_x + direction*(fovx/2+20)
rtx_moves = [c for c in calls if c[0] == "umv" and len(c[1]) > 1]
assert len(rtx_moves) == 1
assert rtx_moves[0][1][1] == expected_offset_x # umv(dev.rtx, <x>, dev.rty, <y>, dev.rtz, <z>)
assert rtx_moves[0][1][3] == 1.0 # normal_offset_y, unshifted
assert ("recenter", expected_offset_x) in calls
assert ("tracker_on",) in calls
assert ("tracker_check",) in calls
acquire_calls = [c for c in calls if c[0] == "acquire"]
assert acquire_calls == [("acquire", {"exp_time": 0.2, "frames_per_trigger": 10})]
# one acquire call bumped next_scan_number by 1 (100 -> 101) -- exactly
# one scan number logged, with subtomo_number=0 (flat field, not part
# of the angular grid)
assert written_scan_numbers == [(100, 0, 0)]