Flomni fixes during beamtimes 1 #317

Merged
holler merged 28 commits from flomni_fixes_during_beamtimes into main 2026-09-15 08:30:21 +02:00
26 changed files with 863 additions and 137 deletions
@@ -18,6 +18,11 @@ import numpy as np
from bec_lib import bec_logger
from bec_lib.alarm_handler import AlarmBase
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import (
filters_out_of_beam,
warn_and_confirm,
)
from .lamni import LamNI
logger = bec_logger.logger
@@ -56,7 +61,7 @@ class MagLamNI(LamNI):
lamni_at_each_angle(self, angle)
return
self.tomo_scan_projection(angle)
self.tomo_scan_projection(angle, _internal=True)
self.tomo_reconstruct()
@@ -87,6 +92,14 @@ class DataDrivenLamNI(LamNI):
"""
bec = builtins.__dict__.get("bec")
scans = builtins.__dict__.get("scans")
dev = builtins.__dict__.get("dev")
all_out, offending = filters_out_of_beam(dev)
if not all_out:
warning = f"Not all filters are out of the beam: {', '.join(offending)}."
if not warn_and_confirm(self, warning, interactive=True, force=False):
print("Aborting tomo scan.")
return
bec.builtin_actors.scan_interlock.trigger_setting = "restart_scan"
bec.builtin_actors.scan_interlock.enabled = True
@@ -14,6 +14,10 @@ from typeguard import typechecked
from csaxs_bec.bec_ipython_client.plugins.LamNI.gui_tools import LamniGuiTools
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_alignment_mixin import LamNIAlignmentMixin
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import (
filters_out_of_beam,
warn_and_confirm,
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import (
OMNYTools,
PtychoReconstructor,
@@ -224,7 +228,6 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
self.tomo_id_manager = TomoIDManager()
self.OMNYTools = OMNYTools(self.client)
self.tomo_id = -1
self.special_angles = []
self.special_angle_repeats = 20
self.special_angle_tolerance = 20
@@ -1159,9 +1162,33 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
# Scan projection
# ------------------------------------------------------------------
def tomo_scan_projection(self, angle: float):
def tomo_scan_projection(self, angle: float, _internal: bool = False):
"""Acquire one fermat-scan projection at `angle`.
_internal=True is passed by callers that already sit inside an
already-checked flow (tomo_scan()'s/tomo_alignment_scan()'s own
per-angle loop, via _at_each_angle) so the filter-out-of-beam check
below only runs once per tomogram/alignment run instead of once per
projection. Direct/standalone calls (the default) are checked every
time, mirroring Flomni.tomo_scan_projection()'s _internal convention.
"""
scans = builtins.__dict__.get("scans")
if not _internal:
dev = builtins.__dict__.get("dev")
all_out, offending = filters_out_of_beam(dev)
if not all_out:
warning = f"Not all filters are out of the beam: {', '.join(offending)}."
if not warn_and_confirm(self, warning, interactive=True, force=False):
# Raise rather than return: this can run inside a
# per-angle loop (directly, or via a custom
# at_each_angle hook), and a bare return would leave the
# projection silently missing / the job stuck running.
raise LamNIError(
"tomo_scan_projection: declined to proceed with filter(s) "
f"in the beam ({', '.join(offending)})."
)
additional_correction = self.compute_additional_correction(angle)
additional_correction_2 = self.compute_additional_correction_2(angle)
correction_xeye_mu = self.lamni_compute_additional_correction_xeye_mu(angle)
@@ -1262,6 +1289,13 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
bec = builtins.__dict__.get("bec")
dev = builtins.__dict__.get("dev")
all_out, offending = filters_out_of_beam(dev)
if not all_out:
warning = f"Not all filters are out of the beam: {', '.join(offending)}."
if not warn_and_confirm(self, warning, interactive=True, force=force):
print("Aborting alignment scan.")
return
self.leye_out()
self.write_alignment_scan_numbers(bec.queue.next_scan_number)
@@ -1281,7 +1315,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
while not successful:
try:
start_scan_number = bec.queue.next_scan_number
self.tomo_scan_projection(angle)
self.tomo_scan_projection(angle, _internal=True)
except AlarmBase as exc:
if exc.alarm_type == "TimeoutError":
bec.queue.request_queue_reset()
@@ -1333,7 +1367,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
hook(self, angle)
return
self.tomo_scan_projection(angle)
self.tomo_scan_projection(angle, _internal=True)
self.tomo_reconstruct()
# ------------------------------------------------------------------
@@ -1667,6 +1701,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
)
time.sleep(10)
dev = builtins.__dict__.get("dev")
all_out, offending = filters_out_of_beam(dev)
if not all_out:
warning = f"Not all filters are out of the beam: {', '.join(offending)}."
if not warn_and_confirm(self, warning, interactive=interactive, force=force):
print("Aborting tomo scan.")
return
self.lamnigui_show_progress()
bec = builtins.__dict__.get("bec")
@@ -92,6 +92,9 @@ _HISTORY_MAX = 50 # events persisted per stream
_HISTORY_SHOW = 5 # events surfaced to the page per stream
_TRACE_MAX = 288 # ring-current samples (24 h at 5 min)
_FLAP_COALESCE_S = 60 # re-alarm within this window extends the event
_ALARM_TEXTS_MAX = 100 # distinct alarm texts kept on a single open eps_event;
# only one event is ever open at a time, so this bounds
# total history growth during a long-running flapping alarm
_SEVERITY_NAMES = {0: "NO_ALARM", 1: "MINOR", 2: "MAJOR", 3: "INVALID"}
@@ -235,7 +238,9 @@ class HistoryTracker:
if open_ev is not None:
open_ev["count"] = max(open_ev.get("count", 0), count)
if text and text not in open_ev.get("texts", []):
open_ev.setdefault("texts", []).append(text)
texts = open_ev.setdefault("texts", [])
texts.append(text)
del texts[:-_ALARM_TEXTS_MAX]
return
# coalesce a rapid re-alarm with identical text
if self.eps_events:
@@ -0,0 +1,46 @@
import time
from csaxs_bec.bec_ipython_client.plugins.cSAXS.filter_transmission import cSAXSFilterTransmission
def filters_out_of_beam(dev, tol: float = 0.1) -> tuple[bool, list[str]]:
"""Check whether all cSAXS exposure-box filters are retracted.
Reuses cSAXSFilterTransmission's own axis list/position table (index 0 of
each row is the "out" position) so there is one source of truth for what
"out" means, matching csaxs.fil_trans()/_fil_trans_report()'s own check.
"""
offending = []
for axis_name, positions in zip(
cSAXSFilterTransmission._AXES, cSAXSFilterTransmission._POSITIONS_USER
):
axis_obj = getattr(dev, axis_name, None)
out_position = positions[0]
if axis_obj is None or out_position is None:
continue
try:
rb = float(axis_obj.readback.get())
except Exception:
continue
if abs(rb - out_position) > tol:
offending.append(axis_name)
return not offending, offending
def warn_and_confirm(setup, warning: str, interactive: bool = True, force: bool = False) -> bool:
"""Eye/optics-check-style warn-and-confirm gate, reusable across plugins.
Returns True if the caller should proceed. Interactive: red warning + a
"Continue anyway?" prompt defaulting to no. Non-interactive (queued/
unattended): warns, waits 10s, and always proceeds -- matches
LamNI.tomo_scan()'s existing eye/optics gate so queued jobs never hang on
input().
"""
if force:
return True
setup.OMNYTools.printredbold(f"WARNING: {warning}")
if interactive:
return setup.OMNYTools.yesno("Continue anyway?", "n")
setup.OMNYTools.printredbold("Proceeding automatically in 10 s (unattended/queued run)...")
time.sleep(10)
return True
@@ -327,12 +327,11 @@ class TomoIDManager:
"""Registers a tomography measurement in the OMNY sample database
and returns its assigned tomo ID.
Non-production accounts (e.g. test accounts like "gac-x01dc") register
against the test server (TEST_OMNY_URL) instead of production, so
testing still gets a real, incrementing tomo ID -- matching the
counter the samples-folder PDF upload reads from on that same test
host -- accepting that the eaccount recorded in that test database
won't be a real e-account.
Non-production accounts (e.g. test accounts like "gac-x01dc") are not
registered against OMNY_URL at all -- there is no longer a separate
test server to redirect them to, and registering test runs against
the production sample database would pollute it. They get
FALLBACK_TOMO_ID instead.
Usage:
id_manager = TomoIDManager()
@@ -348,7 +347,6 @@ class TomoIDManager:
"""
OMNY_URL = "https://omny.psi.ch/samples/newmeasurement.php"
TEST_OMNY_URL = "https://omny.psi.ch/samples/newmeasurement.php"
FALLBACK_TOMO_ID = 0
@staticmethod
@@ -368,20 +366,21 @@ class TomoIDManager:
) -> int:
"""Register a new measurement and return the assigned tomo ID.
Registers against OMNY_URL (production) for a real e-account, or
TEST_OMNY_URL (test server) otherwise. Returns FALLBACK_TOMO_ID (0)
only if the server actually can't be reached / returns an unusable
response.
Registers against OMNY_URL for a real e-account. For any other
account (e.g. a test/"gac-*" account), skips registration entirely
and returns FALLBACK_TOMO_ID (0) -- there is no separate test
server to register against, and registering test runs against
production would pollute the real sample database. FALLBACK_TOMO_ID
is also returned if the server actually can't be reached / returns
an unusable response.
"""
if self._is_valid_eaccount(eaccount):
omny_url = self.OMNY_URL
else:
omny_url = self.TEST_OMNY_URL
if not self._is_valid_eaccount(eaccount):
logger.warning(
f"Account '{eaccount}' is not a valid e-account; registering "
f"against the test server ({self.TEST_OMNY_URL}) instead of "
"production -- the eaccount recorded there won't be real."
f"Account '{eaccount}' is not a valid e-account; skipping OMNY "
f"registration (no separate test server to register against) "
f"and falling back to tomo ID {self.FALLBACK_TOMO_ID}."
)
return self.FALLBACK_TOMO_ID
params = {
"sample": sample_name,
@@ -407,7 +406,7 @@ class TomoIDManager:
try:
response = requests.get(
omny_url,
self.OMNY_URL,
params=params,
timeout=30,
verify=False, # accept self-signed certs
@@ -213,6 +213,7 @@ class TomoQueueMixin:
# Name of a registered at_each_angle hook (see register_at_each_angle_hook()),
# or None for the default per-projection behaviour.
at_each_angle_hook = _GlobalVarParam(None)
tomo_id = _GlobalVarParam(-1, type_=int)
def _init_tomo_queue(self) -> None:
"""Call once from ``__init__``, after ``self.client`` is set.
@@ -392,9 +393,8 @@ class TomoQueueMixin:
user=user,
)
# Must match whatever host TomoIDManager.OMNY_URL/TEST_OMNY_URL
# registered against, since that host's sample counter is what
# self.tomo_id came from.
# Must match whatever host TomoIDManager.OMNY_URL registered against,
# since that host's sample counter is what self.tomo_id came from.
_SAMPLES_UPLOAD_HOSTS = ("https://omny.psi.ch",)
def _upload_pdf_report_to_samples(self, pdf_path: str) -> None:
@@ -19,6 +19,10 @@ from csaxs_bec.bec_ipython_client.plugins.cSAXS import cSAXSBeamlineChecks
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin import FlomniOpticsMixin
from csaxs_bec.bec_ipython_client.plugins.flomni.gui_tools import flomniGuiTools
from csaxs_bec.bec_ipython_client.plugins.flomni.x_ray_eye_align import XrayEyeAlign
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import (
filters_out_of_beam,
warn_and_confirm,
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import (
TomoQueueMixin,
_GlobalVarParam,
@@ -1044,13 +1048,13 @@ class FlomniSampleTransferMixin:
):
packed_name = pack_desc(name, owner)
if position == 100:
dev.flomni_samples.sample_in_gripper.set(used)
dev.flomni_samples.sample_in_gripper_name.set(packed_name)
dev.flomni_samples.sample_in_gripper.set(used).wait(timeout=5)
dev.flomni_samples.sample_in_gripper_name.set(packed_name).wait(timeout=5)
else:
signal = getattr(dev.flomni_samples.sample_placed, f"sample{position}")
signal.set(used)
signal.set(used).wait(timeout=5)
signal_name = getattr(dev.flomni_samples.sample_names, f"sample{position}")
signal_name.set(packed_name)
signal_name.set(packed_name).wait(timeout=5)
def check_position_is_valid(self, position: int):
if 0 <= position < 21:
@@ -1760,7 +1764,6 @@ class Flomni(
super().__init__()
self.client = client
self.device_manager = client.device_manager
self.tomo_id = -1
self.special_angles = []
self.special_angle_repeats = 20
self.special_angle_tolerance = 20
@@ -2197,6 +2200,13 @@ class Flomni(
dev = builtins.__dict__.get("dev")
bec = builtins.__dict__.get("bec")
all_out, offending = filters_out_of_beam(dev)
if not all_out:
warning = f"Not all filters are out of the beam: {', '.join(offending)}."
if not warn_and_confirm(self, warning, interactive=True, force=False):
print("Aborting tomo alignment scan.")
return
# Only run the full eye-out / optics-in transition if we are not
# already in measurement condition with feedback running. That
# transition disables and re-enables-with-reset the rt feedback, which
@@ -2637,8 +2647,10 @@ class Flomni(
Args:
interactive: accepted for signature compatibility with
LamNI.tomo_scan() (tomo_queue_execute() calls both the same
way) -- unused here, FlOMNI has no fine-alignment gate.
way). FlOMNI has no fine-alignment gate, but it is used by
the filter-out-of-beam check below.
"""
dev = builtins.__dict__.get("dev")
if not self._check_eye_out_and_optics_in():
print(
@@ -2650,6 +2662,13 @@ class Flomni(
print("Stopping.")
return
all_out, offending = filters_out_of_beam(dev)
if not all_out:
warning = f"Not all filters are out of the beam: {', '.join(offending)}."
if not warn_and_confirm(self, warning, interactive=interactive, force=False):
print("Aborting tomo scan.")
return
self.flomnigui_show_progress()
bec = builtins.__dict__.get("bec")
@@ -3099,12 +3118,27 @@ class Flomni(
random_offset_x: float | None = None,
random_offset_y: float | None = None,
):
"""write the tomo reconstruct file for the reconstruction queue"""
"""Write the tomo reconstruct file for the reconstruction queue.
Normally called automatically at the end of tomo_scan_projection()/
tomo_acquire_at_angle(), which keep self._current_scan_list up to
date with the scan number(s) of the projection just acquired
(possibly several, when stitching). When called directly -- e.g.
from the command line after a plain scans.flomni_fermat_scan(),
without going through either of those -- that cached list is either
stale (left over from an earlier tomo scan) or not set at all, so
fall back to just the most recently completed scan number.
"""
bec = builtins.__dict__.get("bec")
next_scan_number = bec.queue.next_scan_number
last_scan_number = next_scan_number - 1
scan_list = getattr(self, "_current_scan_list", None)
if not scan_list or scan_list[-1] != last_scan_number:
scan_list = [last_scan_number]
self.reconstructor.folder_name = self.ptycho_reconstruct_foldername
self.reconstructor.write(
scan_list=self._current_scan_list,
next_scan_number=bec.queue.next_scan_number,
scan_list=scan_list,
next_scan_number=next_scan_number,
base_path=base_path,
probe_file_propagation=probe_propagation,
random_offset_x=random_offset_x,
@@ -3323,10 +3357,11 @@ class Flomni(
def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None:
tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt")
sample_name = self.sample_name.replace(" ", "_")
with open(tomo_scan_numbers_file, "a+") as out_file:
# pylint: disable=undefined-variable
out_file.write(
f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.5f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n"
f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.5f} {self.tomo_id} {subtomo_number} {0} {sample_name}\n"
)
def tomo_scan_projection(self, angle: float, _internal: bool = False):
@@ -3343,6 +3378,21 @@ class Flomni(
_at_each_angle, and tomo_alignment_scan, which needs real ptycho
data) pass _internal=True to skip the prompt.
"""
dev = builtins.__dict__.get("dev")
if not _internal:
all_out, offending = filters_out_of_beam(dev)
if not all_out:
warning = f"Not all filters are out of the beam: {', '.join(offending)}."
if not warn_and_confirm(self, warning, interactive=True, force=False):
# Raise rather than return, for the same reason as the
# fermat-vs-single-point decline below: this can run
# inside a per-angle loop, and a bare return would leave
# the projection silently missing / the job stuck.
raise FlomniError(
"tomo_scan_projection: declined to proceed with filter(s) "
f"in the beam ({', '.join(offending)})."
)
if not _internal and self.single_point_instead_of_fermat_scan:
print(
"\x1b[93mWarning: single_point_instead_of_fermat_scan is set, but"
@@ -3963,10 +4013,55 @@ class Flomni(
stitching = f"{self.stitch_x:.0f}/{self.stitch_y:.0f}"
dataset_id = str(self.client.queue.next_dataset_number)
account = bec.active_account
# Same grid sub_tomo_scan() actually uses -- see
# _tomo_type1_actual_grid()'s docstring for why this can't just
# recompute int((tomo_angle_range/tomo_angle_stepsize)*8) locally.
_, _, tomo_type1_total_projections = self._tomo_type1_actual_grid()
# The correct total-projections / sub-tomogram-count / angular-step
# values depend on which tomography type is actually configured --
# mirrors tomo_parameters()'s tomo_type 1/2/3 branching above. This
# branch used to be missing: write_pdf_report() always called
# _tomo_type1_actual_grid() (int(180/tomo_angle_stepsize)*8), which
# is only meaningful for tomo_type==1. For golden-ratio scans
# (tomo_type==2/3) that silently reported a stale, unrelated
# type-1 total left over in the persistent tomo_angle_stepsize
# global var, instead of self.golden_max_number_of_projections --
# the value the user actually configured and that the web page's
# progress display and tomo_parameters() already use correctly.
if self.tomo_type == 1:
_, achievable_step, total_projections = self._tomo_type1_actual_grid()
subtomo_count_label = "Number of individual sub-tomograms:"
subtomo_count_value = "8"
angle_step_label = "Angular step within sub-tomogram:"
angle_step_value = f"{achievable_step:.2f}"
elif self.tomo_type == 3:
total_projections = (
self.golden_max_number_of_projections
if self.golden_max_number_of_projections > 0
else 0
)
subtomo_count_label = "Number of projections per sub-tomogram:"
subtomo_count_value = f"{180 / self.tomo_angle_stepsize:.1f}"
angle_step_label = "Angular step within sub-tomogram:"
angle_step_value = f"{self.tomo_angle_stepsize:.2f}"
else:
# tomo_type == 2: golden ratio tomography -- there is no fixed
# set of sub-tomograms or a fixed angular step, so report the
# bunch size instead (matches tomo_parameters()'s printout).
total_projections = (
self.golden_max_number_of_projections
if self.golden_max_number_of_projections > 0
else 0
)
subtomo_count_label = "Sorted in bunches of:"
subtomo_count_value = f"{self.golden_ratio_bunch_size}"
angle_step_label = "Angular step within sub-tomogram:"
angle_step_value = "N/A (golden ratio)"
total_projections_str = (
f"{total_projections}" if total_projections else "N/A (manual stop)"
)
last_scan_number_str = (
f"{self.client.queue.next_scan_number + total_projections + 10}"
if total_projections
else "N/A"
)
# Same device ffzp_info() already reads. Defensive: may not be
# configured/available in every session (e.g. simulated configs).
try:
@@ -4016,17 +4111,16 @@ class Flomni(
f"{'Dataset ID:':<{padding}}{dataset_id}\n",
f"{'Sample Info:':<{padding}}Sample Info\n",
f"{'e-account:':<{padding}}{account}\n",
f"{'Number of projections:':<{padding}}{tomo_type1_total_projections}\n",
f"{'Number of projections:':<{padding}}{total_projections_str}\n",
f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number}\n",
f"{'Last scan number approx.:':<{padding}}"
f"{self.client.queue.next_scan_number + tomo_type1_total_projections + 10}\n",
f"{'Last scan number approx.:':<{padding}}{last_scan_number_str}\n",
f"{'Current photon energy:':<{padding}}{energy_str}\n",
f"{'Exposure time:':<{padding}}{self.tomo_countingtime:.2f}\n",
f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:.2f}\n",
f"{'FOV:':<{padding}}{fovxy}\n",
f"{'Stitching:':<{padding}}{stitching}\n",
f"{'Number of individual sub-tomograms:':<{padding}}8\n",
f"{'Angular step within sub-tomogram:':<{padding}}{self.tomo_angle_stepsize:.2f}\n",
f"{subtomo_count_label:<{padding}}{subtomo_count_value}\n",
f"{angle_step_label:<{padding}}{angle_step_value}\n",
f"{'FZP diameter:':<{padding}}{fzp_diameter_um} microns\n",
f"{'FZP outermost zone width:':<{padding}}{fzp_zone_width_nm} nm\n",
f"{'FZP details:':<{padding}}{fzp_details}\n",
@@ -167,6 +167,13 @@ class FlomniOpticsMixin:
dev.rtx.controller.feedback_enable_with_reset()
def fosa_in(self):
# TODO(commissioning): the OSA travels inside the heater's (fheater)
# envelope, but this function never checks fheater position before
# driving fosaz. fosaz must only move while fheater is fully "up" or
# fully "down" -- an intermediate heater position here risks a
# collision. Add a heater-state check (see ensure_osa_back()/
# ensure_fheater_up() in flomni.py for the pattern) once fheater's
# up/down limits are commissioned.
# 6.2 keV, 170 um FZP
# umv(dev.losax, -1.4450000, dev.losay, -0.1800)
# umv(dev.losaz, -1)
@@ -48,7 +48,12 @@ class flomniGuiTools:
def flomnigui_show_gui(self):
if "flomni" in self.gui.windows:
self.flomni_window = self.gui.windows["flomni"]
self.gui.flomni.raise_window()
# raise_window() is currently buggy (bec_widgets RPCServer "raise" handling can
# detach/hide the window instead of bringing it to front) - disabled so that
# calling a flomnigui_show_* macro again on an already-open GUI is a safe no-op
# instead of risking hiding the window. See flomnigui_raise() above for the same
# workaround.
# self.gui.flomni.raise_window()
else:
# geometry: (pos_x, pos_y, w, h)
pos_x = self._SCREEN_WIDTH - self._WINDOW_WIDTH
@@ -60,7 +65,8 @@ class flomniGuiTools:
self.gui.flomni.hide()
def flomnigui_raise(self):
self.gui.flomni.raise_window()
pass
#self.gui.flomni.raise_window()
def flomnigui_show_xeyealign(self):
self.flomnigui_show_gui()
@@ -0,0 +1,46 @@
# TODO: OMNY is missing two pre-scan safety checks that flomni/LamNI now have
Both items below were found while implementing a filter-out-of-beam check for
flomni and LamNI (branch `flomni_fixes_during_beamtimes`). OMNY was deliberately
left out of that work to avoid conflicting with other in-progress OMNY changes on
a separate branch — do both of these there.
## 1. Filter-out-of-beam check (new feature, not yet on OMNY at all)
flomni (`flomni.py`) and LamNI (`lamni.py`) now warn hard if any of the four cSAXS
exposure-box filters (`filter_array_1_x``filter_array_4_x`) are not at their "out"
position before a scan starts. The check itself is already shared/reusable:
- `csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py`
- `filters_out_of_beam(dev, tol=0.1) -> (bool, list[str])`
- `warn_and_confirm(setup, warning, interactive=True, force=False) -> bool` — red
warning + "Continue anyway?" prompt (default no) when interactive; warning + 10s
pause + always-proceed when not (for unattended/queued runs).
To wire it into OMNY, mirror what was done in `flomni.py`/`lamni.py`:
- `OMNY.tomo_scan()` (`omny.py:1005`): add a check at the top, right where
`Flomni.tomo_scan()`/`LamNI.tomo_scan()` now have theirs — note OMNY's
`tomo_scan()` currently has **no `interactive`/`force` params at all** (OMNY has no
tomo-queue, see item 2 below, so there's no unattended-run case to support yet);
just call `warn_and_confirm(self, warning, interactive=True, force=False)`.
- `OMNY.tomo_scan_projection()` (`omny.py:1244`): currently `def
tomo_scan_projection(self, angle: float)` — no `_internal` flag. Add
`_internal: bool = False`, gate the filter check on `not _internal`, and raise
`OMNYError` on decline (same rationale as flomni/LamNI: a bare `return` inside a
per-angle loop can silently drop a projection). Update its internal call sites to
pass `_internal=True`: `_at_each_angle()` (`omny.py:1166`) and the loop call at
`omny.py:893`.
## 2. Missing eye-out/optics-in check (pre-existing gap, unrelated to filters)
Discovered independently while researching the filter check: `Flomni.tomo_scan()`
and `LamNI.tomo_scan()` both hard-warn if `_check_eye_out_and_optics_in()` fails
(X-ray eye IN / optics OUT is not a valid measurement configuration). **`OMNY.tomo_scan()`
(`omny.py:1005`) has no equivalent check at all** — there's no OMNY-side
`_check_eye_out_and_optics_in()`, and nothing calls one. Worth adding the same gate,
following `flomni_optics_mixin.py`/`lamni_optics_mixin.py`'s
`_check_eye_out_and_optics_in()` implementations as the template (device names will
differ for OMNY's axes).
Not scoped/designed further here — just flagging it so it isn't lost.
@@ -0,0 +1,68 @@
# TODO: OMNY.tomo_reconstruct() writes a stale/missing scan list
Found while fixing the same bug in `Flomni.tomo_reconstruct()`
(`csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py:3114`, branch
`flomni_fixes_during_beamtimes`). Not applied to OMNY yet to avoid conflicting
with other in-progress OMNY changes on a separate branch — do it there.
## The bug
`OMNY.tomo_reconstruct()` (`csaxs_bec/bec_ipython_client/plugins/omny/omny.py:1217`)
names the reconstruction queue file from a fresh `bec.queue.next_scan_number`
(so the *filename* is always correct), but writes the file's *content* from
`self._current_scan_list`:
```python
def tomo_reconstruct(self, base_path="~/Data10/specES1"):
"""write the tomo reconstruct file for the reconstruction queue"""
bec = builtins.__dict__.get("bec")
base_path = os.path.expanduser(base_path)
ptycho_queue_path = Path(os.path.join(base_path, self.ptycho_reconstruct_foldername))
ptycho_queue_path.mkdir(parents=True, exist_ok=True)
last_scan_number = bec.queue.next_scan_number - 1
ptycho_queue_file = os.path.abspath(
os.path.join(ptycho_queue_path, f"scan_{last_scan_number:05d}.dat")
)
with open(ptycho_queue_file, "w") as queue_file:
scans = " ".join([str(scan) for scan in self._current_scan_list])
queue_file.write(f"p.scan_number {scans}\n")
queue_file.write("p.check_nextscan_started 1\n")
```
`self._current_scan_list` is only kept up to date by whichever internal method
last ran and then called `tomo_reconstruct()` itself right after (the OMNY
equivalents of flomni's `tomo_scan_projection()`/`tomo_acquire_at_angle()`
check where `_current_scan_list` is assigned in `omny.py` for the exact call
sites). Call `tomo_reconstruct()` directly from the command line instead —
e.g. after a plain fermat scan run by hand, not through those internal
flows — and the file gets written with whatever scan list happened to be
cached from the *previous* tomo scan (or raises `AttributeError` if none ran
yet this session). Filename right, content wrong/stale.
## The fix (already applied to flomni, mirror it here)
`Flomni.tomo_reconstruct()` now falls back to just the most recently
completed scan number whenever the cached list doesn't actually match it:
```python
bec = builtins.__dict__.get("bec")
next_scan_number = bec.queue.next_scan_number
last_scan_number = next_scan_number - 1
scan_list = getattr(self, "_current_scan_list", None)
if not scan_list or scan_list[-1] != last_scan_number:
scan_list = [last_scan_number]
```
Internal callers are unaffected (their cached list's last entry always equals
`next_scan_number - 1` at the point they call `tomo_reconstruct()`), while a
direct/standalone call now correctly falls back to `[last_scan_number]`
instead of writing stale content or crashing.
Port the same `getattr(...)`/fallback logic into `OMNY.tomo_reconstruct()`
(note OMNY's version doesn't go through the shared `PtychoReconstructor`
class like flomni's does — it writes the file inline — so the fix applies
directly to the `scans = " ".join(...)` line, not to a shared `write()`
method).
Not scoped/designed further here — just flagging it so it isn't lost.
@@ -1235,10 +1235,11 @@ class OMNY(
tomo_scan_numbers_file = os.path.expanduser(
"~/Data10/specES1/dat-files/tomography_scannumbers.txt"
)
sample_name = self.sample_name.replace(" ", "_")
with open(tomo_scan_numbers_file, "a+") as out_file:
# pylint: disable=undefined-variable
out_file.write(
f"{scan_number} {angle} {dev.osamroy.read()['osamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n"
f"{scan_number} {angle} {dev.osamroy.read()['osamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {sample_name}\n"
)
def tomo_scan_projection(self, angle: float):
+18 -18
View File
@@ -1,26 +1,26 @@
# eiger_1_5:
# description: Eiger 1.5M in-vacuum detector
# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M
# deviceConfig:
# detector_distance: 2200
# beam_center: [870, 1203]
# onFailure: raise
# enabled: True
# readoutPriority: async
# softwareTrigger: False
# deviceTags:
# - bl_detectors
eiger_9:
description: Eiger 9M detector
deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M
eiger_1_5:
description: Eiger 1.5M in-vacuum detector
deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M
deviceConfig:
detector_distance: 6975.225
beam_center: [1149.71, 1450.89]
detector_distance: 2200
beam_center: [870, 1203]
onFailure: raise
enabled: True
readoutPriority: async
softwareTrigger: False
deviceTags:
- bl_detectors
# eiger_9:
# description: Eiger 9M detector
# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M
# deviceConfig:
# detector_distance: 6975.225
# beam_center: [1149.71, 1450.89]
# onFailure: raise
# enabled: True
# readoutPriority: async
# softwareTrigger: False
# ids_cam:
# description: IDS camera for live image acquisition
+6 -6
View File
@@ -16,17 +16,17 @@ endstation:
detectors:
- !include ./bl_detectors.yaml
xeye:
- !include ./xeye.yaml
# xeye:
# - !include ./xeye.yaml
ssaxs:
- !include ./ssaxs.yaml
# ssaxs:
# - !include ./ssaxs.yaml
#sastt:
# - !include ./sastt.yaml
# flomni:
# - !include ./ptycho_flomni.yaml
flomni:
- !include ./ptycho_flomni.yaml
#omny:
# - !include ./ptycho_omny.yaml
+64 -43
View File
@@ -21,8 +21,8 @@ feyex:
userParameter:
in: -16.453
out: -1
fttrx_in: 2.3
fttrx_out: -24
fttrx_in: -2.4
fttrx_out: -27
deviceTags:
- ptycho_flomni
@@ -46,6 +46,13 @@ feyey:
in: -10.09
deviceTags:
- ptycho_flomni
# TODO(commissioning): needs userParameter (e.g. in/up, down) -- move_fheater_down()
# in flomni.py currently reads user_parameter.get("in") and gets None.
# KNOWN GAP: fosa_in()/foptics_in() (flomni_optics_mixin.py) never check fheater
# position before driving fosaz. The OSA travels inside the heater's envelope, so
# fosaz must only move while fheater is fully at its "up" or fully at its "down"
# limit -- an intermediate heater position during fosa_in() risks a collision.
# Verify/add that check once real up/down limits are known from hardware.
fheater:
description: Heater Y
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
@@ -82,16 +89,19 @@ foptx:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
#170 micron, 60 nm
in: -13.831
#150 micros, 60
in: -14.191
out: -13.831
#170 micron, 60 nm
#in: -13.8909
#out: -13.831
#250 micron, 30 nm, Abe structures
# in: -13.8809375
# out: -14.1809
#250 micron, 30 nm, Tomas structures
# in: -14.5490625
# out: -14.1809
fzp_diameter: 170 # microns
fzp_diameter: 150 # microns
fzp_outermost_zone_width: 60 # nm
detector_distance: -1 # mm, sample-to-detector; unknown for now
fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes
@@ -115,9 +125,12 @@ fopty:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
#170 micron, 60 nm
in: 0.42
#150 micron, 60
in: 1.02
out: 0.57
#170 micron, 60 nm
#in: 0.42
#out: 0.57
#250 micron, 30 nm, Abe structures
# in: 2.8299
# out: 2.8299
@@ -277,7 +290,7 @@ ftransy:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
sensor_voltage: -1.6
sensor_voltage: -2.2
deviceTags:
- ptycho_flomni
ftransz:
@@ -354,12 +367,15 @@ fosax:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
#150micron, 60 nm, 7.9 kev
in: 9.0393
out: 5.1
#170 micron, 60 nm, 7.6 kev
#in: 8.7568
#out: 5.1
#170 micron, 60 nm, 7.9 kev
in: 8.727079
out: 5.1
#n: 8.722151
#ut: 5.1
#250 micron, 30 nm, Abe structures
# in: 8.7392
# out: 5.1
@@ -386,10 +402,12 @@ fosay:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
#150 micron, 60 nm, 7.9 kev
in: -0.6422
#170 micron, 60 nm, 7.6 kev
#in: -0.0235
#170 micron, 60 nm, 7.9 kev
in: -0.04603
#in: -0.0563
#250 micron, 30 nm, Abe structures
# in: -2.3684
#250 micron, 30 nm, Tomas structures
@@ -413,12 +431,15 @@ fosaz:
readoutPriority: baseline
connectionTimeout: 20
userParameter:
#150 micron, 60 nm, 7.9 kev, foptz 16.9, probe size 7.5 mu
in: 11.0998
out: 6
#170 micron, 60 nm, 7.6 kev
#in: 8.5
#out: 6
#170 micron, 60 nm, 7.9 kev, foptz 16.9, probe size 7.5 mu
in: 13.1
out: 6
# in: 13.1
# out: 6
# micron, 30 nm, 7.9 kev, very close to the sample. make sure foptz is 32.02 or smaller //abe's fzp's
# in: 0.5
# out: -5
@@ -625,33 +646,33 @@ calculated_signal:
############################################################
#################### OMNY Pandabox #########################
############################################################
omny_panda:
readoutPriority: async
deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY
deviceConfig:
host: omny-panda.psi.ch
signal_alias:
FMC_IN.VAL1.Min: cap_voltage_fzp_y_min
FMC_IN.VAL1.Max: cap_voltage_fzp_y_max
FMC_IN.VAL1.Mean: cap_voltage_fzp_y_mean
FMC_IN.VAL2.Min: cap_voltage_fzp_x_min
FMC_IN.VAL2.Max: cap_voltage_fzp_x_max
FMC_IN.VAL2.Mean: cap_voltage_fzp_x_mean
INENC1.VAL.Max: interf_st_fzp_y_max
INENC1.VAL.Mean: interf_st_fzp_y_mean
INENC1.VAL.Min: interf_st_fzp_y_min
INENC2.VAL.Max: interf_st_fzp_x_max
INENC2.VAL.Mean: interf_st_fzp_x_mean
INENC2.VAL.Min: interf_st_fzp_x_min
INENC3.VAL.Max: interf_st_rotz_max
INENC3.VAL.Mean: interf_st_rotz_mean
INENC3.VAL.Min: interf_st_rotz_min
INENC4.VAL.Max: interf_st_rotx_max
INENC4.VAL.Mean: interf_st_rotx_mean
INENC4.VAL.Min: interf_st_rotx_min
PCAP.GATE_DURATION.Value: pcap_gate_duration_value
enabled: true
readOnly: false
softwareTrigger: false
deviceTags:
- ptycho_flomni
# omny_panda:
# readoutPriority: async
# deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY
# deviceConfig:
# host: omny-panda.psi.ch
# signal_alias:
# FMC_IN.VAL1.Min: cap_voltage_fzp_y_min
# FMC_IN.VAL1.Max: cap_voltage_fzp_y_max
# FMC_IN.VAL1.Mean: cap_voltage_fzp_y_mean
# FMC_IN.VAL2.Min: cap_voltage_fzp_x_min
# FMC_IN.VAL2.Max: cap_voltage_fzp_x_max
# FMC_IN.VAL2.Mean: cap_voltage_fzp_x_mean
# INENC1.VAL.Max: interf_st_fzp_y_max
# INENC1.VAL.Mean: interf_st_fzp_y_mean
# INENC1.VAL.Min: interf_st_fzp_y_min
# INENC2.VAL.Max: interf_st_fzp_x_max
# INENC2.VAL.Mean: interf_st_fzp_x_mean
# INENC2.VAL.Min: interf_st_fzp_x_min
# INENC3.VAL.Max: interf_st_rotz_max
# INENC3.VAL.Mean: interf_st_rotz_mean
# INENC3.VAL.Min: interf_st_rotz_min
# INENC4.VAL.Max: interf_st_rotx_max
# INENC4.VAL.Mean: interf_st_rotx_mean
# INENC4.VAL.Min: interf_st_rotx_min
# PCAP.GATE_DURATION.Value: pcap_gate_duration_value
# enabled: true
# readOnly: false
# softwareTrigger: false
# deviceTags:
# - ptycho_flomni
+10 -10
View File
@@ -64,30 +64,30 @@ class FlomniSampleStorage(Device):
if slot_nr > 20:
raise FlomniSampleStorageError(f"Invalid slot number {slot_nr}.")
getattr(self.sample_placed, f"sample{slot_nr}").set(1)
getattr(self.sample_names, f"sample{slot_nr}").set(pack_desc(name, owner))
getattr(self.sample_placed, f"sample{slot_nr}").set(1).wait(timeout=5)
getattr(self.sample_names, f"sample{slot_nr}").set(pack_desc(name, owner)).wait(timeout=5)
def unset_sample_slot(self, slot_nr: int) -> bool:
if slot_nr > 20:
raise FlomniSampleStorageError(f"Invalid slot number {slot_nr}.")
getattr(self.sample_placed, f"sample{slot_nr}").set(0)
getattr(self.sample_names, f"sample{slot_nr}").set("-")
getattr(self.sample_placed, f"sample{slot_nr}").set(0).wait(timeout=5)
getattr(self.sample_names, f"sample{slot_nr}").set("-").wait(timeout=5)
def set_sample_in_gripper(self, name: str, owner: str = "") -> bool:
self.sample_in_gripper.set(1)
self.sample_in_gripper_name.set(pack_desc(name, owner))
self.sample_in_gripper.set(1).wait(timeout=5)
self.sample_in_gripper_name.set(pack_desc(name, owner)).wait(timeout=5)
def unset_sample_in_gripper(self) -> bool:
self.sample_in_gripper.set(0)
self.sample_in_gripper_name.set("-")
self.sample_in_gripper.set(0).wait(timeout=5)
self.sample_in_gripper_name.set("-").wait(timeout=5)
def is_sample_slot_used(self, slot_nr: int) -> bool:
val = getattr(self.sample_placed, f"sample{slot_nr}").get()
val = getattr(self.sample_placed, f"sample{slot_nr}").get(use_monitor=False)
return bool(val)
def is_sample_in_gripper(self) -> bool:
val = self.sample_in_gripper.get()
val = self.sample_in_gripper.get(use_monitor=False)
return bool(val)
def get_sample_name(self, slot_nr) -> str:
@@ -58,9 +58,17 @@ class GalilController(Controller):
@threadlocked
def socket_put(self, val: str) -> None:
time.sleep(0.01)
self.command_history.append(f"[PUT]: {val}")
self.sock.put(f"{val}\r".encode())
@threadlocked
def socket_get(self) -> str:
time.sleep(0.01)
response = self.sock.receive().decode()
self.command_history.append(f"[GET]: {response}")
return response
@retry_once
def socket_put_confirmed(self, val: str) -> None:
"""Send message to controller and ensure that it is received by checking that the socket receives a colon.
+2 -1
View File
@@ -375,7 +375,7 @@ class RtFlomniController(Controller):
return False
def laser_tracker_wait_on_target(self):
max_repeat = 25
max_repeat = 100
count = 0
self.laser_tracker_galil_enable()
while not self.laser_tracker_on_target():
@@ -383,6 +383,7 @@ class RtFlomniController(Controller):
time.sleep(0.5)
count += 1
if count == 10:
print("Waiting for the tracker.")
ftrackz_con = self.device_manager.devices.ftrackz.obj.controller
ftrackz_con.socket_put_confirmed("tracken=1")
ftrackz_con.socket_put_confirmed("trackyct=0")
+3
View File
@@ -101,6 +101,9 @@ class FlomniFermatScan(ScanBase):
Returns:
ScanReport
Examples:
>>> scans.flomni_fermat_scan(10, 10, 0, 0, 1, 0, 0, exp_time=0.1, frames_per_trigger=1, burst_at_each_point=1)
"""
super().__init__(**kwargs)
self._baseline_readout_status = None
+3
View File
@@ -93,6 +93,9 @@ class OmnyFermatScan(ScanBase):
Returns:
ScanReport
Examples:
>>> scans.omny_fermat_scan(10, 10, 0, 0, 1, 0, 0, exp_time=0.1, frames_per_trigger=1, readout_time=0)
"""
super().__init__(**kwargs)
self._baseline_readout_status = None
+330
View File
@@ -0,0 +1,330 @@
# Plan: Manual exposure / auto-gain control for IDSCamera + xrayeye widget knobs
Status: **planned, not yet implemented** (`IDSCamera` and the `OMNY_XRayEye` GUI widget
are both used in production during beamtimes; implementation should happen as its own
change, reviewed and tested outside a live beamtime).
## Context
`IDSCamera` (the cSAXS ophyd device wrapping IDS uEye cameras via the vendor's `pyueye`
SDK) currently gives operators no way to leave auto-exposure/auto-gain and drive the
camera manually. The underlying driver already has almost everything needed — it's just
not wired up or exposed to BEC clients:
- `Camera.exposure_time` (get/set property) — `base_integration/camera.py:220-235`
- `Camera.set_auto_shutter(enable)``base_integration/camera.py:248-257` (auto-exposure toggle)
- `Camera.set_auto_gain(enable)``base_integration/camera.py:237-246` (auto-gain toggle)
None of these three are called anywhere, and none are on `IDSCamera.USER_ACCESS`, so a
BEC client can't reach them today.
`OMNY_XRayEye` (`csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`) is the GUI panel
for the flomni/LamNI/OMNY X-ray-eye alignment camera (`cam_xeye`, an `IDSCamera`
device). It should get exposure/auto-gain control knobs too, using the same
cached/event-driven pattern it already uses for its "Camera running" toggle, rather than
polling the device with RPC calls.
Per user decisions (2026-09-11): cover exposure control + the auto-gain enable/disable
toggle only (no manual gain *value* — the driver has no SDK call for that yet); leave
connect-time default behavior untouched (no new constructor/config kwarg — the camera
keeps whatever state `is_ResetToDefault()` leaves it in unless a client/GUI explicitly
calls the new methods); and be careful to use cached readings wherever possible rather
than polling the hardware for GUI display.
## Design: `Cpt(Signal, kind=Kind.config)`, not plain methods only
`OMNY_XRayEye`'s existing "Camera running" toggle is the template for a control knob
that both *writes* device state and *reflects* it back without polling:
- **Write**: `self.dev.get(CAMERA[0]).live_mode_enabled.put(enabled)` (`x_ray_eye.py:882`)
— RPC `.put()` on an ophyd `Cpt(Signal, kind=Kind.config)` on the device
(`ids_camera.py:59-65`).
- **Read (cached/event-driven, no polling)**: the widget subscribes once, at
construction, to `MessageEndpoints.device_read_configuration(CAMERA[0])`
(`x_ray_eye.py:293-295`) — BEC pushes **all** `Kind.config` signals of the device
together in one message whenever any of them changes. `getting_camera_status`
(`x_ray_eye.py:886-892`) reads `data["signals"]["cam_xeye_live_mode_enabled"]["value"]`
and updates the toggle via the `blockSignals(True)/.checked = .../blockSignals(False)`
idiom (to avoid re-triggering the write handler).
This matches the repo's established convention for Signal-backed device state
(confirmed against `slit_control.py`, `sample_storage.py`, `tomo_params.py`:
`read(cached=True)` for polled numeric readbacks, and
`bec_dispatcher.connect_slot(..., device_read_configuration(...))` for `Kind.config`
signals, which is cheaper still — zero RPC round-trips, pure pub/sub). So exposure_time
and the two auto-enable flags should be `Cpt(Signal, kind=Kind.config)` on `IDSCamera`
(not plain methods only), with thin `USER_ACCESS` wrapper methods kept on top for
ipython/scripted use — mirroring how `start_live_mode()`/`stop_live_mode()` are just
wrappers around `live_mode_enabled.put()`. **No code should poll `get_exposure_time()` /
`get_gain()` RPCs from a `QTimer` loop.**
## Changes
### 1. `csaxs_bec/devices/ids_cameras/ids_camera.py`
Add three new components, next to `live_mode_enabled` (`ids_camera.py:59-65`):
```python
exposure_time = Cpt(
Signal, name="exposure_time", value=0.0,
doc="Camera exposure time (ms).", kind=Kind.config,
)
auto_exposure_enabled = Cpt(
Signal, name="auto_exposure_enabled", value=True,
doc="Enable/disable auto-exposure (auto-shutter).", kind=Kind.config,
)
auto_gain_enabled = Cpt(
Signal, name="auto_gain_enabled", value=True,
doc="Enable/disable auto-gain.", kind=Kind.config,
)
```
In `__init__`, alongside the existing `live_mode_enabled.subscribe(...)`
(`ids_camera.py:127`):
```python
self.exposure_time.subscribe(self._on_exposure_time_changed, run=False)
self.auto_exposure_enabled.subscribe(self._on_auto_exposure_enabled_changed, run=False)
self.auto_gain_enabled.subscribe(self._on_auto_gain_enabled_changed, run=False)
```
New callbacks + thin `USER_ACCESS` wrappers, in a new
`############## Exposure / Gain ##############` section (placed after the live-mode/ROI
methods and before `############## User Interface Methods ##############`):
```python
def _on_exposure_time_changed(self, *args, value, **kwargs):
self.cam.exposure_time = value
def _on_auto_exposure_enabled_changed(self, *args, value, **kwargs):
self.cam.set_auto_shutter(bool(value))
def _on_auto_gain_enabled_changed(self, *args, value, **kwargs):
self.cam.set_auto_gain(bool(value))
def get_exposure_time(self) -> float:
"""Get the current exposure time (ms), from the cached Signal value."""
return float(self.exposure_time.get())
def set_exposure_time(self, value: float) -> None:
"""Set the exposure time (ms). Does not itself disable auto-exposure --
call set_auto_exposure_enabled(False) first, or the driver will keep overriding it."""
self.exposure_time.put(value)
def set_auto_exposure_enabled(self, enable: bool) -> None:
self.auto_exposure_enabled.put(bool(enable))
def set_auto_gain_enabled(self, enable: bool) -> None:
self.auto_gain_enabled.put(bool(enable))
```
(Confirm the SDK's exposure unit — `is_Exposure`/`IS_EXPOSURE_CMD_*` is documented in
milliseconds; adjust the docstring if inspection shows otherwise.)
Add the four wrapper names to `USER_ACCESS` (`ids_camera.py:67-76`):
`"get_exposure_time"`, `"set_exposure_time"`, `"set_auto_exposure_enabled"`,
`"set_auto_gain_enabled"`.
In `on_connected()` (`ids_camera.py:283-288`), after `self.cam.on_connect()`, seed the
`exposure_time` signal from the real hardware value once, so the GUI shows a real number
immediately on connect instead of the `0.0` placeholder:
```python
self.exposure_time.put(self.cam.exposure_time)
```
(This round-trips through `_on_exposure_time_changed`, which writes the same value back
to the driver — a harmless one-time no-op write, same idiom as the existing
`self.live_mode_enabled.put(bool(self._inputs.get("live_mode", False)))` line right
below it.) `auto_exposure_enabled`/`auto_gain_enabled` are **not** seeded from hardware —
there is no SDK query for current auto-shutter/auto-gain state in this driver (only
enable-setters), so, like `live_mode_enabled`, they start at their declared default
(`True`, matching the uEye SDK's typical post-reset default) and only reflect reality
once explicitly set through this API.
No changes needed in `base_integration/camera.py``exposure_time`, `set_auto_shutter`,
`set_auto_gain` already exist there exactly as needed.
### 2. `csaxs_bec/devices/sim/sim_cameras.py`
`SimIDSCamera` replaces `self.cam` with `_SimIDSBackend` (line 211), which currently has
no `exposure_time` attribute and no `set_auto_shutter`/`set_auto_gain` methods —
`on_connected()`'s `self.exposure_time.put(self.cam.exposure_time)` and the new
subscribe callbacks would raise `AttributeError` against `SimIDSCamera` otherwise. Add to
`_SimIDSBackend` (mirroring `_SimAlliedVisionBackend.exposure_time`, lines 397-403):
```python
def __init__(self, ...):
...
self._exposure_time = 10000.0 # ms
self._auto_exposure = True
self._auto_gain = True
@property
def exposure_time(self) -> float:
return self._exposure_time
@exposure_time.setter
def exposure_time(self, value: float):
self._exposure_time = value
def set_auto_shutter(self, enable: bool):
self._auto_exposure = bool(enable)
def set_auto_gain(self, enable: bool):
self._auto_gain = bool(enable)
```
### 3. Tests — `tests/tests_devices/test_ids_camera.py`
```python
def test_get_set_exposure_time(ids_camera):
ids_camera.set_exposure_time(1234.5)
assert ids_camera.cam.exposure_time == 1234.5
assert ids_camera.get_exposure_time() == 1234.5
def test_set_auto_exposure_enabled(ids_camera):
ids_camera.set_auto_exposure_enabled(False)
ids_camera.cam.set_auto_shutter.assert_called_once_with(False)
def test_set_auto_gain_enabled(ids_camera):
ids_camera.set_auto_gain_enabled(False)
ids_camera.cam.set_auto_gain.assert_called_once_with(False)
def test_on_connected_seeds_exposure_time(ids_camera):
ids_camera.cam.on_connect = mock.Mock()
ids_camera.cam.exposure_time = 4200.0
ids_camera.on_connected()
assert ids_camera.get_exposure_time() == 4200.0
```
## Phase 2: GUI control knobs in `OMNY_XRayEye`
### 4. `csaxs_bec/bec_widgets/widgets/xray_eye/x_ray_eye.py`
**Imports**: add `QDoubleSpinBox` to the `qtpy.QtWidgets` import block (`x_ray_eye.py:15-29`)
— only `QSpinBox` is imported today.
**New widgets in `_init_ui`**, extending the existing `switch_grid` (`x_ray_eye.py:368-404`)
with a 3rd row (same `QGridLayout` the shutter/camera-running and smear toggles already
share, so columns keep lining up):
```python
self.auto_exposure_label = QLabel("Auto exposure", parent=self)
self.auto_exposure_toggle = ToggleSwitch(parent=self)
self.auto_exposure_toggle.checked = True
self.auto_exposure_toggle.enabled.connect(self.auto_exposure_enabled_changed)
self.auto_gain_label = QLabel("Auto gain", parent=self)
self.auto_gain_toggle = ToggleSwitch(parent=self)
self.auto_gain_toggle.checked = True
self.auto_gain_toggle.enabled.connect(self.auto_gain_enabled_changed)
switch_grid.addWidget(self.auto_exposure_label, 2, 1, _right_vcenter)
switch_grid.addWidget(self.auto_exposure_toggle, 2, 2, Qt.AlignmentFlag.AlignVCenter)
switch_grid.addWidget(self.auto_gain_label, 2, 3, _right_vcenter)
switch_grid.addWidget(self.auto_gain_toggle, 2, 4, Qt.AlignmentFlag.AlignVCenter)
```
An exposure-time spinbox, added as its own small form near the step-size form
(`x_ray_eye.py:424-439`), mirroring that form's `QGridLayout` structure:
```python
exposure_form = QGridLayout()
self.exposure_time_spin = QDoubleSpinBox(parent=self)
self.exposure_time_spin.setRange(0.01, 1000.0) # ms; confirm real driver range
self.exposure_time_spin.setDecimals(2)
self.exposure_time_spin.setSuffix(" ms")
self.exposure_time_spin.setEnabled(False) # auto-exposure starts enabled
self.exposure_time_spin.editingFinished.connect(self.exposure_time_submitted)
exposure_form.addWidget(QLabel("Exposure time", parent=self), 0, 0)
exposure_form.addWidget(self.exposure_time_spin, 0, 1)
self.control_panel_layout.addLayout(exposure_form)
```
Using `editingFinished` (fires once, on Enter/focus-loss) rather than `valueChanged`
(fires on every arrow-button click/scroll tick) is the throttling choice here — the
"don't hammer the device" concern applied to a manual-entry control instead of a poll
loop.
**New handlers**, alongside `camera_running_enabled`/`opening_shutter`
(`x_ray_eye.py:875-906`):
```python
@SafeSlot(bool)
def auto_exposure_enabled_changed(self, enabled: bool):
if self._manual_toggle_blocked_by_queue():
logger.warning("Ignoring auto-exposure toggle while scan queue is busy.")
return
self.auto_exposure_toggle.blockSignals(True)
self.dev.get(CAMERA[0]).auto_exposure_enabled.put(enabled)
self.auto_exposure_toggle.checked = enabled
self.exposure_time_spin.setEnabled(not enabled)
self.auto_exposure_toggle.blockSignals(False)
@SafeSlot(bool)
def auto_gain_enabled_changed(self, enabled: bool):
if self._manual_toggle_blocked_by_queue():
logger.warning("Ignoring auto-gain toggle while scan queue is busy.")
return
self.auto_gain_toggle.blockSignals(True)
self.dev.get(CAMERA[0]).auto_gain_enabled.put(enabled)
self.auto_gain_toggle.checked = enabled
self.auto_gain_toggle.blockSignals(False)
def exposure_time_submitted(self):
self.dev.get(CAMERA[0]).exposure_time.put(self.exposure_time_spin.value())
```
**Extend the existing `getting_camera_status`** (`x_ray_eye.py:886-892`) — no new Redis
subscription needed, since `exposure_time`/`auto_exposure_enabled`/`auto_gain_enabled`
are `Kind.config` signals on the same device, so they already arrive in the same
`device_read_configuration(CAMERA[0])` message `live_mode_enabled` uses:
```python
@SafeSlot(dict, dict)
def getting_camera_status(self, data, meta):
signals = data.get("signals")
live_mode_enabled = signals.get(f"{CAMERA[0]}_live_mode_enabled").get("value")
self.camera_running_toggle.blockSignals(True)
self.camera_running_toggle.checked = live_mode_enabled
self.camera_running_toggle.blockSignals(False)
auto_exp = signals.get(f"{CAMERA[0]}_auto_exposure_enabled")
if auto_exp is not None:
enabled = bool(auto_exp.get("value"))
self.auto_exposure_toggle.blockSignals(True)
self.auto_exposure_toggle.checked = enabled
self.exposure_time_spin.setEnabled(not enabled)
self.auto_exposure_toggle.blockSignals(False)
auto_gain = signals.get(f"{CAMERA[0]}_auto_gain_enabled")
if auto_gain is not None:
self.auto_gain_toggle.blockSignals(True)
self.auto_gain_toggle.checked = bool(auto_gain.get("value"))
self.auto_gain_toggle.blockSignals(False)
exposure_time = signals.get(f"{CAMERA[0]}_exposure_time")
if exposure_time is not None:
self.exposure_time_spin.blockSignals(True)
self.exposure_time_spin.setValue(float(exposure_time.get("value")))
self.exposure_time_spin.blockSignals(False)
```
The `is not None` guards are defensive (harmless if this ever runs against an older
`IDSCamera` revision without these signals) and cost nothing.
## Verification
- Unit tests: `pytest tests/tests_devices/test_ids_camera.py -v`. GUI widget tests (if
any exist under `tests/` for `x_ray_eye.py` — check before implementing) should cover
`getting_camera_status` updating the three new widgets from a synthetic message dict,
and `exposure_time_submitted`/`auto_*_enabled_changed` calling the right RPC `.put()`.
- Manual/simulation check: run against `SimIDSCamera` (no real hardware needed) —
confirm toggling "Auto exposure" off enables the spinbox, entering a value + Enter (or
focus-loss) calls `exposure_time.put(...)`, and the value round-trips back through
`getting_camera_status` into the spinbox display without any added polling.
- Manual/integration check on real hardware (during a non-live test session): open the
xrayeye widget against a connected `cam_xeye`, toggle auto-exposure off, set an
exposure time, and confirm the live image brightness responds and stays stable
(doesn't drift back, confirming auto-exposure is actually off).
+20 -3
View File
@@ -222,12 +222,13 @@ The basic scan function can be called by `scans.flomni_fermat_scan()` and offers
| fovy (float) | Fov in the piezo plane (i.e. piezo range). Max 100 um |
| cenx (float) | center position in x |
| ceny (float) | center position in y |
| exp_time (float) | exposure time per frame |
| frames_per_trigger(int) | Number of burst frames per position |
| step (float) | stepsize |
| zshift (float) | shift in z |
| angle (float) | rotation angle (will rotate first) |
| corridor_size (float) | corridor size for the corridor optimization. Default 3 um |
| corridor_size (float) | corridor size for the corridor optimization. Default 3 um (auto-estimated if not provided) |
| exp_time (float) | exposure time per frame |
| frames_per_trigger (int) | Number of burst frames per position |
| burst_at_each_point (int) | Number of triggers and readouts at each point |
Example:
`scans.flomni_fermat_scan(fovx=20, fovy=25, cenx=0.02, ceny=0, zshift=0, angle=0, step=0.5, exp_time=0.01, frames_per_trigger=1)`
@@ -272,6 +273,22 @@ The loading routine uses default values for the vertical alignment. This behavio
At each projection, the angular dependent is computed by
`flomni.get_alignment_offset(angle)`, with _angle_ in degrees.
To manually nudge the alignment by a few microns without recording a new fit
(e.g. to compensate for a small shift introduced by moving `foptz`), edit the
constant offset term of the currently loaded fit directly:
```python
fit = flomni.client.get_global_var("tomo_alignment_fit")
fit[0][2] += 5.0 # x offset, microns
fit[1][2] += 3.0 # y offset, microns
flomni.client.set_global_var("tomo_alignment_fit", fit)
```
`tomo_alignment_fit` is a 2x5 list (row 0 = x, row 1 = y); column 2 in each
row is the constant offset of the sinusoidal fit
(`correction = A*sin(angle + phase) + offset [+ 3rd-order term for y]`), so
changing it shifts the correction by that amount at every angle without
touching the fitted amplitude/phase. Check the result with
`flomni.get_alignment_offset(angle)`.
The alignment can be cleared by
`flomni.reset_tomo_alignment_fit()`
+19 -3
View File
@@ -327,15 +327,16 @@ The basic scan function can be called by `scans.omny_fermat_scan()` and offers a
| fovy (float) | Fov in the piezo plane (i.e. piezo range). Max 100 um |
| cenx (float) | center position in x |
| ceny (float) | center position in y |
| exp_time (float) | exposure time per frame |
| frames_per_trigger(int) | Number of burst frames per position |
| step (float) | stepsize |
| zshift (float) | shift in z |
| angle (float) | rotation angle (will rotate first) |
| corridor_size (float) | corridor size for the corridor optimization. Default 3 um |
| exp_time (float) | exposure time per frame |
| frames_per_trigger (int) | Number of burst frames per position |
| readout_time (float) | configurable readout time for devices that support it |
Example:
`scans.omny_fermat_scan(fovx=20, fovy=25, cenx=0.02, ceny=0, zshift=0, angle=0, step=0.5, exp_time=0.01, frames_per_trigger=1)`
`scans.omny_fermat_scan(fovx=20, fovy=25, cenx=0.02, ceny=0, zshift=0, angle=0, step=0.5, exp_time=0.01, frames_per_trigger=1, readout_time=0)`
#### Overview of the alignment steps
@@ -378,6 +379,21 @@ The loading routine uses default values for the vertical alignment for setup. Th
At each projection, the angular dependent is computed by
`omny.get_alignment_offset(angle)`, with _angle_ in degrees.
To manually nudge the alignment by a few microns without recording a new fit,
edit the constant offset term of the currently loaded fit directly:
```python
fit = omny.client.get_global_var("tomo_alignment_fit")
fit[0][2] += 5.0 # x offset, microns
fit[1][2] += 3.0 # y offset, microns
omny.client.set_global_var("tomo_alignment_fit", fit)
```
`tomo_alignment_fit` is a 2x5 list (row 0 = x, row 1 = y); column 2 in each
row is the constant offset of the sinusoidal fit
(`correction = A*sin(angle + phase) + offset [+ 3rd-order term for y]`), so
changing it shifts the correction by that amount at every angle without
touching the fitted amplitude/phase. Check the result with
`omny.get_alignment_offset(angle)`.
The alignment can be cleared by
`omny.reset_tomo_alignment_fit()`
@@ -97,7 +97,7 @@ def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch):
lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
calls = []
def _fake_projection(angle):
def _fake_projection(angle, _internal=False):
calls.append(angle)
builtins.__dict__["bec"].queue.next_scan_number += 1
@@ -115,7 +115,7 @@ def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch):
def test_tomo_alignment_scan_rotates_back_to_zero(monkeypatch):
lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
lamni.tomo_scan_projection = lambda angle: None
lamni.tomo_scan_projection = lambda angle, _internal=False: None
umv_calls = []
monkeypatch.setattr(lamni_module, "umv", lambda *a: umv_calls.append(a), raising=False)
@@ -208,7 +208,7 @@ def test_at_each_angle_default_path_unchanged_without_hook():
behaviour lamni had before this port: tomo_scan_projection + tomo_reconstruct."""
lamni = make_lamni()
calls = []
lamni.tomo_scan_projection = lambda angle: calls.append(("projection", angle))
lamni.tomo_scan_projection = lambda angle, _internal=False: calls.append(("projection", angle))
lamni.tomo_reconstruct = lambda: calls.append(("reconstruct",))
lamni._at_each_angle(30.0)