Flomni fixes during beamtimes 1 #317
@@ -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:
|
||||
|
||||
BIN
Binary file not shown.
@@ -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.
|
||||
+68
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
@@ -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()`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user