Flomni commissioning 5 #256
@@ -14,7 +14,7 @@ from bec_lib.alarm_handler import AlarmBase
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.pdf_writer import PDFWriter
|
||||
from bec_lib.scan_repeat import scan_repeat
|
||||
from typeguard import typechecked
|
||||
from typeguard import check_type, typechecked
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.cSAXS import cSAXSBeamlineChecks
|
||||
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_optics_mixin import FlomniOpticsMixin
|
||||
@@ -421,6 +421,23 @@ class FlomniSampleTransferMixin:
|
||||
if not low:
|
||||
raise FlomniError("Ftray is not at the 'IN' position. Aborting.")
|
||||
|
||||
def ftransfer_confirm_dialog(self, message: str, default: str = "none") -> bool:
|
||||
"""
|
||||
Yes/No confirmation for sample-transfer steps.
|
||||
|
||||
Uses the GUI console widget (ConsoleButtonsWidget, opened by
|
||||
flomnigui_show_cameras()) when it's already available, so the
|
||||
operator can confirm/abort right next to the live camera view.
|
||||
Falls back to the regular console yesno() prompt if the
|
||||
cameras/console haven't been opened yet -- e.g. if a transfer step
|
||||
is called directly, outside the usual ftransfer_get_sample /
|
||||
ftransfer_put_sample sequence that opens them first.
|
||||
"""
|
||||
if self.console is not None:
|
||||
print("Using GUI console for confirmation dialog.")
|
||||
return self.OMNYTools.gui_yesno(message, gui=self.console, default=default)
|
||||
return self.OMNYTools.yesno(message, default)
|
||||
|
||||
def ftransfer_flomni_stage_in(self):
|
||||
time.sleep(1)
|
||||
sample_in_position = dev.flomni_samples.is_sample_slot_used(0)
|
||||
@@ -440,14 +457,14 @@ class FlomniSampleTransferMixin:
|
||||
|
||||
print("Moving X-ray eye in.")
|
||||
|
||||
if self.OMNYTools.yesno(
|
||||
"Please confirm that this is ok with the flight tube. This check is to be removed after commissioning",
|
||||
"n",
|
||||
):
|
||||
print("OK. continue.")
|
||||
else:
|
||||
print("Stopping.")
|
||||
raise FlomniError("Manual abort of x-ray eye in.")
|
||||
# if self.OMNYTools.yesno(
|
||||
# "Please confirm that this is ok with the flight tube. This check is to be removed after commissioning",
|
||||
# "n",
|
||||
# ):
|
||||
# print("OK. continue.")
|
||||
# else:
|
||||
# print("Stopping.")
|
||||
# raise FlomniError("Manual abort of x-ray eye in.")
|
||||
|
||||
self.feye_in()
|
||||
print("Moving X-ray optics out.")
|
||||
@@ -480,7 +497,7 @@ class FlomniSampleTransferMixin:
|
||||
def laser_parameters_set_psd_intensity_threshold_tracking_low(self, val: float):
|
||||
dev.rtx.controller.emitter_set_psd_intensity_threshold_tracking_low(val)
|
||||
|
||||
def laser_tweak(self):
|
||||
def laser_parameters_tweak_tracking(self):
|
||||
"""Interactive tweak of the two laser tracking target positions.
|
||||
|
||||
Arrow keys nudge the ConstEmitter tracking targets by a fixed step of
|
||||
@@ -494,15 +511,27 @@ class FlomniSampleTransferMixin:
|
||||
After every change the current y/z targets and the fzp x interferometer
|
||||
signal strength (channel 1) are printed, so one can watch the signal
|
||||
respond while tweaking.
|
||||
|
||||
Requires the laser tracker to be enabled: with the tracker off nothing
|
||||
moves on the hardware side, so the tweak would be a no-op.
|
||||
"""
|
||||
import fcntl
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
|
||||
step = 0.01
|
||||
controller = dev.rtx.controller
|
||||
|
||||
if not controller.laser_tracker_check_enabled():
|
||||
print(
|
||||
"The laser tracker is disabled. Enable it with laser_tracker_on() "
|
||||
"before tweaking the tracking targets - otherwise nothing moves on "
|
||||
"the hardware side."
|
||||
)
|
||||
return
|
||||
|
||||
step = 0.02
|
||||
|
||||
def read_targets():
|
||||
values = controller.emitter_get()
|
||||
return (
|
||||
@@ -742,15 +771,22 @@ class FlomniSampleTransferMixin:
|
||||
|
||||
self.transfer_step = 0
|
||||
|
||||
time.sleep(1)
|
||||
while True:
|
||||
in_progress = bool(
|
||||
float(dev.ftransy.controller.socket_put_and_receive("MG mntprgs").strip())
|
||||
)
|
||||
if not in_progress:
|
||||
break
|
||||
self.ftransfer_confirm(step_name="get")
|
||||
try:
|
||||
time.sleep(1)
|
||||
while True:
|
||||
in_progress = bool(
|
||||
float(dev.ftransy.controller.socket_put_and_receive("MG mntprgs").strip())
|
||||
)
|
||||
if not in_progress:
|
||||
break
|
||||
self.ftransfer_confirm(step_name="get")
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
self.ftransfer_abort()
|
||||
raise FlomniError(
|
||||
"Sample transfer aborted by user (Ctrl+C / GUI abort). Assess gripper and"
|
||||
" sample state manually before continuing."
|
||||
) from None
|
||||
self.ftransfer_controller_disable_mount_mode()
|
||||
self.ensure_gripper_up()
|
||||
|
||||
@@ -791,17 +827,24 @@ class FlomniSampleTransferMixin:
|
||||
|
||||
print("The mount process started.")
|
||||
|
||||
time.sleep(1)
|
||||
self.transfer_step = 0
|
||||
while True:
|
||||
in_progress = bool(
|
||||
float(dev.ftransy.controller.socket_put_and_receive("MG mntprgs").strip())
|
||||
)
|
||||
if not in_progress:
|
||||
break
|
||||
|
||||
self.ftransfer_confirm(step_name="put")
|
||||
try:
|
||||
time.sleep(1)
|
||||
self.transfer_step = 0
|
||||
while True:
|
||||
in_progress = bool(
|
||||
float(dev.ftransy.controller.socket_put_and_receive("MG mntprgs").strip())
|
||||
)
|
||||
if not in_progress:
|
||||
break
|
||||
|
||||
self.ftransfer_confirm(step_name="put")
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
self.ftransfer_abort()
|
||||
raise FlomniError(
|
||||
"Sample transfer aborted by user (Ctrl+C / GUI abort). Assess gripper and"
|
||||
" sample state manually before continuing."
|
||||
) from None
|
||||
self.ftransfer_controller_disable_mount_mode()
|
||||
self.ensure_gripper_up()
|
||||
|
||||
@@ -827,6 +870,47 @@ class FlomniSampleTransferMixin:
|
||||
if sample_in_gripper:
|
||||
raise FlomniError("There is already a sample in the gripper. Aborting.")
|
||||
|
||||
if new_sample_position == -1:
|
||||
# Remove-only: stow the sample currently in the sample stage into a
|
||||
# free tray slot and stop there -- no new sample is mounted.
|
||||
sample_in_sample_stage = dev.flomni_samples.is_sample_slot_used(0)
|
||||
if not sample_in_sample_stage:
|
||||
raise FlomniError("There is no sample in the sample stage to remove. Aborting.")
|
||||
|
||||
empty_slots = []
|
||||
for j in range(1, 21):
|
||||
if not dev.flomni_samples.is_sample_slot_used(j):
|
||||
empty_slots.append(j)
|
||||
if not empty_slots:
|
||||
raise FlomniError("There are no empty slots available. Aborting.")
|
||||
|
||||
print(f"The following slots are empty: {empty_slots}.")
|
||||
|
||||
while True:
|
||||
user_input = input(
|
||||
"Where shall I put the sample currently in the sample stage? "
|
||||
f"Default: [{empty_slots[0]}] "
|
||||
)
|
||||
|
||||
if user_input.strip() == "":
|
||||
user_input = empty_slots[0]
|
||||
break
|
||||
|
||||
try:
|
||||
user_input = int(user_input)
|
||||
if user_input not in empty_slots:
|
||||
raise ValueError
|
||||
break
|
||||
except ValueError:
|
||||
print("Please specify a valid number.")
|
||||
continue
|
||||
|
||||
self.check_position_is_valid(user_input)
|
||||
|
||||
self.ftransfer_get_sample(0)
|
||||
self.ftransfer_put_sample(user_input)
|
||||
return
|
||||
|
||||
self.check_position_is_valid(new_sample_position)
|
||||
|
||||
if new_sample_position == 0:
|
||||
@@ -853,7 +937,7 @@ class FlomniSampleTransferMixin:
|
||||
# continue
|
||||
# if val.get("value") == 0:
|
||||
# empty_slots.append(int(name.split("flomni_samples_sample_placed_sample")[1]))
|
||||
for j in range(1, 20):
|
||||
for j in range(1, 21):
|
||||
if not dev.flomni_samples.is_sample_slot_used(j):
|
||||
empty_slots.append(j)
|
||||
if not empty_slots:
|
||||
@@ -928,6 +1012,38 @@ class FlomniSampleTransferMixin:
|
||||
)
|
||||
return in_mount_mode
|
||||
|
||||
def ftransfer_abort(self):
|
||||
"""
|
||||
Hard abort of a running sample transfer routine on the Galil controller.
|
||||
|
||||
Stops the controller via dev.ftransy.stop(), which publishes a stop
|
||||
request that the device server turns into motor.stop() and thus
|
||||
XQ#STOP on the controller. #STOP halts the transfer thread (3),
|
||||
aborts all motion (AB1) and clears mntprgs/mntmod. Afterwards the
|
||||
controller is put back into positioning mode.
|
||||
|
||||
Deliberately does NOT call ensure_gripper_up(): after a mid-transfer
|
||||
abort the gripper may be closed around a partially inserted sample,
|
||||
so any recovery motion must be assessed and performed manually.
|
||||
"""
|
||||
print("Aborting sample transfer: stopping the controller routine.")
|
||||
dev.ftransy.stop()
|
||||
# The stop request is asynchronous (Redis -> device server). Wait
|
||||
# until the transfer thread is actually halted before switching mode:
|
||||
# #POSMODE refuses while mntprgs=1 and disable_mount_mode would raise.
|
||||
timeout = 5
|
||||
start = time.time()
|
||||
while dev.ftransy.controller.is_thread_active(3):
|
||||
if time.time() - start > timeout:
|
||||
raise FlomniError(
|
||||
"Transfer abort requested but the controller transfer routine (thread 3)"
|
||||
f" did not stop within {timeout} s. Check the controller."
|
||||
)
|
||||
time.sleep(0.1)
|
||||
# Ensure the controller is back in positioning mode. #STOP already
|
||||
# clears mntmod, so this is mostly a verification step.
|
||||
self.ftransfer_controller_disable_mount_mode()
|
||||
|
||||
def ftransfer_confirm(self, step_name: str = ""):
|
||||
confirm = int(float(dev.ftransy.controller.socket_put_and_receive("MG confirm").strip()))
|
||||
|
||||
@@ -935,7 +1051,7 @@ class FlomniSampleTransferMixin:
|
||||
return
|
||||
|
||||
self.transfer_step += 1
|
||||
if self.OMNYTools.yesno("All OK? Continue?", "y"):
|
||||
if self.ftransfer_confirm_dialog("All OK? Continue?", "y"):
|
||||
print("OK. continue.")
|
||||
data = self.client.connector.get_last(
|
||||
MessageEndpoints.device_preview("cam_flomni_gripper", "preview")
|
||||
@@ -944,6 +1060,7 @@ class FlomniSampleTransferMixin:
|
||||
dev.ftransy.controller.socket_put_confirmed("confirm=1")
|
||||
else:
|
||||
print("Stopping.")
|
||||
self.ftransfer_abort()
|
||||
raise FlomniError("User abort sample transfer.")
|
||||
|
||||
def save_reference_image(self, image: np.ndarray, file_suffix: str = ""):
|
||||
@@ -979,16 +1096,19 @@ class FlomniSampleTransferMixin:
|
||||
self.check_position_is_valid(position)
|
||||
|
||||
# this is not used for sample stage position!
|
||||
self._ftransfer_shiftx = -0.15
|
||||
self._ftransfer_shiftx = -0.1
|
||||
self._ftransfer_shiftz = -0.5
|
||||
|
||||
fsamx_pos = dev.fsamx.readback.get()
|
||||
if position == 0 and fsamx_pos > -160:
|
||||
|
||||
if self.OMNYTools.yesno(
|
||||
"May the flomni stage be moved out for the sample change? Feedback will be disabled and alignment will be lost!",
|
||||
"y",
|
||||
):
|
||||
print("Use GUI interface.")
|
||||
if self.ftransfer_confirm_dialog("May the flomni stage be moved out for the sample change? Feedback will be disabled and alignment will be lost!", "y"):
|
||||
|
||||
# if self.OMNYTools.yesno(
|
||||
# "May the flomni stage be moved out for the sample change? Feedback will be disabled and alignment will be lost!",
|
||||
# "y",
|
||||
# ):
|
||||
print("OK. continue.")
|
||||
self.ftransfer_flomni_stage_out()
|
||||
else:
|
||||
@@ -999,7 +1119,7 @@ class FlomniSampleTransferMixin:
|
||||
self.check_tray_in()
|
||||
|
||||
if position == 0:
|
||||
umv(dev.ftransx, 11, dev.ftransz, 3.5950)
|
||||
umv(dev.ftransx, 11.02, dev.ftransz, 3.5950)
|
||||
if position == 1:
|
||||
umv(
|
||||
dev.ftransx,
|
||||
@@ -1357,10 +1477,10 @@ class FlomniAlignmentMixin:
|
||||
correction_z = tomo_alignment_fit[0][0] * np.sin(
|
||||
np.radians(angle + 90) + tomo_alignment_fit[0][1]
|
||||
)
|
||||
|
||||
#Todo
|
||||
print(
|
||||
f"Alignment offset x {correction_x}, y {correction_y}, z {correction_z} for angle"
|
||||
f" {angle}\n"
|
||||
f" {angle}\nCurrently not applying z shift (HW damage)."
|
||||
)
|
||||
return (correction_x, correction_y, correction_z)
|
||||
|
||||
@@ -1553,6 +1673,50 @@ class _TomoQueueProxy:
|
||||
return self._load()[index]
|
||||
|
||||
|
||||
class _GlobalVarParam:
|
||||
"""Descriptor for a Flomni parameter backed by a BEC global variable.
|
||||
|
||||
Consolidates the boilerplate getter/setter pairs that read from and write
|
||||
to the BEC global-var store. The value persists across BEC client restarts
|
||||
(instance attributes do not), and is readable from other client sessions
|
||||
via ``client.get_global_var(<name>)``.
|
||||
|
||||
The global-var key is taken from the attribute name the descriptor is
|
||||
assigned to (see :meth:`__set_name__`), so it always matches the property
|
||||
name -- exactly the invariant the hand-written pairs relied on.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
default
|
||||
Value returned by the getter when the global var is unset (``None``).
|
||||
type_
|
||||
Optional expected type. When given, the setter validates ``val`` with
|
||||
:func:`typeguard.check_type` before storing, reproducing the
|
||||
``@typechecked`` guard that previously wrapped some setters (raising
|
||||
``TypeCheckError`` on a mismatch).
|
||||
"""
|
||||
|
||||
def __init__(self, default, type_=None):
|
||||
self._default = default
|
||||
self._type = type_
|
||||
|
||||
def __set_name__(self, owner, name):
|
||||
self._key = name
|
||||
|
||||
def __get__(self, obj, objtype=None):
|
||||
if obj is None:
|
||||
return self
|
||||
val = obj.client.get_global_var(self._key)
|
||||
if val is None:
|
||||
return self._default
|
||||
return val
|
||||
|
||||
def __set__(self, obj, val):
|
||||
if self._type is not None:
|
||||
check_type(val, self._type)
|
||||
obj.client.set_global_var(self._key, val)
|
||||
|
||||
|
||||
class Flomni(
|
||||
FlomniInitStagesMixin,
|
||||
FlomniSampleTransferMixin,
|
||||
@@ -1601,10 +1765,72 @@ class Flomni(
|
||||
self.align = XrayEyeAlign(self.client, self)
|
||||
self.set_client(client)
|
||||
|
||||
self._maybe_reset_params_on_account_change()
|
||||
|
||||
def set_web_password(self, password: str) -> None:
|
||||
"""Set the web password for the current BEC account."""
|
||||
self._webpage_gen.set_web_password(password)
|
||||
|
||||
def _maybe_reset_params_on_account_change(self) -> None:
|
||||
"""Offer a tomo-parameter reset when the active account has changed.
|
||||
|
||||
Called at BEC session start. The account for which defaults were last
|
||||
applied is stored in the global var ``defaults_applied_for_account`` so
|
||||
it survives client restarts. Only a genuine change of account triggers
|
||||
the (interactive) reset; the same account is a silent no-op, so
|
||||
restarting a client within the same experiment never disturbs tuned
|
||||
parameters.
|
||||
"""
|
||||
bec = builtins.__dict__.get("bec")
|
||||
try:
|
||||
account = bec.active_account
|
||||
except Exception as exc:
|
||||
print(f"account-change check skipped: cannot read active_account: {exc}")
|
||||
return
|
||||
if not account:
|
||||
return
|
||||
|
||||
last_account = self.client.get_global_var("defaults_applied_for_account")
|
||||
if account == last_account:
|
||||
return
|
||||
|
||||
if self.OMNYTools.yesno(
|
||||
f"New account '{account}' detected (previous: '{last_account}').\n"
|
||||
"Reset tomo parameters to defaults for the new experiment?",
|
||||
"y",
|
||||
):
|
||||
self._set_default_tomo_params()
|
||||
print(f"Tomo parameters reset to defaults for account '{account}'.")
|
||||
self.client.set_global_var("defaults_applied_for_account", account)
|
||||
|
||||
def _set_default_tomo_params(self) -> None:
|
||||
"""Write all tomo scan parameters back to their default values.
|
||||
|
||||
These are the same baseline values used as the getter fallbacks. Per-
|
||||
sample alignment state (corrections, alignment offsets) is deliberately
|
||||
not reset here, as it is overwritten by the next alignment anyway.
|
||||
"""
|
||||
self.tomo_shellstep = 1
|
||||
self.tomo_countingtime = 0.1
|
||||
self.manual_shift_y = 0.0
|
||||
self.single_point_random_shift_max = 0.0
|
||||
self.fovx = 20
|
||||
self.fovy = 20
|
||||
self.tomo_type = 1
|
||||
self.corridor_size = -1
|
||||
self.stitch_x = 0
|
||||
self.stitch_y = 0
|
||||
self.ptycho_reconstruct_foldername = "ptycho_reconstruct"
|
||||
self.tomo_angle_stepsize = 10.0
|
||||
self.golden_max_number_of_projections = 1000.0
|
||||
self.tomo_stitch_overlap = 0.2
|
||||
self.tomo_angle_range = 180
|
||||
self.golden_projections_at_0_deg_for_damage_estimation = 0
|
||||
self.zero_deg_reference_at_each_subtomo = False
|
||||
self.golden_ratio_bunch_size = 20
|
||||
self.frames_per_trigger = 1
|
||||
self.single_point_instead_of_fermat_scan = False
|
||||
|
||||
def start_x_ray_eye_alignment(self, keep_shutter_open=False):
|
||||
|
||||
if self.OMNYTools.yesno(
|
||||
@@ -1622,7 +1848,45 @@ class Flomni(
|
||||
umv(dev.fsamx, fsamx_in)
|
||||
raise exc
|
||||
|
||||
def _check_beamline_states_valid(self):
|
||||
"""Return (all_valid, invalid_labels).
|
||||
|
||||
Reads every configured beamline state (bec.beamline_states) and
|
||||
collects the ones whose status is not "valid" (i.e. "invalid",
|
||||
"warning" or "unknown"). Used to warn before taking a manual X-ray
|
||||
eye frame: if e.g. the beam is down, the frame would come back empty
|
||||
and the user is left confused about why they see nothing.
|
||||
|
||||
Returns all_valid=True (with an empty list) if the beamline-state
|
||||
machinery isn't available at all, so this never blocks on a setup
|
||||
that doesn't use beamline states.
|
||||
"""
|
||||
bec = builtins.__dict__.get("bec")
|
||||
manager = getattr(bec, "beamline_states", None) if bec is not None else None
|
||||
if manager is None:
|
||||
return True, []
|
||||
|
||||
bad = []
|
||||
for name in list(getattr(manager, "_states", {})):
|
||||
try:
|
||||
status = manager.get_status_by_name(name)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
continue
|
||||
if status is not None and status != "valid":
|
||||
bad.append(f"{name}: {status}")
|
||||
return (len(bad) == 0), bad
|
||||
|
||||
def xrayeye_update_frame(self, keep_shutter_open=False):
|
||||
all_valid, bad_states = self._check_beamline_states_valid()
|
||||
if not all_valid:
|
||||
print(
|
||||
"Attention: not all beamline states are valid:\n "
|
||||
+ "\n ".join(bad_states)
|
||||
+ "\nThe beam may be down, so the frame could come back empty."
|
||||
)
|
||||
if not self.OMNYTools.yesno("Take a frame anyway?", "n"):
|
||||
print("Stopping.")
|
||||
return
|
||||
self.align.update_frame(keep_shutter_open)
|
||||
|
||||
def xrayeye_alignment_start(self, keep_shutter_open=False):
|
||||
@@ -1690,38 +1954,11 @@ class Flomni(
|
||||
raise TypeError(f"progress must be a dict, got {type(val).__name__!r}")
|
||||
self._progress_proxy._save(val)
|
||||
|
||||
@property
|
||||
def tomo_shellstep(self):
|
||||
val = self.client.get_global_var("tomo_shellstep")
|
||||
if val is None:
|
||||
return 1
|
||||
return val
|
||||
tomo_shellstep = _GlobalVarParam(1)
|
||||
|
||||
@tomo_shellstep.setter
|
||||
def tomo_shellstep(self, val: float):
|
||||
self.client.set_global_var("tomo_shellstep", val)
|
||||
tomo_countingtime = _GlobalVarParam(0.1)
|
||||
|
||||
@property
|
||||
def tomo_countingtime(self):
|
||||
val = self.client.get_global_var("tomo_countingtime")
|
||||
if val is None:
|
||||
return 0.1
|
||||
return val
|
||||
|
||||
@tomo_countingtime.setter
|
||||
def tomo_countingtime(self, val: float):
|
||||
self.client.set_global_var("tomo_countingtime", val)
|
||||
|
||||
@property
|
||||
def manual_shift_y(self):
|
||||
val = self.client.get_global_var("manual_shift_y")
|
||||
if val is None:
|
||||
return 0.0
|
||||
return val
|
||||
|
||||
@manual_shift_y.setter
|
||||
def manual_shift_y(self, val: float):
|
||||
self.client.set_global_var("manual_shift_y", val)
|
||||
manual_shift_y = _GlobalVarParam(0.0)
|
||||
|
||||
@property
|
||||
def single_point_random_shift_max(self):
|
||||
@@ -1789,40 +2026,11 @@ class Flomni(
|
||||
else:
|
||||
raise ValueError("Unknown tomo_type.")
|
||||
|
||||
@property
|
||||
def corridor_size(self):
|
||||
val = self.client.get_global_var("corridor_size")
|
||||
if val is None:
|
||||
val = -1
|
||||
return val
|
||||
corridor_size = _GlobalVarParam(-1)
|
||||
|
||||
@corridor_size.setter
|
||||
def corridor_size(self, val: float):
|
||||
self.client.set_global_var("corridor_size", val)
|
||||
stitch_x = _GlobalVarParam(0, type_=int)
|
||||
|
||||
@property
|
||||
def stitch_x(self):
|
||||
val = self.client.get_global_var("stitch_x")
|
||||
if val is None:
|
||||
return 0
|
||||
return val
|
||||
|
||||
@stitch_x.setter
|
||||
@typechecked
|
||||
def stitch_x(self, val: int):
|
||||
self.client.set_global_var("stitch_x", val)
|
||||
|
||||
@property
|
||||
def stitch_y(self):
|
||||
val = self.client.get_global_var("stitch_y")
|
||||
if val is None:
|
||||
return 0
|
||||
return val
|
||||
|
||||
@stitch_y.setter
|
||||
@typechecked
|
||||
def stitch_y(self, val: int):
|
||||
self.client.set_global_var("stitch_y", val)
|
||||
stitch_y = _GlobalVarParam(0, type_=int)
|
||||
|
||||
@property
|
||||
def ptycho_reconstruct_foldername(self):
|
||||
@@ -1836,38 +2044,11 @@ class Flomni(
|
||||
self.client.set_global_var("ptycho_reconstruct_foldername", val)
|
||||
self.reconstructor.folder_name = val # keep reconstructor in sync
|
||||
|
||||
@property
|
||||
def tomo_angle_stepsize(self):
|
||||
val = self.client.get_global_var("tomo_angle_stepsize")
|
||||
if val is None:
|
||||
return 10.0
|
||||
return val
|
||||
tomo_angle_stepsize = _GlobalVarParam(10.0)
|
||||
|
||||
@tomo_angle_stepsize.setter
|
||||
def tomo_angle_stepsize(self, val: float):
|
||||
self.client.set_global_var("tomo_angle_stepsize", val)
|
||||
golden_max_number_of_projections = _GlobalVarParam(1000.0)
|
||||
|
||||
@property
|
||||
def golden_max_number_of_projections(self):
|
||||
val = self.client.get_global_var("golden_max_number_of_projections")
|
||||
if val is None:
|
||||
return 1000.0
|
||||
return val
|
||||
|
||||
@golden_max_number_of_projections.setter
|
||||
def golden_max_number_of_projections(self, val: float):
|
||||
self.client.set_global_var("golden_max_number_of_projections", val)
|
||||
|
||||
@property
|
||||
def tomo_stitch_overlap(self):
|
||||
val = self.client.get_global_var("tomo_stitch_overlap")
|
||||
if val is None:
|
||||
return 0.2
|
||||
return val
|
||||
|
||||
@tomo_stitch_overlap.setter
|
||||
def tomo_stitch_overlap(self, val: float):
|
||||
self.client.set_global_var("tomo_stitch_overlap", val)
|
||||
tomo_stitch_overlap = _GlobalVarParam(0.2)
|
||||
|
||||
@property
|
||||
def tomo_angle_range(self):
|
||||
@@ -1885,16 +2066,7 @@ class Flomni(
|
||||
raise ValueError("tomo_angle_range must be 180 or 360 degrees.")
|
||||
self.client.set_global_var("tomo_angle_range", val)
|
||||
|
||||
@property
|
||||
def golden_projections_at_0_deg_for_damage_estimation(self):
|
||||
val = self.client.get_global_var("golden_projections_at_0_deg_for_damage_estimation")
|
||||
if val is None:
|
||||
return 0
|
||||
return val
|
||||
|
||||
@golden_projections_at_0_deg_for_damage_estimation.setter
|
||||
def golden_projections_at_0_deg_for_damage_estimation(self, val: float):
|
||||
self.client.set_global_var("golden_projections_at_0_deg_for_damage_estimation", val)
|
||||
golden_projections_at_0_deg_for_damage_estimation = _GlobalVarParam(0)
|
||||
|
||||
@property
|
||||
def zero_deg_reference_at_each_subtomo(self):
|
||||
@@ -1917,16 +2089,7 @@ class Flomni(
|
||||
def zero_deg_reference_at_each_subtomo(self, val: bool):
|
||||
self.client.set_global_var("zero_deg_reference_at_each_subtomo", val)
|
||||
|
||||
@property
|
||||
def golden_ratio_bunch_size(self):
|
||||
val = self.client.get_global_var("golden_ratio_bunch_size")
|
||||
if val is None:
|
||||
return 20
|
||||
return val
|
||||
|
||||
@golden_ratio_bunch_size.setter
|
||||
def golden_ratio_bunch_size(self, val: float):
|
||||
self.client.set_global_var("golden_ratio_bunch_size", val)
|
||||
golden_ratio_bunch_size = _GlobalVarParam(20)
|
||||
|
||||
@property
|
||||
def frames_per_trigger(self):
|
||||
@@ -1977,7 +2140,22 @@ class Flomni(
|
||||
dev = builtins.__dict__.get("dev")
|
||||
bec = builtins.__dict__.get("bec")
|
||||
|
||||
self.feye_out()
|
||||
# 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
|
||||
# re-zeros the interferometers and moves us away from the current
|
||||
# sample position. Skipping it when unnecessary lets us repeat
|
||||
# alignment scans (e.g. after changing scan parameters) or interleave
|
||||
# an alignment run into a tomogram while staying right where the last
|
||||
# measurement was.
|
||||
feedback_running = dev.rtx.controller.feedback_is_running()
|
||||
if self._check_eye_out_and_optics_in() and feedback_running:
|
||||
print(
|
||||
"Setup already in measurement condition with feedback running; "
|
||||
"skipping feye_out() to avoid an interferometer reset."
|
||||
)
|
||||
else:
|
||||
self.feye_out()
|
||||
tags = ["BEC_alignment_tomo", self.sample_name]
|
||||
self.write_alignment_scan_numbers(bec.queue.next_scan_number)
|
||||
start_angle = 0
|
||||
@@ -2247,6 +2425,68 @@ class Flomni(
|
||||
end_scan_number=end_scan_number,
|
||||
)
|
||||
|
||||
def collect_empty_frames(self):
|
||||
"""Acquire 10 empty-frame (flat-field) images at angle 0, with fsamx
|
||||
shifted out of the beam by half the field of view (fovx/2), in the
|
||||
direction opposite the normal alignment x-offset at angle 0.
|
||||
|
||||
Called once at the start of a new tomo_scan() (not on resume).
|
||||
These frames are deliberately not passed through tomo_reconstruct()
|
||||
-- they are flat fields, not ptycho projections, and must not be
|
||||
added to the reconstruction queue. Logged to
|
||||
tomography_scannumbers.txt with subtomo_number=0 so they remain
|
||||
traceable but are clearly distinguished from the angular grid
|
||||
(subtomo_number 1-8).
|
||||
"""
|
||||
scans = builtins.__dict__.get("scans")
|
||||
|
||||
angle = 0
|
||||
|
||||
# --- rotation ---
|
||||
fsamroy_current_setpoint = dev.fsamroy.user_setpoint.get()
|
||||
if angle != fsamroy_current_setpoint:
|
||||
umv(dev.fsamroy, angle)
|
||||
else:
|
||||
print("No rotation required")
|
||||
|
||||
# --- alignment offset at angle 0, then push x out of the beam by
|
||||
# fovx/2, in the direction opposite the normal offset ---
|
||||
offsets = self.get_alignment_offset(angle)
|
||||
normal_offset_x = offsets[0]
|
||||
direction = -1 if normal_offset_x >= 0 else 1
|
||||
sum_offset_x = normal_offset_x + direction * (self.fovx / 2) + direction * 20
|
||||
sum_offset_y = (
|
||||
offsets[1]
|
||||
- self.compute_additional_correction_y(angle)
|
||||
- self.compute_additional_correction_y_2(angle)
|
||||
+ self.manual_shift_y
|
||||
)
|
||||
# sum_offset_z = offsets[2]
|
||||
# Todo
|
||||
sum_offset_z = 0
|
||||
|
||||
|
||||
dev.rtx.controller.laser_tracker_on()
|
||||
umv(dev.rtx, sum_offset_x, dev.rty, sum_offset_y, dev.rtz, sum_offset_z)
|
||||
tracker_signal = dev.rtx.controller.laser_tracker_check_signalstrength()
|
||||
# checks that the fsamx coarse stage is at a position that leaves
|
||||
# sufficient piezo range on the fine (rtx) stage
|
||||
dev.rtx.controller.move_samx_to_scan_region(sum_offset_x)
|
||||
|
||||
if tracker_signal == "low":
|
||||
logger.warning("Signal strength of the laser tracker is low. Realignment recommended!")
|
||||
elif tracker_signal == "toolow":
|
||||
raise FlomniError(
|
||||
"Signal strength of the laser tracker is too low for scanning. Realignment required!"
|
||||
)
|
||||
|
||||
print("Acquiring 10 empty frames at angle 0, fsamx shifted out of the beam.")
|
||||
start_scan_number = bec.queue.next_scan_number
|
||||
scans.acquire(exp_time=self.tomo_countingtime, frames_per_trigger=10)
|
||||
end_scan_number = bec.queue.next_scan_number
|
||||
for scan_nr in range(start_scan_number, end_scan_number):
|
||||
self._write_tomo_scan_number(scan_nr, angle, subtomo_number=0)
|
||||
|
||||
def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None):
|
||||
"""start a tomo scan"""
|
||||
|
||||
@@ -2299,6 +2539,7 @@ class Flomni(
|
||||
self.progress["estimated_finish_time"] = None
|
||||
self.progress["accumulated_idle_time"] = 0.0
|
||||
self.progress["heartbeat"] = None
|
||||
self.collect_empty_frames()
|
||||
|
||||
with scans.dataset_id_on_hold:
|
||||
if self.tomo_type == 1:
|
||||
@@ -2423,9 +2664,35 @@ class Flomni(
|
||||
self._print_progress()
|
||||
self._log_tomogram_timing()
|
||||
self.OMNYTools.printgreenbold("Tomoscan finished")
|
||||
print(
|
||||
f"Total measurement time lost to detected gaps: {self._format_duration(self.progress.get('accumulated_idle_time', 0.0))}"
|
||||
idle_s = self.progress.get("accumulated_idle_time", 0.0)
|
||||
start_str = self.progress.get("tomo_start_time")
|
||||
elapsed_s = None
|
||||
if start_str is not None:
|
||||
try:
|
||||
elapsed_s = (
|
||||
datetime.datetime.now() - datetime.datetime.fromisoformat(start_str)
|
||||
).total_seconds()
|
||||
except (ValueError, TypeError):
|
||||
elapsed_s = None
|
||||
timing_lines = [
|
||||
"Tomoscan finished.",
|
||||
f"Measurement ID: {self.tomo_id}",
|
||||
f"Sample: {self.sample_name}",
|
||||
]
|
||||
if elapsed_s is not None:
|
||||
timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}")
|
||||
timing_lines.append(
|
||||
f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}"
|
||||
)
|
||||
timing_lines.append(
|
||||
f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}"
|
||||
)
|
||||
for line in timing_lines[3:]:
|
||||
print(line)
|
||||
timing_content = "\n".join(timing_lines)
|
||||
bec.messaging.scilog.new().add_text(timing_content.replace("\n", "<br>")).add_tags(
|
||||
"tomoscan"
|
||||
).send()
|
||||
|
||||
def tomo_scan_resume(self) -> None:
|
||||
"""Resume a tomo_scan() that crashed or was interrupted, picking up
|
||||
@@ -2606,6 +2873,7 @@ class Flomni(
|
||||
):
|
||||
"""write the tomo reconstruct file for the reconstruction queue"""
|
||||
bec = builtins.__dict__.get("bec")
|
||||
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,
|
||||
@@ -2726,6 +2994,108 @@ class Flomni(
|
||||
}
|
||||
self._append_timing_record(self._TOMOGRAM_TIMING_LOG, record)
|
||||
|
||||
def _read_last_timing_records(self, number_of_scans: int) -> list[dict]:
|
||||
"""Return the last ``number_of_scans`` projection timing records.
|
||||
|
||||
Reads back the append-only projection timing log written by
|
||||
_log_projection_timing(). Returns a list ordered oldest-to-newest
|
||||
(i.e. in acquisition order), or an empty list if the log is missing
|
||||
or unreadable.
|
||||
"""
|
||||
log_file = os.path.join(
|
||||
os.path.expanduser(self._TIMING_LOG_DIR), self._PROJECTION_TIMING_LOG
|
||||
)
|
||||
records: list[dict] = []
|
||||
try:
|
||||
with open(log_file, "r") as in_file:
|
||||
lines = [ln for ln in in_file if ln.strip()]
|
||||
for line in lines[-number_of_scans:]:
|
||||
try:
|
||||
records.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except FileNotFoundError:
|
||||
print(f"scilog_last_scans: timing log not found at {log_file}")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
print(f"scilog_last_scans: could not read timing log: {exc}")
|
||||
return records
|
||||
|
||||
def scilog_last_ptycho_scans(self, number_of_scans: int = 1) -> None:
|
||||
"""Write a scilog entry with a user comment and recent scan info.
|
||||
|
||||
The user is prompted for a free-text comment, which appears at the
|
||||
top of the entry. For each of the last ``number_of_scans`` projections
|
||||
(1 to 4) the entry then lists the scan number(s), field of view,
|
||||
exposure (counting) time and scan duration, read from the projection
|
||||
timing log. Pass ``number_of_scans=0`` to send a comment only, with no
|
||||
scan information.
|
||||
|
||||
Only ptycho projections write a timing record, so non-ptycho scans do
|
||||
not appear; if fewer records exist than requested, whatever is
|
||||
available is used and a note is added.
|
||||
"""
|
||||
if not isinstance(number_of_scans, int) or isinstance(number_of_scans, bool):
|
||||
print("scilog_last_scans: number_of_scans must be an integer between 0 and 4.")
|
||||
return
|
||||
if not 0 <= number_of_scans <= 4:
|
||||
print("scilog_last_scans: number_of_scans must be between 0 and 4.")
|
||||
return
|
||||
|
||||
records = self._read_last_timing_records(number_of_scans) if number_of_scans else []
|
||||
if number_of_scans and not records:
|
||||
print(
|
||||
"scilog_last_scans: no scans found in the timing log; "
|
||||
"sending a comment-only entry."
|
||||
)
|
||||
|
||||
comment = input("Enter a comment for the scilog entry: ").strip()
|
||||
if not comment and not records:
|
||||
print("scilog_last_scans: empty comment and no scans — nothing to write.")
|
||||
return
|
||||
|
||||
lines = []
|
||||
if comment:
|
||||
lines.append(f"{comment}")
|
||||
|
||||
if records:
|
||||
if comment:
|
||||
lines.append("")
|
||||
if len(records) < number_of_scans:
|
||||
lines.append(
|
||||
f"flOMNI summary of the last {len(records)} scan(s) "
|
||||
f"(only {len(records)} of {number_of_scans} requested were found):"
|
||||
)
|
||||
else:
|
||||
lines.append(f"flOMNI parameters:")
|
||||
lines.append("")
|
||||
for rec in records:
|
||||
start = rec.get("start_scan_number")
|
||||
end = rec.get("end_scan_number")
|
||||
if start is not None and end is not None and end - start > 1:
|
||||
scan_str = f"{start}-{end - 1}"
|
||||
elif start is not None:
|
||||
scan_str = f"{start}"
|
||||
else:
|
||||
scan_str = "?"
|
||||
fovx = rec.get("fovx")
|
||||
fovy = rec.get("fovy")
|
||||
fov_str = f"{fovx} x {fovy} um" if fovx is not None and fovy is not None else "?"
|
||||
exposure = rec.get("tomo_countingtime")
|
||||
exp_str = f"{exposure} s" if exposure is not None else "?"
|
||||
duration = rec.get("duration_s")
|
||||
dur_str = self._format_duration(duration) if duration is not None else "?"
|
||||
lines.append(
|
||||
f"scan {scan_str}: FOV {fov_str}, exposure {exp_str}, duration {dur_str}"
|
||||
)
|
||||
|
||||
content = "\n".join(lines)
|
||||
print(content)
|
||||
|
||||
bec = builtins.__dict__.get("bec")
|
||||
bec.messaging.scilog.new().add_text(content.replace("\n", "<br>")).add_tags(
|
||||
"tomoscan"
|
||||
).send()
|
||||
|
||||
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")
|
||||
with open(tomo_scan_numbers_file, "a+") as out_file:
|
||||
@@ -2781,6 +3151,8 @@ class Flomni(
|
||||
|
||||
self._current_scan_list = []
|
||||
|
||||
start_scan_number = bec.queue.next_scan_number
|
||||
projection_start = time.perf_counter()
|
||||
for stitch_x in range(-self.stitch_x, self.stitch_x + 1):
|
||||
for stitch_y in range(-self.stitch_y, self.stitch_y + 1):
|
||||
# pylint: disable=undefined-variable
|
||||
@@ -2816,6 +3188,21 @@ class Flomni(
|
||||
|
||||
scans.flomni_fermat_scan(**scan_kwargs)
|
||||
|
||||
projection_duration = time.perf_counter() - projection_start
|
||||
end_scan_number = bec.queue.next_scan_number
|
||||
# Internal callers (_at_each_angle, tomo_alignment_scan) run inside a
|
||||
# flow that already logs projection timing in _tomo_scan_at_angle, so
|
||||
# only log here for direct CLI calls (e.g. commissioning) to avoid a
|
||||
# duplicate record per projection.
|
||||
if not _internal:
|
||||
self._log_projection_timing(
|
||||
angle=angle,
|
||||
subtomo_number=0,
|
||||
duration_s=projection_duration,
|
||||
start_scan_number=start_scan_number,
|
||||
end_scan_number=end_scan_number,
|
||||
)
|
||||
|
||||
self.tomo_reconstruct(probe_propagation=probe_propagation)
|
||||
|
||||
def tomo_acquire_at_angle(self, angle: float, frames_per_trigger: int | None = None):
|
||||
|
||||
@@ -72,19 +72,50 @@ class FlomniOpticsMixin:
|
||||
else:
|
||||
print("FZP is already at the in position.")
|
||||
|
||||
def ffzp_in(self):
|
||||
return need_move_optics
|
||||
|
||||
def ffzp_in(self, force_feedback_reset=False):
|
||||
"""
|
||||
move in the flomni zone plate.
|
||||
This will disable rt feedback, move the FZP and re-enabled the feedback.
|
||||
This will disable rt feedback, move the FZP and re-enable the feedback.
|
||||
|
||||
The FZP move requires rt feedback OFF, and moving the FZP invalidates
|
||||
the interferometer zero, so feedback is re-enabled *with reset*
|
||||
afterwards. That reset is expensive: it re-zeros the interferometers
|
||||
and moves you away from wherever the sample currently sits, which is
|
||||
undesirable when the FZP is already in and feedback is already running
|
||||
(e.g. repeated alignment scans, or interleaving an alignment run into a
|
||||
tomogram).
|
||||
|
||||
Therefore the disable/move/reset cycle is skipped entirely when the FZP
|
||||
does not actually need to move. Pass ``force_feedback_reset=True`` to
|
||||
force the full disable + reset cycle even if the FZP is already in.
|
||||
"""
|
||||
if "rtx" in dev and dev.rtx.enabled:
|
||||
rtx_present = "rtx" in dev and dev.rtx.enabled
|
||||
|
||||
# Only disable feedback if we're going to move the FZP (or a reset was
|
||||
# explicitly requested). If the FZP is already in and feedback is
|
||||
# already running, leave it untouched -- disabling and
|
||||
# re-enabling-with-reset would needlessly re-zero the interferometers.
|
||||
needs_move = not self._ffzp_is_in()
|
||||
do_cycle = needs_move or force_feedback_reset
|
||||
|
||||
if rtx_present and do_cycle:
|
||||
dev.rtx.controller.feedback_disable()
|
||||
|
||||
self._ffzp_in()
|
||||
|
||||
if "rtx" in dev and dev.rtx.enabled:
|
||||
if rtx_present and do_cycle:
|
||||
dev.rtx.controller.feedback_enable_with_reset()
|
||||
|
||||
def _ffzp_is_in(self, tol=0.003):
|
||||
"""True if both FZP axes (foptx, fopty) are within ``tol`` of their IN position."""
|
||||
foptx_in = self._get_user_param_safe("foptx", "in")
|
||||
fopty_in = self._get_user_param_safe("fopty", "in")
|
||||
return np.isclose(dev.foptx.readback.get(), foptx_in, atol=tol) and np.isclose(
|
||||
dev.fopty.readback.get(), fopty_in, atol=tol
|
||||
)
|
||||
|
||||
def foptics_in(self):
|
||||
"""
|
||||
Move in the flomni optics, including the FZP and the OSA.
|
||||
@@ -290,5 +321,4 @@ class FlomniOpticsMixin:
|
||||
print(
|
||||
f" Note: OSA is {(-diff)*1000:.1f} um away from its IN position (likely parked OUT)."
|
||||
)
|
||||
print(f" Remaining space if OSA is moved to its IN position: \033[1m{remaining_at_in:.1f}\033[0m")
|
||||
|
||||
print(f" Remaining space if OSA is moved to its IN position: \033[1m{remaining_at_in:.1f}\033[0m")
|
||||
@@ -22,6 +22,14 @@ class flomniGuiToolsError(Exception):
|
||||
|
||||
|
||||
class flomniGuiTools:
|
||||
# Screen assumed 2560x1440. Window is right-aligned with a small margin
|
||||
# from the top; width/height tuned from the first test render (which
|
||||
# came out taller than intended at 1000px).
|
||||
_SCREEN_WIDTH = 2560
|
||||
_WINDOW_WIDTH = 1500
|
||||
_WINDOW_HEIGHT = 850
|
||||
_WINDOW_TOP_MARGIN = 50
|
||||
|
||||
def __init__(self):
|
||||
self.text_box = None
|
||||
self.progressbar = None
|
||||
@@ -31,6 +39,7 @@ class flomniGuiTools:
|
||||
self.idle_text_box = None
|
||||
self.camera_gripper_image = None
|
||||
self.camera_overview_image = None
|
||||
self.console = None
|
||||
|
||||
def set_client(self, client):
|
||||
self.client = client
|
||||
@@ -41,7 +50,10 @@ class flomniGuiTools:
|
||||
self.flomni_window = self.gui.windows["flomni"]
|
||||
self.gui.flomni.raise_window()
|
||||
else:
|
||||
self.flomni_window = self.gui.new("flomni")
|
||||
# geometry: (pos_x, pos_y, w, h)
|
||||
pos_x = self._SCREEN_WIDTH - self._WINDOW_WIDTH
|
||||
geometry = (pos_x, self._WINDOW_TOP_MARGIN, self._WINDOW_WIDTH, self._WINDOW_HEIGHT)
|
||||
self.flomni_window = self.gui.new("flomni", geometry=geometry)
|
||||
time.sleep(1)
|
||||
|
||||
def flomnigui_stop_gui(self):
|
||||
@@ -90,8 +102,10 @@ class flomniGuiTools:
|
||||
|
||||
def flomnigui_show_cameras(self):
|
||||
self.flomnigui_show_gui()
|
||||
if self._flomnigui_is_missing("camera_gripper_image") or self._flomnigui_is_missing(
|
||||
"camera_overview_image"
|
||||
if (
|
||||
self._flomnigui_is_missing("camera_gripper_image")
|
||||
or self._flomnigui_is_missing("camera_overview_image")
|
||||
or self._flomnigui_is_missing("console")
|
||||
):
|
||||
self.flomnigui_remove_all_docks()
|
||||
self.camera_gripper_image = self.gui.flomni.new("Image")
|
||||
@@ -117,6 +131,16 @@ class flomniGuiTools:
|
||||
else:
|
||||
print("Cannot open camera_overview. Device does not exist.")
|
||||
|
||||
# Confirm/abort console, docked below the cameras.
|
||||
self.console = self.gui.flomni.new(
|
||||
"ConsoleButtonsWidget", object_name="console", where="bottom"
|
||||
)
|
||||
# set_layout_ratios uses relative weights, not pixels -- there is
|
||||
# no width/height kwarg on dock_area.new(). [5, 1] gives the
|
||||
# cameras most of the vertical space and keeps the console a
|
||||
# slim strip at the bottom; adjust to taste once you see it.
|
||||
self.gui.flomni.set_layout_ratios(vertical=[5, 1])
|
||||
|
||||
def flomnigui_remove_all_docks(self):
|
||||
# dev.cam_flomni_overview.stop_live_mode()
|
||||
# dev.cam_flomni_gripper.stop_live_mode()
|
||||
@@ -130,6 +154,7 @@ class flomniGuiTools:
|
||||
self.idle_text_box = None
|
||||
self.camera_gripper_image = None
|
||||
self.camera_overview_image = None
|
||||
self.console = None
|
||||
|
||||
def flomnigui_idle(self):
|
||||
self.flomnigui_show_gui()
|
||||
@@ -316,4 +341,4 @@ if __name__ == "__main__":
|
||||
flomni_gui = flomniGuiTools()
|
||||
flomni_gui.set_client(client)
|
||||
flomni_gui.flomnigui_show_gui()
|
||||
flomni_gui.flomnigui_show_progress()
|
||||
flomni_gui.flomnigui_show_progress()
|
||||
@@ -28,9 +28,13 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class XrayEyeAlign:
|
||||
# pixel calibration, multiply to get mm
|
||||
# Pixel calibration in mm/pixel (multiply pixel values to get mm).
|
||||
# The live value is read from the cam_xeye device's ``pixel_calibration``
|
||||
# user parameter (see the ``pixel_calibration`` property); this constant is
|
||||
# only the fallback used when that device/parameter is unavailable.
|
||||
test_wo_movements = False
|
||||
PIXEL_CALIBRATION = 0.1 / 113 # .2 with binning
|
||||
PIXEL_CALIBRATION_DEFAULT = 0.05 / 113 # mm per raw ROI pixel (.1/113 with binning)
|
||||
PIXEL_CALIBRATION_USER_PARAM = "pixel_calibration"
|
||||
|
||||
# Sign for the automatic vertical-centering move in the height-centering
|
||||
# branch of _align_impl (search for `_height_centered`).
|
||||
@@ -62,6 +66,22 @@ class XrayEyeAlign:
|
||||
def gui(self):
|
||||
return self.flomni.xeyegui
|
||||
|
||||
@property
|
||||
def pixel_calibration(self) -> float:
|
||||
"""Pixel calibration in mm/pixel.
|
||||
|
||||
Reads the ``pixel_calibration`` user parameter from the cam_xeye device;
|
||||
falls back to ``PIXEL_CALIBRATION_DEFAULT`` if the device or parameter is
|
||||
unavailable.
|
||||
"""
|
||||
try:
|
||||
mm_per_pixel = dev.cam_xeye.user_parameter.get(self.PIXEL_CALIBRATION_USER_PARAM)
|
||||
except Exception:
|
||||
mm_per_pixel = None
|
||||
if mm_per_pixel is None:
|
||||
return self.PIXEL_CALIBRATION_DEFAULT
|
||||
return float(mm_per_pixel)
|
||||
|
||||
def _reset_init_values(self):
|
||||
self.shift_xy = [0, 0]
|
||||
self._xray_fov_xy = [0, 0]
|
||||
@@ -183,6 +203,7 @@ class XrayEyeAlign:
|
||||
# there.
|
||||
self.flomni.reset_correction()
|
||||
self.flomni.reset_tomo_alignment_fit()
|
||||
self.flomni.manual_shift_y = 0
|
||||
|
||||
self.flomni.lights_off()
|
||||
|
||||
@@ -222,7 +243,7 @@ class XrayEyeAlign:
|
||||
if dev.omny_xray_gui.submit.get() == 1:
|
||||
|
||||
self.alignment_values[k] = (
|
||||
getattr(dev.omny_xray_gui, f"xval_x_{k}").get() / 2 * self.PIXEL_CALIBRATION
|
||||
getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration
|
||||
) # in mm
|
||||
print(f"Clicked position {k}: x {self.alignment_values[k]}")
|
||||
rtx_position = dev.rtx.readback.get() / 1000
|
||||
@@ -297,8 +318,7 @@ class XrayEyeAlign:
|
||||
delta_y_mm = (
|
||||
self.HEIGHT_CENTERING_SIGN
|
||||
* (fzp_center_y - mark_y)
|
||||
/ 2
|
||||
* self.PIXEL_CALIBRATION
|
||||
* self.pixel_calibration
|
||||
)
|
||||
print(
|
||||
f"Height centering: fzp_center_y={fzp_center_y}, mark_y={mark_y}, "
|
||||
@@ -367,8 +387,8 @@ class XrayEyeAlign:
|
||||
time.sleep(0.1)
|
||||
|
||||
self.write_output()
|
||||
fovx = self._xray_fov_xy[0] * self.PIXEL_CALIBRATION * 1000 / 2
|
||||
fovy = self._xray_fov_xy[1] * self.PIXEL_CALIBRATION * 1000 / 2
|
||||
fovx = self._xray_fov_xy[0] * self.pixel_calibration * 1000
|
||||
fovy = self._xray_fov_xy[1] * self.pixel_calibration * 1000
|
||||
|
||||
if keep_shutter_open:
|
||||
if self.flomni.OMNYTools.yesno("Close the shutter now?", "y"):
|
||||
@@ -376,10 +396,12 @@ class XrayEyeAlign:
|
||||
self.gui.on_live_view_enabled(False)
|
||||
print("setting 'XOMNYI-XEYE-ACQ:0'")
|
||||
|
||||
print(
|
||||
self.flomni.OMNYTools.printgreenbold(
|
||||
f"The largest field of view from the xrayeyealign was \nfovx = {fovx:.0f} microns, fovy"
|
||||
f" = {fovy:.0f} microns"
|
||||
f" = {fovy:.0f} microns. Adjusting the flomni.fovx to {fovx+6:.0f} microns."
|
||||
)
|
||||
self.flomni.fovx = fovx+6
|
||||
|
||||
print("Check the fit in the GUI...")
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
@@ -93,6 +93,73 @@ class OMNYTools:
|
||||
else:
|
||||
print("Please expicitely confirm y or n.")
|
||||
|
||||
def gui_yesno(
|
||||
self, message: str, gui, default="none", autoconfirm=0, poll_interval: float = 0.1
|
||||
) -> bool:
|
||||
"""
|
||||
GUI-based alternative to yesno(), using a ConsoleButtonsWidget
|
||||
(csaxs_bec/bec_widgets/widgets/console_buttons/console_buttons.py)
|
||||
instead of a blocking console input() prompt.
|
||||
|
||||
Not yet wired up as a replacement for yesno() anywhere -- this is
|
||||
for standalone testing. Once confirmed working, call sites can be
|
||||
switched over deliberately, one at a time.
|
||||
|
||||
Opening the GUI (must already exist and be passed in as `gui`;
|
||||
this method does not create it):
|
||||
|
||||
gui.new("test", timeout=20)
|
||||
console = gui.test.new("ConsoleButtonsWidget", object_name="console", timeout=20)
|
||||
|
||||
Then call, e.g. from an IPython session:
|
||||
|
||||
omny_tools.gui_yesno("Continue with sample transfer?", gui=console)
|
||||
|
||||
Args:
|
||||
message (str): Question to display on the widget.
|
||||
gui: The already-open ConsoleButtonsWidget RPC object (e.g. the
|
||||
`console` object created above, or later a fixed instance
|
||||
such as `gui.flomni.console` once wired into flomni's own
|
||||
gui tools).
|
||||
default (str): "y" or "n" -- only affects the autoconfirm path,
|
||||
same as yesno(). There is no "just press enter" equivalent
|
||||
for a button click, so this has no effect otherwise.
|
||||
autoconfirm (int): if set together with default="y"/"n", skips
|
||||
the GUI entirely and returns immediately, same as yesno().
|
||||
poll_interval (float): seconds between response checks, same
|
||||
style as the 0.1 s submit-poll in XrayEyeAlign.align().
|
||||
|
||||
Returns:
|
||||
bool: True for "yes", False for "no".
|
||||
|
||||
Note on abort: the widget's ABORT button does not write a response
|
||||
to poll for -- it sends a real SIGINT directly to this process (see
|
||||
ConsoleButtonsWidget._on_abort), so pressing it raises
|
||||
KeyboardInterrupt here exactly as a console Ctrl+C would, and
|
||||
propagates normally out of this method without any special-casing.
|
||||
"""
|
||||
if autoconfirm and default == "y":
|
||||
self.printgreen(message + " Automatically confirming default: yes")
|
||||
return True
|
||||
elif autoconfirm and default == "n":
|
||||
self.printgreen(message + " Automatically confirming default: no")
|
||||
return False
|
||||
|
||||
suffix = {"y": " [Y]/n?", "n": " y/[N]?"}.get(default, " y/n?")
|
||||
gui.clear_response()
|
||||
gui.message = message + suffix
|
||||
while True:
|
||||
response = gui.response()
|
||||
if response == "yes":
|
||||
gui.clear_response()
|
||||
gui.message = ""
|
||||
return True
|
||||
if response == "no":
|
||||
gui.clear_response()
|
||||
gui.message = ""
|
||||
return False
|
||||
time.sleep(poll_interval)
|
||||
|
||||
def tweak_cursor(
|
||||
self, dev1, step1: float, dev2="none", step2: float = "0", special_command="none"
|
||||
):
|
||||
|
||||
@@ -12,6 +12,7 @@ logger = bec_logger.logger
|
||||
|
||||
|
||||
_Widgets = {
|
||||
"ConsoleButtonsWidget": "ConsoleButtonsWidget",
|
||||
"SampleStorageWidget": "SampleStorageWidget",
|
||||
"SAXSWidget": "SAXSWidget",
|
||||
"SlitControlWidget": "SlitControlWidget",
|
||||
@@ -20,6 +21,47 @@ _Widgets = {
|
||||
}
|
||||
|
||||
|
||||
class ConsoleButtonsWidget(RPCBase):
|
||||
"""Small Yes / No / Abort control widget, intended as a GUI replacement for"""
|
||||
|
||||
_IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons"
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def message(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@message.setter
|
||||
@rpc_call
|
||||
def message(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def response(self) -> "str":
|
||||
"""
|
||||
Current button response: "yes", "no", or "" if no button has been
|
||||
pressed (or since the last clear_response()).
|
||||
|
||||
Note: intentionally a plain method, not @SafeProperty. SafeProperty
|
||||
only becomes a real Qt property descriptor once a setter is chained
|
||||
in the class body (see `message` above); a getter-only SafeProperty
|
||||
is left as an unconverted internal wrapper object, which the RPC
|
||||
generator then exposes as a callable stub anyway. Since `response`
|
||||
has no RPC-facing setter, a plain method avoids that trap entirely -
|
||||
same pattern XRayEye uses for its own read-only `active_roi`.
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def clear_response(self):
|
||||
"""
|
||||
None
|
||||
"""
|
||||
|
||||
|
||||
class SampleStorageWidget(RPCBase):
|
||||
"""View and correct the FlOMNI sample-storage records."""
|
||||
|
||||
@@ -28,7 +70,14 @@ class SampleStorageWidget(RPCBase):
|
||||
@rpc_call
|
||||
def refresh(self) -> "None":
|
||||
"""
|
||||
Re-read every slot from the device and update the cells.
|
||||
Re-read all slots (one bulk device round-trip) and update only the
|
||||
cells whose state actually changed.
|
||||
|
||||
Repainting every cell on every 2 s poll — even when nothing changed,
|
||||
which is almost always the case for a sample magazine — was needless
|
||||
work on the GUI thread. We diff against the last-seen state and only
|
||||
call set_state() on cells that differ, so a steady-state poll does no
|
||||
UI work at all.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.messages import VariableMessage
|
||||
from bec_widgets import BECWidget, SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
|
||||
class ConsoleButtonsWidget(BECWidget, QWidget):
|
||||
"""
|
||||
Small Yes / No / Abort control widget, intended as a GUI replacement for
|
||||
console prompts (e.g. ``OMNYTools.yesno()``) and as a general-purpose
|
||||
emergency-stop control.
|
||||
|
||||
- Yes / No: set ``response`` to "yes" / "no". A blocking CLI script can
|
||||
poll ``gui.<name>.response`` and reset it via ``clear_response()``.
|
||||
- Abort: sends a real SIGINT to the BEC IPython client process (the
|
||||
parent of the GUI server process), equivalent to pressing Ctrl+C in
|
||||
the console. This works even if the client is blocked inside a motor
|
||||
move or other long call, since it is a real OS signal rather than a
|
||||
polled flag. The SIGINT is only sent if the parent process actually
|
||||
looks like a BEC client -- it does not when the widget is opened
|
||||
standalone from the launcher menu. In addition, 500 ms later a device
|
||||
stop request is published to the device server (same mechanism as the
|
||||
PositionerBox stop button): by default for ALL devices, so the widget
|
||||
acts as a generic emergency stop from any context, even if the client
|
||||
process is hung or dead.
|
||||
"""
|
||||
|
||||
USER_ACCESS = ["message", "message.setter", "response", "clear_response"]
|
||||
PLUGIN = True
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
# Devices for which a backup stop request is published when ABORT is
|
||||
# pressed. An empty list (default) means "stop ALL devices" -- the
|
||||
# same device-server path BEC uses on scan abort -- which makes the
|
||||
# widget a generic emergency stop, e.g. when opened standalone from
|
||||
# the launcher menu. Stop-all also covers the flomni sample transfer:
|
||||
# XQ#STOP via ftransy is controller-wide and halts the #GRGET/#GRPUT
|
||||
# thread. Pass an explicit list to restrict the stop.
|
||||
self._backup_stop_devices = list(kwargs.pop("backup_stop_devices", []))
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._response = ""
|
||||
# Captured once at construction time: the GUI server process is a
|
||||
# direct child of the BEC IPython client (subprocess.Popen in
|
||||
# bec_widgets.cli.client_utils), so getppid() here is the client's
|
||||
# PID. Not re-read later, so a subsequent reparenting (e.g. if the
|
||||
# client died) can't silently redirect the signal.
|
||||
self._client_pid = os.getppid()
|
||||
self._init_ui()
|
||||
|
||||
def _init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self.yes_button = QPushButton("Yes", parent=self)
|
||||
self.no_button = QPushButton("No", parent=self)
|
||||
self.abort_button = QPushButton("ABORT", parent=self)
|
||||
self.abort_button.setStyleSheet(
|
||||
"background-color: #c0392b; color: white; font-weight: bold;"
|
||||
)
|
||||
button_row.addWidget(self.yes_button)
|
||||
button_row.addWidget(self.no_button)
|
||||
button_row.addWidget(self.abort_button)
|
||||
layout.addLayout(button_row)
|
||||
|
||||
self.message_label = QLabel("", parent=self)
|
||||
self.message_label.setWordWrap(True)
|
||||
layout.addWidget(self.message_label)
|
||||
|
||||
self.yes_button.clicked.connect(self._on_yes)
|
||||
self.no_button.clicked.connect(self._on_no)
|
||||
self.abort_button.clicked.connect(self._on_abort)
|
||||
|
||||
# Start with Yes/No greyed out: with no message there is nothing to
|
||||
# respond to, so there should be nothing clickable. ABORT is left
|
||||
# always enabled -- it's an emergency stop and must work at any time,
|
||||
# message or not.
|
||||
self._set_yesno_enabled(False)
|
||||
|
||||
def _set_yesno_enabled(self, enabled: bool):
|
||||
self.yes_button.setEnabled(enabled)
|
||||
self.no_button.setEnabled(enabled)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_yes(self):
|
||||
self._response = "yes"
|
||||
|
||||
@SafeSlot()
|
||||
def _on_no(self):
|
||||
self._response = "no"
|
||||
|
||||
def _client_is_bec_process(self) -> bool:
|
||||
"""
|
||||
Heuristic check whether the parent process (captured at construction
|
||||
time) is a BEC IPython client. Only then does SIGINT make sense: when
|
||||
the widget is opened from the standalone launcher instead of a
|
||||
client-spawned GUI server, the parent is e.g. a shell or the launcher
|
||||
process, which must not receive the signal.
|
||||
"""
|
||||
try:
|
||||
with open(f"/proc/{self._client_pid}/cmdline", "rb") as f:
|
||||
cmdline = f.read().replace(b"\x00", b" ").decode(errors="ignore")
|
||||
except OSError:
|
||||
return False
|
||||
return ("bec" in cmdline) or ("ipython" in cmdline)
|
||||
|
||||
@SafeSlot()
|
||||
def _on_abort(self):
|
||||
if self._client_is_bec_process():
|
||||
logger.warning(f"ConsoleButtonsWidget: sending SIGINT to client pid {self._client_pid}")
|
||||
os.kill(self._client_pid, signal.SIGINT)
|
||||
else:
|
||||
logger.warning(
|
||||
"ConsoleButtonsWidget: parent process does not look like a BEC client;"
|
||||
" skipping SIGINT and only sending the device stop request."
|
||||
)
|
||||
# Backup: direct device stop via the device server, independent of
|
||||
# the client process. Delayed so the SIGINT-triggered abort handler
|
||||
# in the client (which still sees mntprgs=1 and aborts in a
|
||||
# controlled way) wins the race: if the stop landed first, #STOP
|
||||
# would clear mntprgs and the client transfer loop would exit
|
||||
# "cleanly" into ensure_gripper_up, which must not happen
|
||||
# mid-transfer.
|
||||
QTimer.singleShot(500, self._send_backup_stop)
|
||||
|
||||
@SafeSlot()
|
||||
def _send_backup_stop(self):
|
||||
"""Publish a stop request for the configured devices to the device server."""
|
||||
devices = self._backup_stop_devices
|
||||
logger.warning(f"ConsoleButtonsWidget: sending backup stop request for {devices}")
|
||||
self.client.connector.send(MessageEndpoints.stop_devices(), VariableMessage(value=devices))
|
||||
|
||||
@SafeProperty(str)
|
||||
def message(self):
|
||||
return self.message_label.text()
|
||||
|
||||
@message.setter
|
||||
@rpc_timeout(20)
|
||||
def message(self, text: str):
|
||||
self.message_label.setText(text)
|
||||
# A non-empty message means there's something to respond to, so
|
||||
# enable Yes/No; an empty message greys them out again.
|
||||
self._set_yesno_enabled(bool(text))
|
||||
|
||||
def response(self) -> str:
|
||||
"""
|
||||
Current button response: "yes", "no", or "" if no button has been
|
||||
pressed (or since the last clear_response()).
|
||||
|
||||
Note: intentionally a plain method, not @SafeProperty. SafeProperty
|
||||
only becomes a real Qt property descriptor once a setter is chained
|
||||
in the class body (see `message` above); a getter-only SafeProperty
|
||||
is left as an unconverted internal wrapper object, which the RPC
|
||||
generator then exposes as a callable stub anyway. Since `response`
|
||||
has no RPC-facing setter, a plain method avoids that trap entirely -
|
||||
same pattern XRayEye uses for its own read-only `active_roi`.
|
||||
"""
|
||||
return self._response
|
||||
|
||||
@SafeSlot()
|
||||
def clear_response(self):
|
||||
self._response = ""
|
||||
@@ -0,0 +1 @@
|
||||
{'files': ['console_buttons.py']}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (C) 2022 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
||||
|
||||
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_designer import designer_material_icon
|
||||
from csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons import ConsoleButtonsWidget
|
||||
|
||||
DOM_XML = """
|
||||
<ui language='c++'>
|
||||
<widget class='ConsoleButtonsWidget' name='console_buttons_widget'>
|
||||
</widget>
|
||||
</ui>
|
||||
"""
|
||||
|
||||
|
||||
class ConsoleButtonsWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._form_editor = None
|
||||
|
||||
def createWidget(self, parent):
|
||||
if parent is None:
|
||||
return QWidget()
|
||||
t = ConsoleButtonsWidget(parent)
|
||||
return t
|
||||
|
||||
def domXml(self):
|
||||
return DOM_XML
|
||||
|
||||
def group(self):
|
||||
return ""
|
||||
|
||||
def icon(self):
|
||||
return designer_material_icon(ConsoleButtonsWidget.ICON_NAME)
|
||||
|
||||
def includeFile(self):
|
||||
return "console_buttons_widget"
|
||||
|
||||
def initialize(self, form_editor):
|
||||
self._form_editor = form_editor
|
||||
|
||||
def isContainer(self):
|
||||
return False
|
||||
|
||||
def isInitialized(self):
|
||||
return self._form_editor is not None
|
||||
|
||||
def name(self):
|
||||
return "ConsoleButtonsWidget"
|
||||
|
||||
def toolTip(self):
|
||||
return ""
|
||||
|
||||
def whatsThis(self):
|
||||
return self.toolTip()
|
||||
@@ -0,0 +1,15 @@
|
||||
def main(): # pragma: no cover
|
||||
from qtpy import PYSIDE6
|
||||
|
||||
if not PYSIDE6:
|
||||
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
|
||||
return
|
||||
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
|
||||
|
||||
from csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons_widget_plugin import ConsoleButtonsWidgetPlugin
|
||||
|
||||
QPyDesignerCustomWidgetCollection.addCustomWidget(ConsoleButtonsWidgetPlugin())
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -5,6 +5,10 @@ from __future__ import annotations
|
||||
# pylint: skip-file
|
||||
|
||||
designer_plugins = {
|
||||
"ConsoleButtonsWidget": (
|
||||
"csaxs_bec.bec_widgets.widgets.console_buttons.console_buttons",
|
||||
"ConsoleButtonsWidget",
|
||||
),
|
||||
"SampleStorageWidget": (
|
||||
"csaxs_bec.bec_widgets.widgets.sample_storage.sample_storage",
|
||||
"SampleStorageWidget",
|
||||
@@ -22,6 +26,7 @@ designer_plugins = {
|
||||
}
|
||||
|
||||
widget_icons = {
|
||||
"ConsoleButtonsWidget": "widgets",
|
||||
"SampleStorageWidget": "widgets",
|
||||
"SAXSWidget": "table_chart",
|
||||
"SlitControlWidget": "widgets",
|
||||
|
||||
@@ -339,6 +339,9 @@ class SampleStorageWidget(BECWidget, QWidget):
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
for idx, slot in enumerate(STORAGE_SLOTS):
|
||||
r, c = divmod(idx, STORAGE_COLS)
|
||||
# display each row right-to-left (5..1, 10..6, ...) rather than
|
||||
# left-to-right, to match the physical magazine orientation
|
||||
c = STORAGE_COLS - 1 - c
|
||||
cell = _SlotCell(slot, self, COLOR_SLOT_BORDER)
|
||||
self._cells[slot] = cell
|
||||
grid.addWidget(cell, r, c)
|
||||
|
||||
@@ -260,6 +260,13 @@ class XRayEye(BECWidget, QWidget):
|
||||
ROI_LINE_COLOR = "blue"
|
||||
ROI_LINE_WIDTH = 2
|
||||
|
||||
# Image scale read from the camera's ``pixel_calibration`` user parameter
|
||||
# (mm/pixel, matching XrayEyeAlign.PIXEL_CALIBRATION in x_ray_eye_align.py).
|
||||
# If the camera device or the parameter is missing, fall back to 1 (i.e.
|
||||
# sizes are reported in raw pixels).
|
||||
PIXEL_CALIBRATION_USER_PARAM = "pixel_calibration"
|
||||
PIXEL_CALIBRATION_DEFAULT = 1.0
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._connected_motor = None
|
||||
@@ -398,10 +405,21 @@ class XRayEye(BECWidget, QWidget):
|
||||
form.addWidget(QLabel("Sample", parent=self), 0, 0)
|
||||
form.addWidget(self.sample_name_line_edit, 0, 1)
|
||||
self.message_line_edit = QTextEdit(parent=self)
|
||||
self.message_line_edit.setFixedHeight(60)
|
||||
self.message_line_edit.setFixedHeight(90)
|
||||
self.message_line_edit.setReadOnly(True)
|
||||
form.addWidget(QLabel("Message", parent=self), 1, 0)
|
||||
form.addWidget(self.message_line_edit, 1, 1)
|
||||
# Live ROI size readout (microns), updated as the active ROI is resized.
|
||||
self.roi_size_line_edit = QLineEdit(parent=self)
|
||||
self.roi_size_line_edit.setReadOnly(True)
|
||||
form.addWidget(QLabel("ROI size", parent=self), 2, 0)
|
||||
form.addWidget(self.roi_size_line_edit, 2, 1)
|
||||
# Pixel calibration readout (microns/pixel), from the cam_xeye device.
|
||||
self.calibration_line_edit = QLineEdit(parent=self)
|
||||
self.calibration_line_edit.setReadOnly(True)
|
||||
form.addWidget(QLabel("Calibration", parent=self), 3, 0)
|
||||
form.addWidget(self.calibration_line_edit, 3, 1)
|
||||
self._update_calibration_readout()
|
||||
self.control_panel_layout.addLayout(form)
|
||||
|
||||
# Fix panel width and allow vertical expansion
|
||||
@@ -475,6 +493,61 @@ class XRayEye(BECWidget, QWidget):
|
||||
def _style_new_roi(self, roi):
|
||||
"""Force a thinner outline on newly drawn ROIs (color is set via compact_color)."""
|
||||
roi.line_width = self.ROI_LINE_WIDTH
|
||||
# Live-update the microns readout while this ROI is drawn/resized.
|
||||
roi.sigRegionChanged.connect(lambda r=roi: self._update_roi_size_readout(r))
|
||||
self._update_roi_size_readout(roi)
|
||||
|
||||
def _microns_per_pixel(self):
|
||||
"""Resolve microns/pixel and unit label from the camera user parameter.
|
||||
|
||||
Reads ``cam_xeye``'s ``pixel_calibration`` user parameter (mm/pixel) and
|
||||
converts to microns. If the camera device or the parameter is not
|
||||
available, falls back to ``PIXEL_CALIBRATION_DEFAULT`` and reports the
|
||||
unit as raw pixels.
|
||||
|
||||
Returns:
|
||||
tuple[float, str]: (scale factor applied to pixel sizes, unit label).
|
||||
"""
|
||||
try:
|
||||
cam = getattr(self.dev, CAMERA[0])
|
||||
mm_per_pixel = cam.user_parameter.get(self.PIXEL_CALIBRATION_USER_PARAM)
|
||||
except Exception:
|
||||
mm_per_pixel = None
|
||||
if mm_per_pixel is None:
|
||||
return self.PIXEL_CALIBRATION_DEFAULT, "px"
|
||||
return float(mm_per_pixel) * 1000, "um"
|
||||
|
||||
def _update_calibration_readout(self):
|
||||
"""Update the pixel-calibration readout from the camera user parameter."""
|
||||
scale, unit = self._microns_per_pixel()
|
||||
if unit == "um":
|
||||
self.calibration_line_edit.setText(f"{scale:.4f} um/pixel")
|
||||
else:
|
||||
self.calibration_line_edit.setText("uncalibrated (pixels)")
|
||||
|
||||
def _update_roi_size_readout(self, roi=None):
|
||||
"""Update the ROI size readout (microns, or pixels if uncalibrated)."""
|
||||
self._update_calibration_readout()
|
||||
if roi is None:
|
||||
roi = self.roi_manager.single_active_roi
|
||||
if roi is None:
|
||||
self.roi_size_line_edit.setText("")
|
||||
return
|
||||
if isinstance(roi, RectangularROI):
|
||||
coords = roi.get_coordinates(typed=True)
|
||||
size_x = coords["width"]
|
||||
size_y = coords["height"]
|
||||
elif isinstance(roi, CircularROI):
|
||||
# Read the raw x/y size so a non-circular (ellipse) state is reflected
|
||||
# correctly; for an aspect-locked circle both values are equal.
|
||||
size_x, size_y = roi.state["size"]
|
||||
else:
|
||||
self.roi_size_line_edit.setText("")
|
||||
return
|
||||
scale, unit = self._microns_per_pixel()
|
||||
self.roi_size_line_edit.setText(
|
||||
f"x = {abs(size_x) * scale:.1f} {unit}, y = {abs(size_y) * scale:.1f} {unit}"
|
||||
)
|
||||
|
||||
def _create_separator(self):
|
||||
sep = QFrame(parent=self)
|
||||
|
||||
@@ -52,6 +52,22 @@ fsh:
|
||||
enabled: true
|
||||
readoutPriority: monitored
|
||||
|
||||
##########################################################################
|
||||
########################### FAST SHUTTER #################################
|
||||
##########################################################################
|
||||
|
||||
shutter_es:
|
||||
description: X12SA ES shutter status
|
||||
deviceClass: ophyd_devices.EpicsSignalRO
|
||||
deviceConfig:
|
||||
read_pv: X12SA-OP-PSH1-EMLS-7010:OPEN
|
||||
auto_monitor: true
|
||||
onFailure: buffer
|
||||
enabled: true
|
||||
readoutPriority: baseline
|
||||
readOnly: true
|
||||
softwareTrigger: false
|
||||
|
||||
##########################################################################
|
||||
######################## SMARACT STAGES ##################################
|
||||
##########################################################################
|
||||
|
||||
@@ -77,10 +77,11 @@ foptx:
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
#170 micron, 60 nm
|
||||
#in: -13.831
|
||||
in: -13.831
|
||||
out: -13.831
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: -13.8809375
|
||||
out: -14.1809
|
||||
# in: -13.8809375
|
||||
# out: -14.1809
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
# in: -14.5490625
|
||||
# out: -14.1809
|
||||
@@ -103,11 +104,11 @@ fopty:
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
#170 micron, 60 nm
|
||||
#in: 0.42
|
||||
#out: 0.57
|
||||
in: 0.42
|
||||
out: 0.57
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: 2.8299
|
||||
out: 2.8299
|
||||
# in: 2.8299
|
||||
# out: 2.8299
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
# in: 2.8419921875
|
||||
# out: 2.8419921875
|
||||
@@ -321,11 +322,11 @@ fosax:
|
||||
#in: 8.7568
|
||||
#out: 5.1
|
||||
#170 micron, 60 nm, 7.9 kev
|
||||
# in: 8.731922
|
||||
# out: 5.1
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: 8.7392
|
||||
in: 8.727079
|
||||
out: 5.1
|
||||
#250 micron, 30 nm, Abe structures
|
||||
# in: 8.7392
|
||||
# out: 5.1
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
# in: 9.420798
|
||||
# out: 5.1
|
||||
@@ -349,10 +350,10 @@ fosay:
|
||||
userParameter:
|
||||
#170 micron, 60 nm, 7.6 kev
|
||||
#in: -0.0235
|
||||
#170 micron, 60 nm, 7.6 kev
|
||||
#in: -0.0422
|
||||
#170 micron, 60 nm, 7.9 kev
|
||||
in: -0.04603
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: -2.3684
|
||||
# in: -2.3684
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
# in: -2.383993
|
||||
fosaz:
|
||||
@@ -375,12 +376,12 @@ fosaz:
|
||||
#170 micron, 60 nm, 7.6 kev
|
||||
#in: 8.5
|
||||
#out: 6
|
||||
#170 micron, 60 nm, 7.9 kev, foptz 15.9
|
||||
# in: 11.9
|
||||
# out: 6
|
||||
#170 micron, 60 nm, 7.9 kev, foptz 16.9, probe size 7.5 mu
|
||||
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
|
||||
# in: 0.5
|
||||
# out: -5
|
||||
|
||||
############################################################
|
||||
#################### flOMNI RT motors ######################
|
||||
@@ -493,7 +494,8 @@ cam_xeye:
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: async
|
||||
|
||||
userParameter:
|
||||
pixel_calibration: 0.00044247787610619477 # mm/pixel (= 0.05 / 113)
|
||||
# cam_ids_rgb:
|
||||
# description: Camera flOMNI Xray eye ID203
|
||||
# deviceClass: csaxs_bec.devices.ids_cameras.ids_camera.IDSCamera
|
||||
|
||||
@@ -24,6 +24,10 @@ You will be asked to enter a sample name.
|
||||
To load a new sample in the sample stage, in principle only one command is needed
|
||||
`flomni.ftransfer_sample_change(<position new sample>)`
|
||||
You will be asked where the previous sample should go with a suggestion for an empty position in the tray.
|
||||
|
||||
To remove the current sample from the sample stage __without__ mounting a new one, use
|
||||
`flomni.ftransfer_sample_change(-1)`
|
||||
You will be asked where to stow the removed sample, same as above.
|
||||
Other commands:
|
||||
`ftransfer_tray_in /_out (not yet implemented).`
|
||||
|
||||
@@ -54,7 +58,7 @@ If you see your sample already at the approximately correct height:
|
||||
|
||||
Otherwise adjust the height manually:
|
||||
|
||||
1. `umvr_fsamy_tracked(0.01)`, attention: unit <mm>, move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen
|
||||
1. `flomni.umvr_fsamy_tracked(0.01)`, attention: unit <mm>, move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen
|
||||
1. `flomni.xrayeye_update_frame()` will update the current image on the xray eye screen
|
||||
|
||||
#### Fine alignment
|
||||
|
||||
Reference in New Issue
Block a user