Feat/update lamni to flomni standards run1 #258
@@ -1,6 +1,11 @@
|
||||
from .alignment import XrayEyeAlign
|
||||
from .lamni_alignment_mixin import LamNIAlignmentMixin
|
||||
from .lamni import LamNI
|
||||
from .lamni_optics_mixin import LamNIInitError, LaMNIInitStages, LamNIOpticsMixin
|
||||
|
||||
__all__ = [
|
||||
"LamNI", "XrayEyeAlign", "LamNIInitError", "LaMNIInitStages", "LamNIOpticsMixin"
|
||||
]
|
||||
"LamNI",
|
||||
"LamNIAlignmentMixin",
|
||||
"LamNIInitError",
|
||||
"LaMNIInitStages",
|
||||
"LamNIOpticsMixin",
|
||||
]
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
import builtins
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
from typeguard import typechecked
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_get, epics_put, fshopen
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
if builtins.__dict__.get("bec") is not None:
|
||||
bec = builtins.__dict__.get("bec")
|
||||
dev = builtins.__dict__.get("dev")
|
||||
umv = builtins.__dict__.get("umv")
|
||||
umvr = builtins.__dict__.get("umvr")
|
||||
|
||||
|
||||
class XrayEyeAlign:
|
||||
# pixel calibration, multiply to get mm
|
||||
# PIXEL_CALIBRATION = 0.2/209 #.2 with binning
|
||||
PIXEL_CALIBRATION = 0.2 / 218 # .2 with binning
|
||||
|
||||
def __init__(self, client, lamni) -> None:
|
||||
self.client = client
|
||||
self.lamni = lamni
|
||||
self.device_manager = client.device_manager
|
||||
self.scans = client.scans
|
||||
self.alignment_values = defaultdict(list)
|
||||
self._reset_init_values()
|
||||
self.corr_pos_x = []
|
||||
self.corr_pos_y = []
|
||||
self.corr_angle = []
|
||||
self.corr_pos_x_2 = []
|
||||
self.corr_pos_y_2 = []
|
||||
self.corr_angle_2 = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Correction reset
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def reset_correction(self):
|
||||
self.corr_pos_x = []
|
||||
self.corr_pos_y = []
|
||||
self.corr_angle = []
|
||||
|
||||
def reset_correction_2(self):
|
||||
self.corr_pos_x_2 = []
|
||||
self.corr_pos_y_2 = []
|
||||
self.corr_angle_2 = []
|
||||
|
||||
def reset_xray_eye_correction(self):
|
||||
self.client.delete_global_var("tomo_fit_xray_eye")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FOV offset properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def tomo_fovx_offset(self):
|
||||
val = self.client.get_global_var("tomo_fov_offset")
|
||||
if val is None:
|
||||
return 0.0
|
||||
return val[0] / 1000
|
||||
|
||||
@tomo_fovx_offset.setter
|
||||
@typechecked
|
||||
def tomo_fovx_offset(self, val: float):
|
||||
val_old = self.client.get_global_var("tomo_fov_offset")
|
||||
if val_old is None:
|
||||
val_old = [0.0, 0.0]
|
||||
self.client.set_global_var("tomo_fov_offset", [val * 1000, val_old[1]])
|
||||
|
||||
@property
|
||||
def tomo_fovy_offset(self):
|
||||
val = self.client.get_global_var("tomo_fov_offset")
|
||||
if val is None:
|
||||
return 0.0
|
||||
return val[1] / 1000
|
||||
|
||||
@tomo_fovy_offset.setter
|
||||
@typechecked
|
||||
def tomo_fovy_offset(self, val: float):
|
||||
val_old = self.client.get_global_var("tomo_fov_offset")
|
||||
if val_old is None:
|
||||
val_old = [0.0, 0.0]
|
||||
self.client.set_global_var("tomo_fov_offset", [val_old[0], val * 1000])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _reset_init_values(self):
|
||||
self.shift_xy = [0, 0]
|
||||
self._xray_fov_xy = [0, 0]
|
||||
|
||||
def _disable_rt_feedback(self):
|
||||
self.device_manager.devices.rtx.controller.feedback_disable()
|
||||
|
||||
def _enable_rt_feedback(self):
|
||||
self.device_manager.devices.rtx.controller.feedback_enable_with_reset()
|
||||
|
||||
def tomo_rotate(self, val: float):
|
||||
# pylint: disable=undefined-variable
|
||||
umv(self.device_manager.devices.lsamrot, val)
|
||||
|
||||
def get_tomo_angle(self):
|
||||
return self.device_manager.devices.lsamrot.readback.read()["lsamrot"]["value"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# X-ray eye camera control
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def save_frame(self):
|
||||
epics_put("XOMNYI-XEYE-SAVFRAME:0", 1)
|
||||
|
||||
def update_frame(self):
|
||||
epics_put("XOMNYI-XEYE-ACQDONE:0", 0)
|
||||
# start live
|
||||
epics_put("XOMNYI-XEYE-ACQ:0", 1)
|
||||
# wait for start live
|
||||
while epics_get("XOMNYI-XEYE-ACQDONE:0") == 0:
|
||||
time.sleep(0.5)
|
||||
print("waiting for live view to start...")
|
||||
fshopen()
|
||||
|
||||
epics_put("XOMNYI-XEYE-ACQDONE:0", 0)
|
||||
|
||||
while epics_get("XOMNYI-XEYE-ACQDONE:0") == 0:
|
||||
print("waiting for new frame...")
|
||||
time.sleep(0.5)
|
||||
|
||||
time.sleep(0.5)
|
||||
# stop live view
|
||||
epics_put("XOMNYI-XEYE-ACQ:0", 0)
|
||||
time.sleep(1)
|
||||
print("got new frame")
|
||||
|
||||
def update_fov(self, k: int):
|
||||
self._xray_fov_xy[0] = max(epics_get(f"XOMNYI-XEYE-XWIDTH_X:{k}"), self._xray_fov_xy[0])
|
||||
self._xray_fov_xy[1] = max(0, self._xray_fov_xy[0])
|
||||
|
||||
@property
|
||||
def movement_buttons_enabled(self):
|
||||
return [epics_get("XOMNYI-XEYE-ENAMVX:0"), epics_get("XOMNYI-XEYE-ENAMVY:0")]
|
||||
|
||||
@movement_buttons_enabled.setter
|
||||
def movement_buttons_enabled(self, enabled: bool):
|
||||
enabled = int(enabled)
|
||||
epics_put("XOMNYI-XEYE-ENAMVX:0", enabled)
|
||||
epics_put("XOMNYI-XEYE-ENAMVY:0", enabled)
|
||||
|
||||
def send_message(self, msg: str):
|
||||
epics_put("XOMNYI-XEYE-MESSAGE:0.DESC", msg)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Alignment procedure
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def align(self):
|
||||
self._reset_init_values()
|
||||
self.reset_correction()
|
||||
self.reset_correction_2()
|
||||
|
||||
self._disable_rt_feedback()
|
||||
epics_put("XOMNYI-XEYE-PIXELSIZE:0", self.PIXEL_CALIBRATION)
|
||||
self._enable_rt_feedback()
|
||||
|
||||
self.movement_buttons_enabled = False
|
||||
epics_put("XOMNYI-XEYE-ACQ:0", 0)
|
||||
self.send_message("please wait...")
|
||||
epics_put("XOMNYI-XEYE-SAMPLENAME:0.DESC", "Let us LAMNI...")
|
||||
|
||||
self._disable_rt_feedback()
|
||||
k = 0
|
||||
|
||||
self.lamni.lfzp_in()
|
||||
self.update_frame()
|
||||
|
||||
self.movement_buttons_enabled = False
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", 0)
|
||||
epics_put("XOMNYI-XEYE-STEP:0", 0)
|
||||
self.send_message("Submit center value of FZP.")
|
||||
|
||||
while True:
|
||||
if epics_get("XOMNYI-XEYE-SUBMIT:0") == 1:
|
||||
val_x = epics_get(f"XOMNYI-XEYE-XVAL_X:{k}") * self.PIXEL_CALIBRATION # in mm
|
||||
val_y = epics_get(f"XOMNYI-XEYE-YVAL_Y:{k}") * self.PIXEL_CALIBRATION # in mm
|
||||
self.alignment_values[k] = [val_x, val_y]
|
||||
print(
|
||||
f"Clicked position {k}: x {self.alignment_values[k][0]}, y"
|
||||
f" {self.alignment_values[k][1]}"
|
||||
)
|
||||
|
||||
if k == 0: # received center value of FZP
|
||||
self.send_message("please wait ...")
|
||||
self.lamni.loptics_out()
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", -1)
|
||||
self.movement_buttons_enabled = False
|
||||
print("Moving sample in, FZP out")
|
||||
|
||||
self._disable_rt_feedback()
|
||||
time.sleep(0.3)
|
||||
self._enable_rt_feedback()
|
||||
time.sleep(0.3)
|
||||
|
||||
self.update_frame()
|
||||
self.send_message("Go and find the sample")
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", 0)
|
||||
self.movement_buttons_enabled = True
|
||||
|
||||
elif k == 1: # received sample center value at samrot 0
|
||||
msg = (
|
||||
f"Base shift values from movement are x {self.shift_xy[0]}, y"
|
||||
f" {self.shift_xy[1]}"
|
||||
)
|
||||
print(msg)
|
||||
logger.info(msg)
|
||||
self.shift_xy[0] += (
|
||||
self.alignment_values[0][0] - self.alignment_values[1][0]
|
||||
) * 1000
|
||||
self.shift_xy[1] += (
|
||||
self.alignment_values[1][1] - self.alignment_values[0][1]
|
||||
) * 1000
|
||||
print(
|
||||
"Base shift values from movement and clicked position are x"
|
||||
f" {self.shift_xy[0]}, y {self.shift_xy[1]}"
|
||||
)
|
||||
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
|
||||
).wait()
|
||||
|
||||
self.send_message("please wait ...")
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", -1)
|
||||
self.movement_buttons_enabled = False
|
||||
time.sleep(1)
|
||||
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
|
||||
).wait()
|
||||
|
||||
epics_put("XOMNYI-XEYE-ANGLE:0", self.get_tomo_angle())
|
||||
self.update_frame()
|
||||
self.send_message("Submit sample center and FOV (0 deg)")
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", 0)
|
||||
self.update_fov(k)
|
||||
|
||||
elif 1 < k < 10: # received sample center value at samrot 0 ... 315
|
||||
self.send_message("please wait ...")
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", -1)
|
||||
|
||||
self._disable_rt_feedback()
|
||||
self.tomo_rotate((k - 1) * 45 - 45 / 2)
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
|
||||
).wait()
|
||||
self._disable_rt_feedback()
|
||||
self.tomo_rotate((k - 1) * 45)
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
|
||||
).wait()
|
||||
|
||||
epics_put("XOMNYI-XEYE-ANGLE:0", self.get_tomo_angle())
|
||||
self.update_frame()
|
||||
self.send_message("Submit sample center")
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", 0)
|
||||
epics_put("XOMNYI-XEYE-ENAMVX:0", 1)
|
||||
self.update_fov(k)
|
||||
|
||||
elif k == 10: # received sample center value at samrot 270, done
|
||||
self.send_message("done...")
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", -1)
|
||||
self.movement_buttons_enabled = False
|
||||
self.update_fov(k)
|
||||
break
|
||||
|
||||
k += 1
|
||||
epics_put("XOMNYI-XEYE-STEP:0", k)
|
||||
|
||||
if k < 2:
|
||||
_xrayeyalignmvx = epics_get("XOMNYI-XEYE-MVX:0")
|
||||
_xrayeyalignmvy = epics_get("XOMNYI-XEYE-MVY:0")
|
||||
if _xrayeyalignmvx != 0 or _xrayeyalignmvy != 0:
|
||||
self.shift_xy[0] = self.shift_xy[0] + _xrayeyalignmvx
|
||||
self.shift_xy[1] = self.shift_xy[1] + _xrayeyalignmvy
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
self.shift_xy[0] / 1000, self.shift_xy[1] / 1000, self.get_tomo_angle()
|
||||
).wait()
|
||||
print(
|
||||
f"Current center horizontal {self.shift_xy[0]} vertical {self.shift_xy[1]}"
|
||||
)
|
||||
epics_put("XOMNYI-XEYE-MVY:0", 0)
|
||||
epics_put("XOMNYI-XEYE-MVX:0", 0)
|
||||
self.update_frame()
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
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
|
||||
print(
|
||||
f"The largest field of view from the xrayeyealign was \nfovx = {fovx:.0f} microns,"
|
||||
f" fovy = {fovy:.0f} microns"
|
||||
)
|
||||
print("Use matlab routine to fit the current alignment...")
|
||||
print(
|
||||
"This additional shift is applied to the base shift values\n which are x"
|
||||
f" {self.shift_xy[0]}, y {self.shift_xy[1]}"
|
||||
)
|
||||
|
||||
self._disable_rt_feedback()
|
||||
self.tomo_rotate(0)
|
||||
|
||||
print(
|
||||
"\n\nNEXT LOAD ALIGNMENT PARAMETERS\nby running"
|
||||
" lamni.align.read_xray_eye_correction()\n"
|
||||
)
|
||||
|
||||
self.client.set_global_var("tomo_fov_offset", self.shift_xy)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Alignment output
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def write_output(self):
|
||||
import os
|
||||
with open(
|
||||
os.path.expanduser("~/Data10/specES1/internal/xrayeye_alignmentvalues"), "w"
|
||||
) as alignment_values_file:
|
||||
alignment_values_file.write("angle\thorizontal\tvertical\n")
|
||||
for k in range(2, 11):
|
||||
fovx_offset = (self.alignment_values[0][0] - self.alignment_values[k][0]) * 1000
|
||||
fovy_offset = (self.alignment_values[k][1] - self.alignment_values[0][1]) * 1000
|
||||
print(
|
||||
f"Writing to file new alignment: number {k}, value x {fovx_offset}, y"
|
||||
f" {fovy_offset}"
|
||||
)
|
||||
alignment_values_file.write(f"{(k-2)*45}\t{fovx_offset}\t{fovy_offset}\n")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# X-ray eye sinusoidal correction (loaded from MATLAB fit files)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def read_xray_eye_correction(self, dir_path=None):
|
||||
import os
|
||||
if dir_path is None:
|
||||
dir_path = os.path.expanduser("~/Data10/specES1/internal/")
|
||||
tomo_fit_xray_eye = np.zeros((2, 3))
|
||||
for i, axis in enumerate(["x", "y"]):
|
||||
for j, coeff in enumerate(["A", "B", "C"]):
|
||||
with open(os.path.join(dir_path, f"ptychotomoalign_{coeff}{axis}.txt"), "r") as f:
|
||||
tomo_fit_xray_eye[i][j] = f.readline()
|
||||
|
||||
self.client.set_global_var("tomo_fit_xray_eye", tomo_fit_xray_eye.tolist())
|
||||
# x amp, phase, offset, y amp, phase, offset
|
||||
# 0 0 0 1 0 2 1 0 1 1 1 2
|
||||
print("New alignment parameters loaded from X-ray eye")
|
||||
print(
|
||||
f"X Amplitude {tomo_fit_xray_eye[0][0]}, "
|
||||
f"X Phase {tomo_fit_xray_eye[0][1]}, "
|
||||
f"X Offset {tomo_fit_xray_eye[0][2]}, "
|
||||
f"Y Amplitude {tomo_fit_xray_eye[1][0]}, "
|
||||
f"Y Phase {tomo_fit_xray_eye[1][1]}, "
|
||||
f"Y Offset {tomo_fit_xray_eye[1][2]}"
|
||||
)
|
||||
|
||||
def lamni_compute_additional_correction_xeye_mu(self, angle):
|
||||
"""Compute sinusoidal correction from the X-ray eye fit for the given angle."""
|
||||
tomo_fit_xray_eye = self.client.get_global_var("tomo_fit_xray_eye")
|
||||
if tomo_fit_xray_eye is None:
|
||||
print("Not applying any additional correction. No x-ray eye data available.\n")
|
||||
return (0, 0)
|
||||
|
||||
# x amp, phase, offset, y amp, phase, offset
|
||||
# 0 0 0 1 0 2 1 0 1 1 1 2
|
||||
correction_x = (
|
||||
tomo_fit_xray_eye[0][0] * np.sin(np.radians(angle) + tomo_fit_xray_eye[0][1])
|
||||
+ tomo_fit_xray_eye[0][2]
|
||||
) / 1000
|
||||
correction_y = (
|
||||
tomo_fit_xray_eye[1][0] * np.sin(np.radians(angle) + tomo_fit_xray_eye[1][1])
|
||||
+ tomo_fit_xray_eye[1][2]
|
||||
) / 1000
|
||||
|
||||
print(f"Xeye correction x {correction_x}, y {correction_y} for angle {angle}\n")
|
||||
return (correction_x, correction_y)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Additional lookup-table corrections (iteration 1 and 2)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def read_additional_correction(self, correction_file: str):
|
||||
self.corr_pos_x, self.corr_pos_y, self.corr_angle = self._read_correction_file_xy(
|
||||
correction_file
|
||||
)
|
||||
|
||||
def read_additional_correction_2(self, correction_file: str):
|
||||
self.corr_pos_x_2, self.corr_pos_y_2, self.corr_angle_2 = self._read_correction_file_xy(
|
||||
correction_file
|
||||
)
|
||||
|
||||
def _read_correction_file_xy(self, correction_file: str):
|
||||
"""Parse a correction file that contains corr_pos_x, corr_pos_y and corr_angle entries."""
|
||||
with open(correction_file, "r") as f:
|
||||
num_elements = f.readline()
|
||||
int_num_elements = int(num_elements.split(" ")[2])
|
||||
print(int_num_elements)
|
||||
corr_pos_x = []
|
||||
corr_pos_y = []
|
||||
corr_angle = []
|
||||
for j in range(0, int_num_elements * 3):
|
||||
line = f.readline()
|
||||
value = line.split(" ")[2]
|
||||
name = line.split(" ")[0].split("[")[0]
|
||||
if name == "corr_pos_x":
|
||||
corr_pos_x.append(float(value) / 1000)
|
||||
elif name == "corr_pos_y":
|
||||
corr_pos_y.append(float(value) / 1000)
|
||||
elif name == "corr_angle":
|
||||
corr_angle.append(float(value))
|
||||
return corr_pos_x, corr_pos_y, corr_angle
|
||||
|
||||
def compute_additional_correction(self, angle):
|
||||
return self._compute_correction_xy(
|
||||
angle, self.corr_pos_x, self.corr_pos_y, self.corr_angle, label="1"
|
||||
)
|
||||
|
||||
def compute_additional_correction_2(self, angle):
|
||||
return self._compute_correction_xy(
|
||||
angle, self.corr_pos_x_2, self.corr_pos_y_2, self.corr_angle_2, label="2"
|
||||
)
|
||||
|
||||
def _compute_correction_xy(self, angle, corr_pos_x, corr_pos_y, corr_angle, label=""):
|
||||
"""Find the correction for the closest angle in the lookup table."""
|
||||
if not corr_pos_x:
|
||||
print(f"Not applying additional correction {label}. No data available.\n")
|
||||
return (0, 0)
|
||||
|
||||
shift_x = corr_pos_x[0]
|
||||
shift_y = corr_pos_y[0]
|
||||
angledelta = np.fabs(corr_angle[0] - angle)
|
||||
|
||||
for j in range(1, len(corr_pos_x)):
|
||||
newangledelta = np.fabs(corr_angle[j] - angle)
|
||||
if newangledelta < angledelta:
|
||||
shift_x = corr_pos_x[j]
|
||||
shift_y = corr_pos_y[j]
|
||||
angledelta = newangledelta
|
||||
|
||||
if shift_x == 0 and angle < corr_angle[0]:
|
||||
shift_x = corr_pos_x[0]
|
||||
shift_y = corr_pos_y[0]
|
||||
|
||||
if shift_x == 0 and angle > corr_angle[-1]:
|
||||
shift_x = corr_pos_x[-1]
|
||||
shift_y = corr_pos_y[-1]
|
||||
|
||||
print(f"Additional correction shifts {label}: {shift_x} {shift_y}")
|
||||
return (shift_x, shift_y)
|
||||
@@ -47,7 +47,7 @@ class MagLamNI(LamNI):
|
||||
for target_angle in np.linspace(current_angle, angle, steps, endpoint=True):
|
||||
umv(dev.lsamrot, target_angle)
|
||||
scans.lamni_move_to_scan_center(
|
||||
self.align.tomo_fovx_offset, self.align.tomo_fovy_offset, target_angle
|
||||
self.tomo_fovx_offset, self.tomo_fovy_offset, target_angle
|
||||
)
|
||||
|
||||
def _at_each_angle(self, angle: float) -> None:
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
|
||||
from bec_widgets.cli.client import BECDockArea
|
||||
|
||||
# from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_get, epics_put, fshopen, fshclose
|
||||
import datetime
|
||||
import time
|
||||
|
||||
if builtins.__dict__.get("bec") is not None:
|
||||
bec = builtins.__dict__.get("bec")
|
||||
dev = builtins.__dict__.get("dev")
|
||||
scans = builtins.__dict__.get("scans")
|
||||
|
||||
|
||||
def umv(*args):
|
||||
return scans.umv(*args, relative=False)
|
||||
|
||||
@@ -18,53 +19,102 @@ class LamniGuiToolsError(Exception):
|
||||
|
||||
|
||||
class LamniGuiTools:
|
||||
GUI_RPC_TIMEOUT = 20
|
||||
|
||||
def __init__(self):
|
||||
self.lamni_window = None
|
||||
self.text_box = None
|
||||
self.progressbar = None
|
||||
self.xeyegui = None
|
||||
self.pdf_viewer = None
|
||||
self.idle_text_box = None
|
||||
|
||||
def set_client(self, client):
|
||||
self.client = client
|
||||
self.gui = self.client.gui
|
||||
self.gui = getattr(client, "gui", None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Window management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def lamnigui_show_gui(self):
|
||||
if "lamni" in self.gui.windows:
|
||||
self.gui.lamni.show()
|
||||
self.lamni_window = self.gui.windows["lamni"]
|
||||
self.gui.lamni.raise_window()
|
||||
else:
|
||||
self.gui.new("lamni")
|
||||
|
||||
def lamnigui_stop_gui(self):
|
||||
self.gui.lamni.hide()
|
||||
self.lamni_window = self.gui.new("lamni", timeout=self.GUI_RPC_TIMEOUT)
|
||||
time.sleep(1)
|
||||
|
||||
def lamnigui_raise(self):
|
||||
self.gui.lamni.raise_window()
|
||||
|
||||
def lamnigui_show_xeyealign(self):
|
||||
self.lamnigui_show_gui()
|
||||
if self._lamnigui_check_attribute_not_exists("xeyegui"):
|
||||
self.lamnigui_remove_all_docks()
|
||||
self.xeyegui = self.gui.lamni.new("xeyegui").new("XRayEye")
|
||||
# start live
|
||||
if not dev.cam_xeye.live_mode:
|
||||
dev.cam_xeye.live_mode = True
|
||||
def lamnigui_stop_gui(self):
|
||||
self.gui.lamni.hide()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Widget existence checks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _lamnigui_check_attribute_not_exists(self, attribute_name):
|
||||
if hasattr(self.gui,"lamni"):
|
||||
if hasattr(self.gui.lamni,attribute_name):
|
||||
return False
|
||||
return True
|
||||
def _lamnigui_is_missing(self, attribute_name: str) -> bool:
|
||||
"""Check whether a stored widget reference is absent or has been deleted."""
|
||||
widget = getattr(self, attribute_name, None)
|
||||
if widget is None:
|
||||
return True
|
||||
if hasattr(widget, "_is_deleted") and widget._is_deleted():
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dock management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def lamnigui_remove_all_docks(self):
|
||||
self.gui.lamni.delete_all()
|
||||
if hasattr(self.gui, "lamni"):
|
||||
self.gui.lamni.delete_all(timeout=self.GUI_RPC_TIMEOUT)
|
||||
self.progressbar = None
|
||||
self.text_box = None
|
||||
self.xeyegui = None
|
||||
self.pdf_viewer = None
|
||||
self.idle_text_box = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# X-ray eye alignment views
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def lamnigui_show_xeyealign(self):
|
||||
"""Open (or raise) the X-ray eye widget on the Alignment tab."""
|
||||
self.lamnigui_show_gui()
|
||||
if self._lamnigui_is_missing("xeyegui"):
|
||||
self.lamnigui_remove_all_docks()
|
||||
self.xeyegui = self.gui.lamni.new(
|
||||
"XRayEye", object_name="xrayeye", timeout=self.GUI_RPC_TIMEOUT
|
||||
)
|
||||
# start live
|
||||
if not dev.cam_xeye.live_mode_enabled.get():
|
||||
dev.cam_xeye.live_mode_enabled.put(True)
|
||||
self.xeyegui.switch_tab("alignment")
|
||||
|
||||
def lamnigui_show_xeyealign_fittab(self):
|
||||
"""Open (or raise) the X-ray eye widget on the Fit tab."""
|
||||
self.lamnigui_show_gui()
|
||||
if self._lamnigui_is_missing("xeyegui"):
|
||||
self.lamnigui_remove_all_docks()
|
||||
self.xeyegui = self.gui.lamni.new(
|
||||
"XRayEye", object_name="xrayeye", timeout=self.GUI_RPC_TIMEOUT
|
||||
)
|
||||
self.xeyegui.switch_tab("fit")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Idle splash
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def lamnigui_idle(self):
|
||||
self.lamnigui_show_gui()
|
||||
if self._lamnigui_check_attribute_not_exists("idle_text_box"):
|
||||
if self._lamnigui_is_missing("idle_text_box"):
|
||||
self.lamnigui_remove_all_docks()
|
||||
idle_text_box = self.gui.lamni.new("idle_textbox").new("TextBox")
|
||||
self.idle_text_box = self.gui.lamni.new(
|
||||
"TextBox", object_name="idle_textbox", timeout=self.GUI_RPC_TIMEOUT
|
||||
)
|
||||
text = (
|
||||
"<pre>"
|
||||
+ "██████╗ ███████╗ ██████╗ ██╗ █████╗ ███╗ ███╗███╗ ██╗██╗\n"
|
||||
@@ -75,18 +125,24 @@ class LamniGuiTools:
|
||||
+ "╚═════╝ ╚══════╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝\n"
|
||||
+ "</pre>"
|
||||
)
|
||||
idle_text_box.set_html_text(text)
|
||||
self.idle_text_box.set_html_text(text)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Documentation viewer
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def lamnigui_docs(self, filename: str | None = None):
|
||||
import csaxs_bec
|
||||
from pathlib import Path
|
||||
|
||||
print("The general lamni documentation is at \nhttps://sls-csaxs.readthedocs.io/en/latest/user/ptychography/lamni.html#user-ptychography-lamni")
|
||||
print(
|
||||
"The general LamNI documentation is at \n"
|
||||
"https://sls-csaxs.readthedocs.io/en/latest/user/ptychography/lamni.html"
|
||||
)
|
||||
|
||||
csaxs_bec_basepath = Path(csaxs_bec.__file__).parent
|
||||
docs_folder = (
|
||||
csaxs_bec_basepath /
|
||||
"bec_ipython_client" / "plugins" / "lamni" / "docs"
|
||||
csaxs_bec_basepath / "bec_ipython_client" / "plugins" / "LamNI" / "docs"
|
||||
)
|
||||
|
||||
if not docs_folder.is_dir():
|
||||
@@ -96,20 +152,19 @@ class LamniGuiTools:
|
||||
if not pdfs:
|
||||
raise FileNotFoundError(f"No PDF files found in {docs_folder}")
|
||||
|
||||
# --- Resolve PDF ------------------------------------------------------
|
||||
if filename is not None:
|
||||
pdf_file = docs_folder / filename
|
||||
if not pdf_file.exists():
|
||||
raise FileNotFoundError(f"Requested file not found: {filename}")
|
||||
else:
|
||||
print("\nAvailable lamni documentation PDFs:\n")
|
||||
print("\nAvailable LamNI documentation PDFs:\n")
|
||||
for i, pdf in enumerate(pdfs, start=1):
|
||||
print(f" {i:2d}) {pdf.name}")
|
||||
print()
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = int(input(f"Select a file (1–{len(pdfs)}): "))
|
||||
choice = int(input(f"Select a file (1-{len(pdfs)}): "))
|
||||
if 1 <= choice <= len(pdfs):
|
||||
pdf_file = pdfs[choice - 1]
|
||||
break
|
||||
@@ -117,62 +172,97 @@ class LamniGuiTools:
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number.")
|
||||
|
||||
# --- GUI handling (active existence check) ----------------------------
|
||||
self.lamnigui_show_gui()
|
||||
|
||||
if self._lamnigui_check_attribute_not_exists("PdfViewerWidget"):
|
||||
if self._lamnigui_is_missing("pdf_viewer"):
|
||||
self.lamnigui_remove_all_docks()
|
||||
self.pdf_viewer = self.gui.lamni.new(widget="PdfViewerWidget")
|
||||
self.pdf_viewer = self.gui.lamni.new(
|
||||
widget="PdfViewerWidget", timeout=self.GUI_RPC_TIMEOUT
|
||||
)
|
||||
|
||||
# --- Load PDF ---------------------------------------------------------
|
||||
self.pdf_viewer.PdfViewerWidget.load_pdf(str(pdf_file.resolve()))
|
||||
self.pdf_viewer.load_pdf(str(pdf_file.resolve()))
|
||||
print(f"\nLoaded: {pdf_file.name}\n")
|
||||
|
||||
|
||||
def _lamnicam_check_device_exists(self, device):
|
||||
try:
|
||||
device
|
||||
except:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
# ------------------------------------------------------------------
|
||||
# Progress bar
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def lamnigui_show_progress(self):
|
||||
self.lamnigui_show_gui()
|
||||
if self._lamnigui_check_attribute_not_exists("progressbar"):
|
||||
if self._lamnigui_is_missing("progressbar"):
|
||||
self.lamnigui_remove_all_docks()
|
||||
# Add a new dock with a RingProgressBar widget
|
||||
self.progressbar = self.gui.lamni.new("progressbar").new("RingProgressBar")
|
||||
# Customize the size of the progress ring
|
||||
self.progressbar.set_line_widths(20)
|
||||
# Disable automatic updates and manually set the self.progressbar value
|
||||
self.progressbar.enable_auto_updates(False)
|
||||
# Set precision for the self.progressbar display
|
||||
self.progressbar.set_precision(1) # Display self.progressbar with one decimal places
|
||||
# Setting multiple rigns with different values
|
||||
self.progressbar.set_number_of_bars(3)
|
||||
self.progressbar.rings[0].set_update("manual")
|
||||
self.progressbar.rings[1].set_update("manual")
|
||||
self.progressbar.rings[2].set_update("scan")
|
||||
# Set the values of the rings to 50, 75, and 25 from outer to inner ring
|
||||
# self.progressbar.set_value([50, 75])
|
||||
# Add a new dock with a TextBox widget
|
||||
self.text_box = self.gui.lamni.new(name="progress_text").new("TextBox")
|
||||
self.progressbar = self.gui.lamni.new(
|
||||
"RingProgressBar", timeout=self.GUI_RPC_TIMEOUT
|
||||
)
|
||||
# Outer ring: overall tomo progress (manual update)
|
||||
self.progressbar.add_ring().set_update("manual")
|
||||
# Middle ring: current sub-tomo progress (manual update)
|
||||
self.progressbar.add_ring().set_update("manual")
|
||||
# Inner ring: current scan progress (driven by BEC scan events)
|
||||
self.progressbar.add_ring().set_update("scan")
|
||||
|
||||
self._lamnigui_update_progress()
|
||||
|
||||
def _lamnigui_update_progress(self):
|
||||
if self.progressbar is not None:
|
||||
progress = self.progress["projection"] / self.progress["total_projections"] * 100
|
||||
subtomo_progress = (
|
||||
self.progress["subtomo_projection"]
|
||||
/ self.progress["subtomo_total_projections"]
|
||||
* 100
|
||||
"""Update the progress ring bar and centre label from the current progress state.
|
||||
|
||||
``self.progress`` is backed by the BEC global variable ``tomo_progress``
|
||||
so this reflects the live state accessible from any BEC client session via::
|
||||
|
||||
client.get_global_var("tomo_progress")
|
||||
"""
|
||||
if self.progressbar is None:
|
||||
return
|
||||
|
||||
main_progress_ring = self.progressbar.rings[0]
|
||||
subtomo_progress_ring = self.progressbar.rings[1]
|
||||
|
||||
progress = self.progress["projection"] / self.progress["total_projections"] * 100
|
||||
subtomo_progress = (
|
||||
self.progress["subtomo_projection"]
|
||||
/ self.progress["subtomo_total_projections"]
|
||||
* 100
|
||||
)
|
||||
main_progress_ring.set_value(progress)
|
||||
subtomo_progress_ring.set_value(subtomo_progress)
|
||||
|
||||
# Format start time
|
||||
start_str = self.progress.get("tomo_start_time")
|
||||
if start_str is not None:
|
||||
start_display = datetime.datetime.fromisoformat(start_str).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
self.progressbar.set_value([progress, subtomo_progress, 0])
|
||||
if self.text_box is not None:
|
||||
text = f"Progress report:\n Tomo type: ....................... {self.progress['tomo_type']}\n Projection: ...................... {self.progress['projection']:.0f}\n Total projections expected ....... {self.progress['total_projections']}\n Angle: ........................... {self.progress['angle']}\n Current subtomo: ................. {self.progress['subtomo']}\n Current projection within subtomo: {self.progress['subtomo_projection']}\n Total projections per subtomo: ... {self.progress['subtomo_total_projections']}"
|
||||
self.text_box.set_plain_text(text)
|
||||
else:
|
||||
start_display = "N/A"
|
||||
|
||||
# Format estimated remaining time
|
||||
remaining_s = self.progress.get("estimated_remaining_time")
|
||||
if remaining_s is not None and remaining_s >= 0:
|
||||
remaining_s = int(remaining_s)
|
||||
h, rem = divmod(remaining_s, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
if h > 0:
|
||||
eta_display = f"{h}h {m:02d}m {s:02d}s"
|
||||
elif m > 0:
|
||||
eta_display = f"{m}m {s:02d}s"
|
||||
else:
|
||||
eta_display = f"{s}s"
|
||||
else:
|
||||
eta_display = "N/A"
|
||||
|
||||
text = (
|
||||
f"Progress report:\n"
|
||||
f" Tomo type: {self.progress['tomo_type']}\n"
|
||||
f" Projection: {self.progress['projection']:.0f}\n"
|
||||
f" Total projections expected: {self.progress['total_projections']:.1f}\n"
|
||||
f" Angle: {self.progress['angle']:.1f}\n"
|
||||
f" Current subtomo: {self.progress['subtomo']}\n"
|
||||
f" Current projection within subtomo: {self.progress['subtomo_projection']}\n"
|
||||
f" Total projections per subtomo: {int(self.progress['subtomo_total_projections'])}\n"
|
||||
f" Scan started: {start_display}\n"
|
||||
f" Est. remaining: {eta_display}"
|
||||
)
|
||||
self.progressbar.set_center_label(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -183,6 +273,7 @@ if __name__ == "__main__":
|
||||
client.start()
|
||||
client.gui = BECGuiClient()
|
||||
|
||||
lamni_gui = LamniGuiTools(client)
|
||||
lamni_gui = LamniGuiTools()
|
||||
lamni_gui.set_client(client)
|
||||
lamni_gui.lamnigui_show_gui()
|
||||
lamni_gui.lamnigui_show_progress()
|
||||
lamni_gui.lamnigui_show_progress()
|
||||
@@ -17,8 +17,9 @@ from csaxs_bec.bec_ipython_client.plugins.omny.omny_general_tools import (
|
||||
TomoIDManager,
|
||||
)
|
||||
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 .alignment import XrayEyeAlign
|
||||
from .x_ray_eye_align import XrayEyeAlign as XrayEyeAlignGUI
|
||||
from .lamni_optics_mixin import LaMNIInitStages, LamNIOpticsMixin
|
||||
|
||||
logger = bec_logger.logger
|
||||
@@ -31,14 +32,94 @@ if builtins.__dict__.get("bec") is not None:
|
||||
umvr = builtins.__dict__.get("umvr")
|
||||
|
||||
|
||||
class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
class _ProgressProxy:
|
||||
"""Dict-like proxy that persists the LamNI progress dict as a BEC global variable.
|
||||
|
||||
Every read (`proxy["key"]`) fetches the current dict from the global var store,
|
||||
and every write (`proxy["key"] = val`) fetches, updates, and saves it back.
|
||||
This makes the progress state visible to all BEC client sessions via
|
||||
``client.get_global_var("tomo_progress")`` -- same key and shape as the
|
||||
FlOMNI side, since only one tomography setup is ever live against a given
|
||||
BEC session.
|
||||
"""
|
||||
|
||||
_GLOBAL_VAR_KEY = "tomo_progress"
|
||||
_DEFAULTS: dict = {
|
||||
"tomo_type": "Equally spaced sub-tomograms",
|
||||
"subtomo": 0,
|
||||
"subtomo_projection": 0,
|
||||
"subtomo_total_projections": 1,
|
||||
"projection": 0,
|
||||
"total_projections": 1,
|
||||
"angle": 0,
|
||||
"tomo_start_time": None,
|
||||
"estimated_remaining_time": None,
|
||||
"estimated_finish_time": None,
|
||||
"heartbeat": None,
|
||||
"accumulated_idle_time": 0.0,
|
||||
}
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
def _load(self) -> dict:
|
||||
val = self._client.get_global_var(self._GLOBAL_VAR_KEY)
|
||||
if val is None:
|
||||
return dict(self._DEFAULTS)
|
||||
return val
|
||||
|
||||
def _save(self, data: dict) -> None:
|
||||
self._client.set_global_var(self._GLOBAL_VAR_KEY, data)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dict-like interface
|
||||
# ------------------------------------------------------------------
|
||||
def __getitem__(self, key):
|
||||
return self._load()[key]
|
||||
|
||||
def __setitem__(self, key, value) -> None:
|
||||
data = self._load()
|
||||
data[key] = value
|
||||
self._save(data)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self._load()!r})"
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._load().get(key, default)
|
||||
|
||||
def update(self, *args, **kwargs) -> None:
|
||||
"""Update multiple fields in a single round-trip."""
|
||||
data = self._load()
|
||||
data.update(*args, **kwargs)
|
||||
self._save(data)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset all progress fields to their default values."""
|
||||
self._save(dict(self._DEFAULTS))
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
"""Return a plain copy of the current progress state."""
|
||||
return self._load()
|
||||
|
||||
|
||||
class LamNI(LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools):
|
||||
def __init__(self, client):
|
||||
super().__init__()
|
||||
self.client = client
|
||||
self.set_client(client)
|
||||
self.device_manager = client.device_manager
|
||||
self.align = XrayEyeAlign(client, self)
|
||||
self.init = LaMNIInitStages(client)
|
||||
|
||||
# Correction state: corr_pos_x/y/angle (+ _2 iteration) are now
|
||||
# global-var-backed properties defined in LamNIAlignmentMixin, so
|
||||
# they are NOT initialized as plain lists here -- doing so would
|
||||
# shadow the properties with instance attributes of the same name,
|
||||
# silently breaking persistence. See lamni_alignment_mixin.py.
|
||||
|
||||
# Extracted collaborators
|
||||
self.reconstructor = PtychoReconstructor(self.ptycho_reconstruct_foldername)
|
||||
self.tomo_id_manager = TomoIDManager()
|
||||
@@ -50,16 +131,17 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
self.special_angle_tolerance = 20
|
||||
self._current_special_angles = []
|
||||
|
||||
# Progress tracking
|
||||
self.progress = {}
|
||||
self.progress["tomo_type"] = "Equally spaced sub-tomograms"
|
||||
self.progress["subtomo"] = 0
|
||||
self.progress["subtomo_projection"] = 0
|
||||
self.progress["subtomo_total_projections"] = 1
|
||||
self.progress["projection"] = 0
|
||||
self.progress["total_projections"] = 1
|
||||
self.progress["angle"] = 0
|
||||
|
||||
# Progress tracking, persisted via the BEC global variable
|
||||
# "tomo_progress" so it survives a client restart -- deliberately NOT
|
||||
# reset here: this proxy is constructed every time LamNI() is
|
||||
# instantiated (i.e. every new client session), and resetting
|
||||
# unconditionally on every instantiation would wipe tomo_start_time
|
||||
# (and everything else) on exactly the restarts where recovering
|
||||
# scan state matters most. A genuinely new tomo_scan() call resets
|
||||
# the relevant fields itself (see the "new scan" branch in
|
||||
# tomo_scan()); use tomo_progress_reset() to explicitly clear stale
|
||||
# progress without starting a new scan.
|
||||
self._progress_proxy = _ProgressProxy(self.client)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Special angles
|
||||
@@ -82,6 +164,124 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
self.special_angles = []
|
||||
self.special_angle_repeats = 1
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Progress tracking
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def progress(self) -> _ProgressProxy:
|
||||
"""Proxy dict backed by the BEC global variable ``tomo_progress``.
|
||||
|
||||
Readable from any BEC client session via::
|
||||
|
||||
client.get_global_var("tomo_progress")
|
||||
|
||||
Individual fields can be read and written just like a regular dict::
|
||||
|
||||
lamni.progress["projection"] # read
|
||||
lamni.progress["projection"] = 42 # write (persists immediately)
|
||||
|
||||
To update multiple fields atomically use :py:meth:`_ProgressProxy.update`::
|
||||
|
||||
lamni.progress.update(projection=42, angle=90.0)
|
||||
|
||||
To reset all fields to their defaults::
|
||||
|
||||
lamni.progress.reset()
|
||||
"""
|
||||
return self._progress_proxy
|
||||
|
||||
@progress.setter
|
||||
def progress(self, val: dict) -> None:
|
||||
"""Replace the entire progress dict.
|
||||
|
||||
Accepts a plain :class:`dict` and persists it to the global var store.
|
||||
"""
|
||||
if not isinstance(val, dict):
|
||||
raise TypeError(f"progress must be a dict, got {type(val).__name__!r}")
|
||||
self._progress_proxy._save(val)
|
||||
|
||||
def tomo_progress_reset(self) -> None:
|
||||
"""Explicitly clear the persisted tomo progress (start time, ETA,
|
||||
current angle/subtomo/projection, accumulated idle time, ...).
|
||||
|
||||
Not called automatically on LamNI() startup -- that would wipe an
|
||||
in-progress scan's state on every kernel restart. Call this by hand
|
||||
if you want a clean progress display without it being tied to
|
||||
starting a new tomo_scan() (which already resets the relevant
|
||||
fields itself).
|
||||
"""
|
||||
self._progress_proxy.reset()
|
||||
print("Tomo progress reset.")
|
||||
|
||||
@staticmethod
|
||||
def _format_duration(seconds: float) -> str:
|
||||
"""Format a duration in seconds as a human-readable string, e.g. '2h 03m 15s'."""
|
||||
seconds = int(seconds)
|
||||
h, remainder = divmod(seconds, 3600)
|
||||
m, s = divmod(remainder, 60)
|
||||
if h > 0:
|
||||
return f"{h}h {m:02d}m {s:02d}s"
|
||||
if m > 0:
|
||||
return f"{m}m {s:02d}s"
|
||||
return f"{s}s"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# X-ray eye alignment entry points
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def xrayeye_alignment_start(self, keep_shutter_open: bool = False):
|
||||
"""Run the BEC GUI-based X-ray eye alignment procedure.
|
||||
|
||||
Creates a fresh :class:`XrayEyeAlignGUI` instance, which resets the
|
||||
correction state, and calls its ``align()`` method. The GUI window
|
||||
is opened automatically. Interrupt with Ctrl-C to abort.
|
||||
|
||||
Args:
|
||||
keep_shutter_open: If True the shutter is left open between angle
|
||||
steps so the sample remains visible in live view.
|
||||
"""
|
||||
aligner = XrayEyeAlignGUI(self.client, self)
|
||||
try:
|
||||
aligner.align(keep_shutter_open=keep_shutter_open)
|
||||
except KeyboardInterrupt as exc:
|
||||
print("Alignment interrupted by user.")
|
||||
try:
|
||||
aligner.gui.hide_crosshair()
|
||||
except Exception as gui_exc: # pylint: disable=broad-except
|
||||
logger.warning(f"Failed to hide XRayEye alignment crosshair: {gui_exc}")
|
||||
try:
|
||||
aligner.gui.set_dap_params_forwarding(False)
|
||||
except Exception as gui_exc: # pylint: disable=broad-except
|
||||
logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {gui_exc}")
|
||||
raise exc
|
||||
|
||||
# ── Reset manual shifts if needed ─────────────────────────────
|
||||
mx = self.manual_shift_x
|
||||
my = self.manual_shift_y
|
||||
|
||||
if mx != 0.0 or my != 0.0:
|
||||
self.manual_shift_x = 0.0
|
||||
self.manual_shift_y = 0.0
|
||||
|
||||
# Use OMNY-style green status message
|
||||
self.OMNYTools.printgreen(
|
||||
f"Manual shifts were reset to zero after X-ray eye alignment "
|
||||
f"(previous values: x={mx:.3f}, y={my:.3f})."
|
||||
)
|
||||
|
||||
def xrayeye_update_frame(self, keep_shutter_open: bool = False):
|
||||
"""Capture a single fresh X-ray eye frame without running full alignment.
|
||||
|
||||
Useful for visually checking the sample position. Opens the X-ray eye
|
||||
GUI if it is not already visible.
|
||||
|
||||
Args:
|
||||
keep_shutter_open: If True the shutter is left open after the frame.
|
||||
"""
|
||||
aligner = XrayEyeAlignGUI(self.client, self)
|
||||
aligner.update_frame(keep_shutter_open=keep_shutter_open)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RT feedback / interferometer helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -247,6 +447,23 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
def corridor_size(self, val: float):
|
||||
self.client.set_global_var("corridor_size", val)
|
||||
|
||||
@property
|
||||
def frames_per_trigger(self):
|
||||
"""Number of burst frames acquired per point/projection. Used by
|
||||
scans.lamni_fermat_scan (via tomo_scan_projection)."""
|
||||
val = self.client.get_global_var("frames_per_trigger")
|
||||
if val is None:
|
||||
return 1
|
||||
return val
|
||||
|
||||
@frames_per_trigger.setter
|
||||
def frames_per_trigger(self, val: int):
|
||||
if isinstance(val, bool) or not isinstance(val, int):
|
||||
raise ValueError("frames_per_trigger must be a positive integer.")
|
||||
if val <= 0:
|
||||
raise ValueError("frames_per_trigger must be a positive integer.")
|
||||
self.client.set_global_var("frames_per_trigger", val)
|
||||
|
||||
@property
|
||||
def lamni_stitch_x(self):
|
||||
val = self.client.get_global_var("lamni_stitch_x")
|
||||
@@ -338,7 +555,9 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
|
||||
@golden_projections_at_0_deg_for_damage_estimation.setter
|
||||
def golden_projections_at_0_deg_for_damage_estimation(self, val: int):
|
||||
self.client.set_global_var("golden_projections_at_0_deg_for_damage_estimation", val)
|
||||
self.client.set_global_var(
|
||||
"golden_projections_at_0_deg_for_damage_estimation", val
|
||||
)
|
||||
|
||||
@property
|
||||
def sample_name(self):
|
||||
@@ -380,9 +599,7 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
)
|
||||
|
||||
def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None:
|
||||
tomo_scan_numbers_file = os.path.expanduser(
|
||||
"~/Data10/specES1/dat-files/tomography_scannumbers.txt"
|
||||
)
|
||||
tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt")
|
||||
with open(tomo_scan_numbers_file, "a+") as out_file:
|
||||
out_file.write(
|
||||
f"{scan_number} {angle} {dev.lsamrot.read()['lsamrot']['value']:.3f}"
|
||||
@@ -390,7 +607,7 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sample database — delegated to TomoIDManager in omny general tools
|
||||
# Sample database — delegated to TomoIDManager
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_sample_database(
|
||||
@@ -414,9 +631,9 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
def tomo_scan_projection(self, angle: float):
|
||||
scans = builtins.__dict__.get("scans")
|
||||
|
||||
additional_correction = self.align.compute_additional_correction(angle)
|
||||
additional_correction_2 = self.align.compute_additional_correction_2(angle)
|
||||
correction_xeye_mu = self.align.lamni_compute_additional_correction_xeye_mu(angle)
|
||||
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)
|
||||
|
||||
self._current_scan_list = []
|
||||
|
||||
@@ -434,8 +651,8 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
stitch_x=stitch_x,
|
||||
stitch_y=stitch_y,
|
||||
stitch_overlap=self.tomo_stitch_overlap,
|
||||
center_x=self.align.tomo_fovx_offset,
|
||||
center_y=self.align.tomo_fovy_offset,
|
||||
center_x=self.tomo_fovx_offset,
|
||||
center_y=self.tomo_fovy_offset,
|
||||
shift_x=(
|
||||
self.manual_shift_x
|
||||
+ correction_xeye_mu[0]
|
||||
@@ -452,11 +669,12 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
angle=angle,
|
||||
scan_type="fly",
|
||||
exp_time=self.tomo_countingtime,
|
||||
frames_per_trigger=self.frames_per_trigger,
|
||||
optim_trajectory_corridor=corridor_size,
|
||||
)
|
||||
|
||||
def tomo_reconstruct(self, base_path="~/Data10/specES1"):
|
||||
"""Write the tomo reconstruct file for the reconstruction queue."""
|
||||
def tomo_reconstruct(self, base_path="~/data/raw/logs/reconstruction_queue"):
|
||||
"""write the tomo reconstruct file for the reconstruction queue"""
|
||||
bec = builtins.__dict__.get("bec")
|
||||
self.reconstructor.write(
|
||||
scan_list=self._current_scan_list,
|
||||
@@ -473,13 +691,39 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _print_progress(self):
|
||||
# --- compute and store estimated remaining time -----------------------
|
||||
start_str = self.progress.get("tomo_start_time")
|
||||
projection = self.progress["projection"]
|
||||
total = self.progress["total_projections"]
|
||||
if start_str is not None and total > 0 and projection > 9:
|
||||
now = datetime.datetime.now()
|
||||
elapsed = (now - datetime.datetime.fromisoformat(start_str)).total_seconds()
|
||||
# Exclude detected idle time (beamline-down pauses, a crash +
|
||||
# restart gap, ...) so it doesn't make the scan look slower than
|
||||
# it actually is while it's running.
|
||||
elapsed -= self.progress.get("accumulated_idle_time", 0.0)
|
||||
elapsed = max(elapsed, 1.0) # guard against a degenerate/negative denominator
|
||||
rate = projection / elapsed # projections per second
|
||||
remaining_s = (total - projection) / rate
|
||||
self.progress["estimated_remaining_time"] = remaining_s
|
||||
eta_str = self._format_duration(remaining_s)
|
||||
finish_dt = now + datetime.timedelta(seconds=remaining_s)
|
||||
self.progress["estimated_finish_time"] = finish_dt.isoformat()
|
||||
finish_str = finish_dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
else:
|
||||
eta_str = "N/A"
|
||||
finish_str = "N/A"
|
||||
# ----------------------------------------------------------------------
|
||||
print("\x1b[95mProgress report:")
|
||||
print(f"Tomo type: ....................... {self.progress['tomo_type']}")
|
||||
print(f"Projection: ...................... {self.progress['projection']}")
|
||||
print(f"Total projections expected ....... {self.progress['total_projections']}")
|
||||
print(f"Angle: ........................... {self.progress['angle']}")
|
||||
print(f"Current subtomo: ................. {self.progress['subtomo']}")
|
||||
print(f"Current projection within subtomo: {self.progress['subtomo_projection']}\x1b[0m")
|
||||
print(f"Current projection within subtomo: {self.progress['subtomo_projection']}")
|
||||
print(f"Estimated remaining time: ........ {eta_str}")
|
||||
print(f"Estimated finish time: ........... {finish_str}\x1b[0m")
|
||||
self._lamnigui_update_progress()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tomo scan orchestration
|
||||
@@ -521,6 +765,34 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
successful = False
|
||||
error_caught = False
|
||||
if 0 <= angle < 360.05:
|
||||
now = datetime.datetime.now()
|
||||
prev_heartbeat_str = self.progress.get("heartbeat")
|
||||
if prev_heartbeat_str is not None:
|
||||
gap = (now - datetime.datetime.fromisoformat(prev_heartbeat_str)).total_seconds()
|
||||
# Normal cadence between consecutive projections is roughly the
|
||||
# acquisition time (times the number of stitch tiles per
|
||||
# projection) plus motor/readout overhead. A gap well beyond
|
||||
# that means something interrupted the scan in between
|
||||
# (beamline-down interlock pause, a crash + manual restart,
|
||||
# ...) -- attribute the excess to idle time so it doesn't drag
|
||||
# down the apparent scan rate used for the ETA above. The
|
||||
# 5x/60s margins are a heuristic, not a precise timing model --
|
||||
# tune if it over/under-triggers in practice.
|
||||
n_tiles = (2 * self.lamni_stitch_x + 1) * (2 * self.lamni_stitch_y + 1)
|
||||
normal_cadence = max(
|
||||
60.0, 5 * self.tomo_countingtime * self.frames_per_trigger * n_tiles
|
||||
)
|
||||
if gap > normal_cadence:
|
||||
idle = gap - normal_cadence
|
||||
self.progress["accumulated_idle_time"] = (
|
||||
self.progress.get("accumulated_idle_time", 0.0) + idle
|
||||
)
|
||||
print(
|
||||
f"Detected a {self._format_duration(gap)} gap since the last projection"
|
||||
f" -- excluding {self._format_duration(idle)} from the ETA estimate."
|
||||
)
|
||||
self.progress["heartbeat"] = now.isoformat()
|
||||
|
||||
print(f"Starting LamNI scan for angle {angle} in subtomo {subtomo_number}")
|
||||
self._print_progress()
|
||||
while not successful:
|
||||
@@ -550,10 +822,8 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
for scan_nr in range(start_scan_number, end_scan_number):
|
||||
self._write_tomo_scan_number(scan_nr, angle, subtomo_number)
|
||||
|
||||
#todo here bl chk, if ok then successfull true
|
||||
successful = True
|
||||
|
||||
|
||||
def _golden(self, ii, howmany_sorted, maxangle=360, reverse=False):
|
||||
"""Return the ii-th golden ratio angle within sorted bunches and its subtomo number."""
|
||||
golden = []
|
||||
@@ -628,6 +898,14 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
"BEC",
|
||||
)
|
||||
self.write_pdf_report()
|
||||
self.progress["tomo_start_time"] = datetime.datetime.now().isoformat()
|
||||
# reset stale estimates from any previous scan, otherwise the GUI
|
||||
# would keep showing a leftover ETA from before this scan has
|
||||
# accumulated enough projections to compute a fresh one
|
||||
self.progress["estimated_remaining_time"] = None
|
||||
self.progress["estimated_finish_time"] = None
|
||||
self.progress["accumulated_idle_time"] = 0.0
|
||||
self.progress["heartbeat"] = None
|
||||
|
||||
with scans.dataset_id_on_hold:
|
||||
if self.tomo_type == 1:
|
||||
@@ -716,6 +994,14 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
else:
|
||||
raise ValueError(f"Unknown tomo_type: {self.tomo_type}.")
|
||||
|
||||
self.progress["projection"] = self.progress["total_projections"]
|
||||
self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"]
|
||||
self._print_progress()
|
||||
print(
|
||||
"Total measurement time lost to detected gaps:"
|
||||
f" {self._format_duration(self.progress.get('accumulated_idle_time', 0.0))}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Parameter display and interactive update
|
||||
# ------------------------------------------------------------------
|
||||
@@ -733,11 +1019,13 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
print(f"Stitching overlap = {self.tomo_stitch_overlap}")
|
||||
print(f"Circular FOV diam <microns> = {self.tomo_circfov}")
|
||||
print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}")
|
||||
print("FOV offset rotates to find the ROI; manual shift moves the rotation center.")
|
||||
print(f" _tomo_fovx_offset <mm> = {self.align.tomo_fovx_offset}")
|
||||
print(f" _tomo_fovy_offset <mm> = {self.align.tomo_fovy_offset}")
|
||||
print(f" _manual_shift_x <mm> = {self.manual_shift_x}")
|
||||
print(f" _manual_shift_y <mm> = {self.manual_shift_y}")
|
||||
print(f"Frames per trigger (burst) = {self.frames_per_trigger}")
|
||||
print("FOV offset rotates to find the ROI; initial values determined in Xrayeye alignment.")
|
||||
print("manual shift moves the rotation center.")
|
||||
print(f" _tomo_fovx_offset <mm> = {self.tomo_fovx_offset:.4f}")
|
||||
print(f" _tomo_fovy_offset <mm> = {self.tomo_fovy_offset:.4f}")
|
||||
print(f" _manual_shift_x <mm> = {self.manual_shift_x:.4f}")
|
||||
print(f" _manual_shift_y <mm> = {self.manual_shift_y:.4f}")
|
||||
print("")
|
||||
if self.tomo_type == 1:
|
||||
print("\x1b[1mTomo type 1:\x1b[0m 8 equally spaced sub-tomograms (360 deg)")
|
||||
@@ -764,8 +1052,8 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
print("Repeating projections at 0 deg at start of every second subtomogram.")
|
||||
print(f"\nSample name: {self.sample_name}\n")
|
||||
|
||||
user_input = input("Are these parameters correctly set for your scan? ")
|
||||
if user_input == "y":
|
||||
|
||||
if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"):
|
||||
print("OK. continue.")
|
||||
return
|
||||
|
||||
@@ -783,6 +1071,9 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
self.ptycho_reconstruct_foldername = self._get_val(
|
||||
"Reconstruction queue", self.ptycho_reconstruct_foldername, str
|
||||
)
|
||||
self.frames_per_trigger = self._get_val(
|
||||
"Frames per trigger (burst)", self.frames_per_trigger, int
|
||||
)
|
||||
|
||||
print("Tomography type:")
|
||||
print(" 1: 8 equally spaced sub-tomograms (360 deg)")
|
||||
@@ -898,7 +1189,6 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
)
|
||||
self.client.logbook.send_logbook_message(msg)
|
||||
|
||||
|
||||
def get_calibration_of_capstops_left_and_right(self):
|
||||
import time
|
||||
print("""
|
||||
@@ -909,28 +1199,28 @@ class LamNI(LamNIOpticsMixin, LamniGuiTools):
|
||||
Example: At 0 deg, accessible rty -60 to 51. So the init was 5 microns off.
|
||||
Then this routine here will provide data for the new capstop left and right.
|
||||
""")
|
||||
|
||||
|
||||
angle = 0
|
||||
umv(dev.lsamrot,0)
|
||||
umv(dev.lsamrot, 0)
|
||||
print("Capstop right\nAngle, Voltage1, Voltage2")
|
||||
mv(dev.lsamrot,361)
|
||||
mv(dev.lsamrot, 361)
|
||||
while angle <= 360:
|
||||
angle = dev.lsamrot.readback.get()
|
||||
voltage1=float(dev.lsamrot.controller.socket_put_and_receive("MG@AN[1]"))
|
||||
voltage2=float(dev.lsamrot.controller.socket_put_and_receive("MG@AN[2]"))
|
||||
if angle<360:
|
||||
voltage1 = float(dev.lsamrot.controller.socket_put_and_receive("MG@AN[1]"))
|
||||
voltage2 = float(dev.lsamrot.controller.socket_put_and_receive("MG@AN[2]"))
|
||||
if angle < 360:
|
||||
print(f"{angle},{voltage1},{voltage2}")
|
||||
time.sleep(.3)
|
||||
|
||||
time.sleep(10)
|
||||
print("\nCapstop left\nAngle, Voltage1, Voltage2")
|
||||
mv(dev.lsamrot,-1)
|
||||
mv(dev.lsamrot, -1)
|
||||
while angle > 0:
|
||||
angle = dev.lsamrot.readback.get()
|
||||
voltage1=float(dev.lsamrot.controller.socket_put_and_receive("MG@AN[1]"))
|
||||
voltage2=float(dev.lsamrot.controller.socket_put_and_receive("MG@AN[2]"))
|
||||
if angle>0:
|
||||
voltage1 = float(dev.lsamrot.controller.socket_put_and_receive("MG@AN[1]"))
|
||||
voltage2 = float(dev.lsamrot.controller.socket_put_and_receive("MG@AN[2]"))
|
||||
if angle > 0:
|
||||
print(f"{angle},{voltage1},{voltage2}")
|
||||
time.sleep(.3)
|
||||
|
||||
print("Finished")
|
||||
print("Finished")
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
"""LamNI alignment correction infrastructure.
|
||||
|
||||
This mixin provides the correction infrastructure that is mixed directly into
|
||||
the :class:`LamNI` class, mirroring the pattern used by
|
||||
:class:`FlomniAlignmentMixin` in flomni.py.
|
||||
|
||||
The mixin assumes the hosting class provides:
|
||||
- ``self.client`` (BECClient)
|
||||
|
||||
``corr_pos_x``/``corr_pos_y``/``corr_angle`` (+ the ``_2`` iteration) are
|
||||
global-var-backed properties defined below -- the host class's ``__init__``
|
||||
must NOT assign plain-list instance attributes of these names, as that would
|
||||
shadow the properties and silently break persistence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from bec_lib import bec_logger
|
||||
from typeguard import typechecked
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
dev = builtins.__dict__.get("dev")
|
||||
|
||||
|
||||
class LamNIAlignmentMixin:
|
||||
"""Correction infrastructure for LamNI laminography.
|
||||
|
||||
Mixes into :class:`LamNI`. All methods are accessible directly as
|
||||
``lamni.reset_correction()``, ``lamni.tomo_fovx_offset``, etc.,
|
||||
matching the FlOMNI user API.
|
||||
"""
|
||||
|
||||
# Pixel calibration: mm per pixel (0.2 mm FOV, 218 px)
|
||||
PIXEL_CALIBRATION = 0.2 / 218
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Correction reset
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def reset_correction(self):
|
||||
"""Reset the look-up-table corrections to empty (iteration 1)."""
|
||||
self.corr_pos_x = []
|
||||
self.corr_pos_y = []
|
||||
self.corr_angle = []
|
||||
|
||||
def reset_correction_2(self):
|
||||
"""Reset the second iteration look-up-table correction to empty."""
|
||||
self.corr_pos_x_2 = []
|
||||
self.corr_pos_y_2 = []
|
||||
self.corr_angle_2 = []
|
||||
|
||||
def reset_xray_eye_correction(self):
|
||||
"""Delete the X-ray eye sinusoidal fit from the BEC global variable store."""
|
||||
self.client.delete_global_var("tomo_fit_xray_eye")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Additional lookup-table correction properties -- global-var-backed so
|
||||
# they survive a BEC client restart, same pattern as tomo_fovx_offset/
|
||||
# tomo_fovy_offset below and as FlomniAlignmentMixin's corr_pos_y/
|
||||
# corr_angle_y on the FlOMNI side. Keys are "lamni_"-prefixed because the
|
||||
# shape here differs from the FlOMNI side (real x *and* y correction
|
||||
# plus a shared angle grid, vs. FlOMNI's y-only correction) -- not meant
|
||||
# to be the same data, just the same persistence pattern.
|
||||
#
|
||||
# No default correction file exists for LamNI (unlike FlOMNI's
|
||||
# default_correction_file): reset_correction()/reset_correction_2() just
|
||||
# clear to empty, nothing is auto-loaded.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def corr_pos_x(self):
|
||||
val = self.client.get_global_var("lamni_corr_pos_x")
|
||||
return [] if val is None else val
|
||||
|
||||
@corr_pos_x.setter
|
||||
def corr_pos_x(self, val: list):
|
||||
self.client.set_global_var("lamni_corr_pos_x", val)
|
||||
|
||||
@property
|
||||
def corr_pos_y(self):
|
||||
val = self.client.get_global_var("lamni_corr_pos_y")
|
||||
return [] if val is None else val
|
||||
|
||||
@corr_pos_y.setter
|
||||
def corr_pos_y(self, val: list):
|
||||
self.client.set_global_var("lamni_corr_pos_y", val)
|
||||
|
||||
@property
|
||||
def corr_angle(self):
|
||||
val = self.client.get_global_var("lamni_corr_angle")
|
||||
return [] if val is None else val
|
||||
|
||||
@corr_angle.setter
|
||||
def corr_angle(self, val: list):
|
||||
self.client.set_global_var("lamni_corr_angle", val)
|
||||
|
||||
@property
|
||||
def corr_pos_x_2(self):
|
||||
val = self.client.get_global_var("lamni_corr_pos_x_2")
|
||||
return [] if val is None else val
|
||||
|
||||
@corr_pos_x_2.setter
|
||||
def corr_pos_x_2(self, val: list):
|
||||
self.client.set_global_var("lamni_corr_pos_x_2", val)
|
||||
|
||||
@property
|
||||
def corr_pos_y_2(self):
|
||||
val = self.client.get_global_var("lamni_corr_pos_y_2")
|
||||
return [] if val is None else val
|
||||
|
||||
@corr_pos_y_2.setter
|
||||
def corr_pos_y_2(self, val: list):
|
||||
self.client.set_global_var("lamni_corr_pos_y_2", val)
|
||||
|
||||
@property
|
||||
def corr_angle_2(self):
|
||||
val = self.client.get_global_var("lamni_corr_angle_2")
|
||||
return [] if val is None else val
|
||||
|
||||
@corr_angle_2.setter
|
||||
def corr_angle_2(self, val: list):
|
||||
self.client.set_global_var("lamni_corr_angle_2", val)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# FOV offset properties (backed by BEC global variable)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def tomo_fovx_offset(self):
|
||||
"""Horizontal FOV offset in mm (rotated with laminography geometry)."""
|
||||
val = self.client.get_global_var("tomo_fov_offset")
|
||||
if val is None:
|
||||
return 0.0
|
||||
return val[0] / 1000
|
||||
|
||||
@tomo_fovx_offset.setter
|
||||
@typechecked
|
||||
def tomo_fovx_offset(self, val: float):
|
||||
val_old = self.client.get_global_var("tomo_fov_offset")
|
||||
if val_old is None:
|
||||
val_old = [0.0, 0.0]
|
||||
self.client.set_global_var("tomo_fov_offset", [val * 1000, val_old[1]])
|
||||
|
||||
@property
|
||||
def tomo_fovy_offset(self):
|
||||
"""Vertical FOV offset in mm (rotated with laminography geometry)."""
|
||||
val = self.client.get_global_var("tomo_fov_offset")
|
||||
if val is None:
|
||||
return 0.0
|
||||
return val[1] / 1000
|
||||
|
||||
@tomo_fovy_offset.setter
|
||||
@typechecked
|
||||
def tomo_fovy_offset(self, val: float):
|
||||
val_old = self.client.get_global_var("tomo_fov_offset")
|
||||
if val_old is None:
|
||||
val_old = [0.0, 0.0]
|
||||
self.client.set_global_var("tomo_fov_offset", [val_old[0], val * 1000])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# X-ray eye sinusoidal correction — read from files or GUI
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# def read_xray_eye_correction(self, dir_path=None):
|
||||
# """Load the sinusoidal X-ray eye fit from archived text files.
|
||||
|
||||
# Files are written by :meth:`XrayEyeAlign.write_output` at the end of
|
||||
# every alignment run and are the fallback when the GUI has been closed.
|
||||
|
||||
# Args:
|
||||
# dir_path: Directory containing ``ptychotomoalign_{A,B,C}{x,y}.txt``.
|
||||
# Defaults to ``~/Data10/specES1/internal/``.
|
||||
# """
|
||||
# if dir_path is None:
|
||||
# dir_path = os.path.expanduser("~/Data10/specES1/internal/")
|
||||
# tomo_fit_xray_eye = np.zeros((2, 3))
|
||||
# for i, axis in enumerate(["x", "y"]):
|
||||
# for j, coeff in enumerate(["A", "B", "C"]):
|
||||
# with open(
|
||||
# os.path.join(dir_path, f"ptychotomoalign_{coeff}{axis}.txt"), "r"
|
||||
# ) as f:
|
||||
# tomo_fit_xray_eye[i][j] = f.readline()
|
||||
# self.client.set_global_var("tomo_fit_xray_eye", tomo_fit_xray_eye.tolist())
|
||||
# print("New alignment parameters loaded from X-ray eye files:")
|
||||
# self._print_xeye_fit(tomo_fit_xray_eye)
|
||||
|
||||
def read_xray_eye_correction_from_gui(self):
|
||||
"""Load the sinusoidal X-ray eye fit from the live XRayEye GUI widget.
|
||||
|
||||
Reads ``fit_params_x`` and ``fit_params_y`` from the ``omny_xray_gui``
|
||||
device and stores the result as
|
||||
``tomo_fit_xray_eye = [[Ax, Bx, Cx], [Ay, By, Cy]]``
|
||||
in the BEC global variable store.
|
||||
|
||||
The stored array is consumed by
|
||||
:meth:`lamni_compute_additional_correction_xeye_mu`.
|
||||
|
||||
.. important::
|
||||
This method reads from the live GUI widget. If the XRayEye GUI
|
||||
window has been closed since the alignment was performed the call
|
||||
will fail. In that case use :meth:`read_xray_eye_correction` to
|
||||
reload from the archived text files.
|
||||
"""
|
||||
_dev = builtins.__dict__.get("dev")
|
||||
tomo_fit_xray_eye = np.zeros((2, 3))
|
||||
|
||||
params_x = _dev.omny_xray_gui.fit_params_x.get()
|
||||
params_y = _dev.omny_xray_gui.fit_params_y.get()
|
||||
for label, params in (("x", params_x), ("y", params_y)):
|
||||
if (
|
||||
not isinstance(params, dict)
|
||||
or not {"SineModel_0_amplitude", "SineModel_0_shift", "LinearModel_1_intercept"}
|
||||
<= params.keys()
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"No converged DAP sinusoidal fit available for the {label} axis "
|
||||
f"(omny_xray_gui.fit_params_{label} did not return the expected "
|
||||
"named parameters -- got "
|
||||
f"{type(params).__name__ if not isinstance(params, dict) else 'a dict missing keys'}). "
|
||||
"This means the fit hasn't converged / DAP forwarding hasn't produced "
|
||||
"a result yet, not that the correction data is missing. Wait for the "
|
||||
"fit to converge in the XRayEye GUI's Fit tab, or use "
|
||||
"read_xray_eye_correction() to reload from the archived text files "
|
||||
"instead."
|
||||
)
|
||||
|
||||
tomo_fit_xray_eye[0][0] = params_x["SineModel_0_amplitude"]
|
||||
tomo_fit_xray_eye[0][1] = params_x["SineModel_0_shift"]
|
||||
tomo_fit_xray_eye[0][2] = params_x["LinearModel_1_intercept"]
|
||||
|
||||
tomo_fit_xray_eye[1][0] = params_y["SineModel_0_amplitude"]
|
||||
tomo_fit_xray_eye[1][1] = params_y["SineModel_0_shift"]
|
||||
tomo_fit_xray_eye[1][2] = params_y["LinearModel_1_intercept"]
|
||||
|
||||
self.client.set_global_var("tomo_fit_xray_eye", tomo_fit_xray_eye.tolist())
|
||||
print("New alignment parameters loaded from XRayEye GUI fit:")
|
||||
self._print_xeye_fit(tomo_fit_xray_eye)
|
||||
|
||||
@staticmethod
|
||||
def _print_xeye_fit(fit):
|
||||
"""Pretty-print the 2×3 X-ray eye fit array."""
|
||||
print(
|
||||
f" X: A={fit[0][0]:.4f}, B={fit[0][1]:.4f}, C={fit[0][2]:.4f}\n"
|
||||
f" Y: A={fit[1][0]:.4f}, B={fit[1][1]:.4f}, C={fit[1][2]:.4f}"
|
||||
)
|
||||
|
||||
def lamni_compute_additional_correction_xeye_mu(self, angle):
|
||||
"""Evaluate the sinusoidal X-ray eye correction at *angle* degrees.
|
||||
|
||||
Returns:
|
||||
tuple: ``(correction_x_mm, correction_y_mm)``
|
||||
"""
|
||||
tomo_fit_xray_eye = self.client.get_global_var("tomo_fit_xray_eye")
|
||||
if tomo_fit_xray_eye is None:
|
||||
print("Not applying any X-ray eye correction. No fit data available.\n")
|
||||
return (0, 0)
|
||||
|
||||
correction_x = (
|
||||
tomo_fit_xray_eye[0][0] * np.sin(
|
||||
np.radians(angle) + tomo_fit_xray_eye[0][1]
|
||||
)
|
||||
+ tomo_fit_xray_eye[0][2]
|
||||
) / 1000
|
||||
correction_y = (
|
||||
tomo_fit_xray_eye[1][0] * np.sin(
|
||||
np.radians(angle) + tomo_fit_xray_eye[1][1]
|
||||
)
|
||||
+ tomo_fit_xray_eye[1][2]
|
||||
) / 1000
|
||||
|
||||
print(
|
||||
f"Xeye correction x={correction_x:.6f} mm,"
|
||||
f" y={correction_y:.6f} mm @ angle={angle}\n"
|
||||
)
|
||||
return (correction_x, correction_y)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Additional look-up-table corrections (iteration 1 and 2)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def read_additional_correction(self, correction_file: str):
|
||||
"""Load the iteration-1 correction lookup table from *correction_file*."""
|
||||
self.corr_pos_x, self.corr_pos_y, self.corr_angle = self._read_correction_file_xy(
|
||||
correction_file
|
||||
)
|
||||
|
||||
def read_additional_correction_2(self, correction_file: str):
|
||||
"""Load the iteration-2 correction lookup table from *correction_file*."""
|
||||
self.corr_pos_x_2, self.corr_pos_y_2, self.corr_angle_2 = (
|
||||
self._read_correction_file_xy(correction_file)
|
||||
)
|
||||
|
||||
def _read_correction_file_xy(self, correction_file: str):
|
||||
"""Parse a correction file containing ``corr_pos_x``, ``corr_pos_y``
|
||||
and ``corr_angle`` entries.
|
||||
|
||||
Returns:
|
||||
tuple: ``(corr_pos_x, corr_pos_y, corr_angle)`` as lists of floats.
|
||||
"""
|
||||
with open(correction_file, "r") as f:
|
||||
num_elements = f.readline()
|
||||
int_num_elements = int(num_elements.split(" ")[2])
|
||||
print(int_num_elements)
|
||||
corr_pos_x = []
|
||||
corr_pos_y = []
|
||||
corr_angle = []
|
||||
for _ in range(int_num_elements * 3):
|
||||
line = f.readline()
|
||||
value = line.split(" ")[2]
|
||||
name = line.split(" ")[0].split("[")[0]
|
||||
if name == "corr_pos_x":
|
||||
corr_pos_x.append(float(value) / 1000)
|
||||
elif name == "corr_pos_y":
|
||||
corr_pos_y.append(float(value) / 1000)
|
||||
elif name == "corr_angle":
|
||||
corr_angle.append(float(value))
|
||||
return corr_pos_x, corr_pos_y, corr_angle
|
||||
|
||||
def compute_additional_correction(self, angle):
|
||||
"""Return the iteration-1 lookup-table correction for *angle*.
|
||||
|
||||
Returns:
|
||||
tuple: ``(shift_x_mm, shift_y_mm)``
|
||||
"""
|
||||
return self._compute_correction_xy(
|
||||
angle, self.corr_pos_x, self.corr_pos_y, self.corr_angle, label="1"
|
||||
)
|
||||
|
||||
def compute_additional_correction_2(self, angle):
|
||||
"""Return the iteration-2 lookup-table correction for *angle*.
|
||||
|
||||
Returns:
|
||||
tuple: ``(shift_x_mm, shift_y_mm)``
|
||||
"""
|
||||
return self._compute_correction_xy(
|
||||
angle, self.corr_pos_x_2, self.corr_pos_y_2, self.corr_angle_2, label="2"
|
||||
)
|
||||
|
||||
def _compute_correction_xy(self, angle, corr_pos_x, corr_pos_y, corr_angle, label=""):
|
||||
"""Find the correction for the closest angle in the lookup table."""
|
||||
if not corr_pos_x:
|
||||
print(f"Not applying additional correction {label}. No data available.\n")
|
||||
return (0, 0)
|
||||
|
||||
shift_x = corr_pos_x[0]
|
||||
shift_y = corr_pos_y[0]
|
||||
angledelta = np.fabs(corr_angle[0] - angle)
|
||||
|
||||
for j in range(1, len(corr_pos_x)):
|
||||
newangledelta = np.fabs(corr_angle[j] - angle)
|
||||
if newangledelta < angledelta:
|
||||
shift_x = corr_pos_x[j]
|
||||
shift_y = corr_pos_y[j]
|
||||
angledelta = newangledelta
|
||||
|
||||
if shift_x == 0 and angle < corr_angle[0]:
|
||||
shift_x = corr_pos_x[0]
|
||||
shift_y = corr_pos_y[0]
|
||||
|
||||
if shift_x == 0 and angle > corr_angle[-1]:
|
||||
shift_x = corr_pos_x[-1]
|
||||
shift_y = corr_pos_y[-1]
|
||||
|
||||
print(f"Additional correction {label}: x={shift_x}, y={shift_y}")
|
||||
return (shift_x, shift_y)
|
||||
@@ -144,6 +144,8 @@ class LaMNIInitStages:
|
||||
umv(dev.lsamrot, -1)
|
||||
umv(dev.lsamrot, 0)
|
||||
|
||||
self.set_default_lamni_limits()
|
||||
|
||||
time.sleep(2)
|
||||
dev.rtx.controller.feedback_disable_and_even_reset_lamni_angle_interferometer()
|
||||
|
||||
@@ -159,6 +161,38 @@ class LaMNIInitStages:
|
||||
else:
|
||||
return False
|
||||
|
||||
def set_default_lamni_limits(self):
|
||||
"""
|
||||
Apply safe, collision-protected default limits for LamNI.
|
||||
Mirrors legacy SPEC limits.
|
||||
"""
|
||||
if not self.OMNYTools.yesno("Set default limits for LamNI?"):
|
||||
print("Stopping.")
|
||||
return
|
||||
|
||||
print("Setting LamNI limits...")
|
||||
|
||||
# Sample stages
|
||||
dev.lsamx.limits = [6, 14]
|
||||
dev.lsamy.limits = [6, 14]
|
||||
dev.lsamrot.limits = [-3, 362]
|
||||
|
||||
# Optics (FZP)
|
||||
dev.loptx.limits = [-1, -0.2]
|
||||
dev.lopty.limits = [3.0, 3.6]
|
||||
dev.loptz.limits = [82, 87]
|
||||
|
||||
# X-ray eye
|
||||
dev.leyex.limits = [0, 25]
|
||||
dev.leyey.limits = [0.5, 50]
|
||||
|
||||
# OSA / SmarAct
|
||||
dev.losax.limits = [-1.5, 0.25]
|
||||
dev.losay.limits = [-2.5, 4.1]
|
||||
dev.losaz.limits = [-4.1, -0.5]
|
||||
|
||||
print("LamNI limits successfully applied.")
|
||||
|
||||
|
||||
class LamNIOpticsMixin:
|
||||
"""Optics movement methods: FZP, OSA, central stop and X-ray eye."""
|
||||
@@ -178,12 +212,12 @@ class LamNIOpticsMixin:
|
||||
|
||||
epics_put("XOMNYI-XEYE-ACQ:0", 2)
|
||||
umv(dev.lsamrot, 0)
|
||||
umv(dev.dttrz, 5854, dev.fttrz, 2395)
|
||||
#umv(dev.dttrz, 5854, dev.fttrz, 2395)
|
||||
|
||||
def leye_in(self):
|
||||
bec.queue.next_dataset_number += 1
|
||||
umv(dev.lsamrot, 0)
|
||||
umv(dev.dttrz, 6419.677, dev.fttrz, 2959.979)
|
||||
#umv(dev.dttrz, 6419.677, dev.fttrz, 2959.979)
|
||||
while True:
|
||||
moved_out = (input("Did the flight tube move out? (Y/n)") or "y").lower()
|
||||
if moved_out == "y":
|
||||
@@ -193,7 +227,7 @@ class LamNIOpticsMixin:
|
||||
leyex_in = self._get_user_param_safe("leyex", "in")
|
||||
leyey_in = self._get_user_param_safe("leyey", "in")
|
||||
umv(dev.leyex, leyex_in, dev.leyey, leyey_in)
|
||||
self.align.update_frame()
|
||||
self.xrayeye_update_frame()
|
||||
|
||||
def _lfzp_in(self):
|
||||
loptx_in = self._get_user_param_safe("loptx", "in")
|
||||
@@ -208,7 +242,10 @@ class LamNIOpticsMixin:
|
||||
self._lfzp_in()
|
||||
|
||||
if "rtx" in dev and dev.rtx.enabled:
|
||||
print("Re-establishing interferometer feedback...")
|
||||
_t0 = time.time()
|
||||
dev.rtx.controller.feedback_enable_with_reset()
|
||||
print(f"Interferometer feedback re-established ({time.time() - _t0:.1f} s).")
|
||||
|
||||
def loptics_in(self):
|
||||
"""Move in the LamNI optics (FZP + OSA)."""
|
||||
@@ -227,7 +264,10 @@ class LamNIOpticsMixin:
|
||||
|
||||
if "rtx" in dev and dev.rtx.enabled:
|
||||
time.sleep(1)
|
||||
print("Re-establishing interferometer feedback...")
|
||||
_t0 = time.time()
|
||||
dev.rtx.controller.feedback_enable_with_reset()
|
||||
print(f"Interferometer feedback re-established ({time.time() - _t0:.1f} s).")
|
||||
|
||||
def lcs_in(self):
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
num_elements = 8
|
||||
corr_pos_x[0] = 0.5
|
||||
corr_pos_y[0] = -0.3
|
||||
corr_angle[0] = 0.0
|
||||
corr_pos_x[1] = 0.8
|
||||
corr_pos_y[1] = 0.1
|
||||
corr_angle[1] = 45.0
|
||||
corr_pos_x[2] = 0.6
|
||||
corr_pos_y[2] = 0.9
|
||||
corr_angle[2] = 90.0
|
||||
corr_pos_x[3] = -0.2
|
||||
corr_pos_y[3] = 1.1
|
||||
corr_angle[3] = 135.0
|
||||
corr_pos_x[4] = -0.7
|
||||
corr_pos_y[4] = 0.4
|
||||
corr_angle[4] = 180.0
|
||||
corr_pos_x[5] = -0.9
|
||||
corr_pos_y[5] = -0.5
|
||||
corr_angle[5] = 225.0
|
||||
corr_pos_x[6] = -0.3
|
||||
corr_pos_y[6] = -1.0
|
||||
corr_angle[6] = 270.0
|
||||
corr_pos_x[7] = 0.4
|
||||
corr_pos_y[7] = -0.8
|
||||
corr_angle[7] = 315.0
|
||||
@@ -0,0 +1,569 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import builtins
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
bec = builtins.__dict__.get("bec")
|
||||
dev = builtins.__dict__.get("dev")
|
||||
scans = builtins.__dict__.get("scans")
|
||||
|
||||
|
||||
def umv(*args):
|
||||
return scans.umv(*args, relative=False)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
|
||||
|
||||
|
||||
# Laminography alignment angles: full 360° in 45° steps
|
||||
LAMNI_ALIGNMENT_ANGLES = [k * 45 for k in range(8)] # [0, 45, 90, 135, 180, 225, 270, 315]
|
||||
|
||||
|
||||
class XrayEyeAlign:
|
||||
"""BEC-GUI-based X-ray eye alignment for LamNI.
|
||||
|
||||
Replaces the old EPICS/LabView interface. Collects sample centre
|
||||
coordinates (x and y) at 8 equally-spaced laminography angles over the
|
||||
full 360°, submits them to the XRayEye widget's fit tab, and then reads
|
||||
the resulting sinusoidal fit parameters back via
|
||||
``lamni.read_xray_eye_correction_from_gui()``.
|
||||
|
||||
The key difference from the FlOMNI alignment is that LamNI needs *both*
|
||||
x and y fits because the tilted rotation axis (61°) couples both
|
||||
directions.
|
||||
"""
|
||||
|
||||
# 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, matching the
|
||||
# FlOMNI side); this constant is only the fallback used when that
|
||||
# device/parameter is unavailable.
|
||||
PIXEL_CALIBRATION_DEFAULT = 0.2 / 218 # mm/pixel (0.2 mm FOV, 218 px)
|
||||
PIXEL_CALIBRATION_USER_PARAM = "pixel_calibration"
|
||||
|
||||
def __init__(self, client, lamni: LamNI) -> None:
|
||||
self.client = client
|
||||
self.lamni = lamni
|
||||
self.device_manager = client.device_manager
|
||||
self.scans = client.scans
|
||||
# Deliberately NOT calling self.lamni.reset_correction()/
|
||||
# reset_xray_eye_correction() here: a fresh XrayEyeAlign is
|
||||
# constructed on *every* call to xrayeye_alignment_start() AND
|
||||
# xrayeye_update_frame() (see lamni.py) -- the latter is meant to be
|
||||
# a lightweight "just grab a frame to look at" utility, not the start
|
||||
# of a new alignment run. Now that corr_pos_x/y/angle and
|
||||
# tomo_fit_xray_eye are persisted via BEC global vars, resetting them
|
||||
# here would silently wipe a valid, already-fitted alignment every
|
||||
# time someone calls xrayeye_update_frame() to check the sample.
|
||||
# These resets belong in align() instead, matching what an actual
|
||||
# fresh alignment run should do -- see the start of align() below.
|
||||
# alignment_values[k] = [x_mm, y_mm]
|
||||
# k=0 : FZP centre
|
||||
# k=1 : sample at 0°, pre-shift (used only to compute shift_xy,
|
||||
# the base shift to scan centre -- NOT one of the 8
|
||||
# angle-fit points used in write_output())
|
||||
# k=2 : sample at 0°, post-shift/at scan centre (first real
|
||||
# angle-fit point; reference for all offsets below)
|
||||
# k=3 : sample at 45°
|
||||
# ...
|
||||
# k=9 : sample at 315°
|
||||
self.alignment_values: dict[int, list[float]] = {}
|
||||
self.alignment_images = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GUI shortcut
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def gui(self):
|
||||
"""Return the live XRayEye RPC handle stored by LamniGuiTools."""
|
||||
return self.lamni.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. Matches the FlOMNI side, which moved from a
|
||||
hardcoded constant to this camera-sourced value.
|
||||
"""
|
||||
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)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _reset_init_values(self):
|
||||
self.shift_xy = [0.0, 0.0] # base shift to bring sample to beam centre [µm]
|
||||
self._xray_fov_xy = [0.0, 0.0]
|
||||
# Raw pixel coords + ROI size collected at each submit:
|
||||
# [[step_k, x_px, y_px, w_px, h_px, image_idx], ...]
|
||||
# image_idx refers to alignment_images[image_idx], i.e. the last
|
||||
# frame captured before that submit (shutter is closed at submit time).
|
||||
self.roi_pixel_data = []
|
||||
|
||||
def tomo_rotate(self, val: float):
|
||||
umv(self.device_manager.devices.lsamrot, val)
|
||||
|
||||
def get_tomo_angle(self) -> float:
|
||||
return self.device_manager.devices.lsamrot.readback.read()["lsamrot"]["value"]
|
||||
|
||||
def _disable_rt_feedback(self):
|
||||
self.device_manager.devices.rtx.controller.feedback_disable()
|
||||
|
||||
def _enable_rt_feedback(self):
|
||||
self.device_manager.devices.rtx.controller.feedback_enable_with_reset()
|
||||
|
||||
def update_frame(self, keep_shutter_open: bool = False):
|
||||
"""Capture a fresh camera frame.
|
||||
|
||||
Args:
|
||||
keep_shutter_open: If True the shutter is left open and the GUI
|
||||
stays in live-view mode after the frame is taken. Useful when
|
||||
it is hard to locate the sample between rotations. The caller
|
||||
is responsible for closing the shutter afterwards.
|
||||
"""
|
||||
if not dev.cam_xeye.live_mode_enabled.get():
|
||||
dev.cam_xeye.live_mode_enabled.put(True)
|
||||
self.gui.on_live_view_enabled(True)
|
||||
dev.fsh.fshopen()
|
||||
time.sleep(1)
|
||||
# store the image: the relevant frame for any submit that follows,
|
||||
# since the shutter is closed again (unless keep_shutter_open) by
|
||||
# the time the user actually clicks submit
|
||||
self.alignment_images.append(dev.cam_xeye.get_last_image())
|
||||
if not keep_shutter_open:
|
||||
self.gui.on_live_view_enabled(False)
|
||||
time.sleep(0.1)
|
||||
dev.fsh.fshclose()
|
||||
print("Received new frame.")
|
||||
else:
|
||||
print("Received new frame. Shutter remains open and live view active.")
|
||||
|
||||
def update_fov(self, k: int):
|
||||
self._xray_fov_xy[0] = max(
|
||||
getattr(dev.omny_xray_gui, f"width_x_{k}").get(), self._xray_fov_xy[0]
|
||||
)
|
||||
self._xray_fov_xy[1] = max(
|
||||
getattr(dev.omny_xray_gui, f"width_y_{k}").get(), self._xray_fov_xy[1]
|
||||
)
|
||||
|
||||
def movement_buttons_enabled(self, enable_x: bool, enable_y: bool):
|
||||
self.gui.on_motors_enable(enable_x, enable_y)
|
||||
|
||||
def send_message(self, msg: str):
|
||||
print(f"Alignment GUI: {msg}")
|
||||
self.gui.user_message = msg
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main alignment procedure
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _wait_for_dap_fit(self, timeout: float = 30.0, poll_interval: float = 1.0) -> bool:
|
||||
"""Poll ``omny_xray_gui.fit_params_x``/``fit_params_y`` until both report
|
||||
a converged DAP fit, or *timeout* seconds have passed.
|
||||
|
||||
Returns True if a converged fit was seen, False on timeout. Either way,
|
||||
the caller (read_xray_eye_correction_from_gui) does its own check and
|
||||
raises a clear error if the fit still isn't ready -- this just avoids
|
||||
giving up after an arbitrary fixed sleep when the DAP service is simply
|
||||
running a bit slower than usual.
|
||||
"""
|
||||
required_keys = {"SineModel_0_amplitude", "SineModel_0_shift", "LinearModel_1_intercept"}
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
params_x = dev.omny_xray_gui.fit_params_x.get()
|
||||
params_y = dev.omny_xray_gui.fit_params_y.get()
|
||||
if (
|
||||
isinstance(params_x, dict)
|
||||
and required_keys <= params_x.keys()
|
||||
and isinstance(params_y, dict)
|
||||
and required_keys <= params_y.keys()
|
||||
):
|
||||
print(f"DAP fit converged after {time.time() - t0:.1f} s.")
|
||||
return True
|
||||
time.sleep(poll_interval)
|
||||
print(
|
||||
f"DAP fit did not report converged parameters within {timeout:.0f} s -- "
|
||||
"proceeding anyway; the next step will raise a clear error if it's "
|
||||
"still not ready."
|
||||
)
|
||||
return False
|
||||
|
||||
def align(self, keep_shutter_open: bool = False):
|
||||
"""Run the full LamNI X-ray eye alignment.
|
||||
|
||||
Args:
|
||||
keep_shutter_open: If True the shutter is left open between angle
|
||||
steps so the sample remains visible in live view. At the end
|
||||
of the procedure the user is asked whether to close the shutter.
|
||||
Matches the equivalent FlOMNI option.
|
||||
|
||||
Procedure
|
||||
---------
|
||||
Step 0 – FZP centre
|
||||
Put FZP in, capture frame, user clicks FZP centre.
|
||||
|
||||
Step 1 – Sample at 0°
|
||||
Put sample in, capture frame, user clicks sample centre.
|
||||
Compute base shift (shift_xy) and move to scan centre.
|
||||
|
||||
Steps 2–8 – Sample at 45°, 90°, …, 315°
|
||||
Rotate to each angle (via lamni_move_to_scan_center so the base
|
||||
shift is applied), capture frame, user clicks sample centre.
|
||||
|
||||
After all submits
|
||||
Compute x/y offsets, push to GUI fit tab, wait for DAP fit,
|
||||
then load fit parameters into the global variable store.
|
||||
"""
|
||||
self.lamni.lamnigui_show_xeyealign()
|
||||
self.gui.set_dap_params_forwarding(True)
|
||||
self.send_message("Getting things ready. Please wait...")
|
||||
|
||||
# Actual start of a fresh alignment run -- see the comment in
|
||||
# __init__ for why this can't live there.
|
||||
self.lamni.reset_correction()
|
||||
self.lamni.reset_xray_eye_correction()
|
||||
|
||||
self.gui.enable_submit_button(False)
|
||||
|
||||
# Initialise EPICS GUI device state
|
||||
dev.omny_xray_gui.mvx.set(0)
|
||||
dev.omny_xray_gui.mvy.set(0)
|
||||
dev.omny_xray_gui.submit.set(0)
|
||||
|
||||
self.movement_buttons_enabled(False, False)
|
||||
self._reset_init_values()
|
||||
self.alignment_images = []
|
||||
|
||||
# --- Step 0: FZP centre ------------------------------------------
|
||||
#self._disable_rt_feedback()
|
||||
self.lamni.lfzp_in()
|
||||
#self._enable_rt_feedback()
|
||||
|
||||
self.update_frame(keep_shutter_open)
|
||||
|
||||
self.gui.enable_submit_button(True)
|
||||
dev.omny_xray_gui.step.set(0)
|
||||
self.send_message("Submit centre of FZP.")
|
||||
|
||||
k = 0
|
||||
while True:
|
||||
if dev.omny_xray_gui.submit.get() == 1:
|
||||
val_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get() * self.pixel_calibration
|
||||
val_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get() * self.pixel_calibration
|
||||
self.alignment_values[k] = [val_x, val_y]
|
||||
print(
|
||||
f"Clicked position {k}: "
|
||||
f"x={self.alignment_values[k][0]:.4f} mm, "
|
||||
f"y={self.alignment_values[k][1]:.4f} mm"
|
||||
)
|
||||
dev.omny_xray_gui.submit.set(0)
|
||||
|
||||
# Raw pixel position and ROI size at submit time. The
|
||||
# relevant image is the last captured frame (shutter is
|
||||
# closed by the time the user clicks submit).
|
||||
_raw_x = getattr(dev.omny_xray_gui, f"xval_x_{k}").get()
|
||||
_raw_y = getattr(dev.omny_xray_gui, f"yval_y_{k}").get()
|
||||
_raw_w = getattr(dev.omny_xray_gui, f"width_x_{k}").get()
|
||||
_raw_h = getattr(dev.omny_xray_gui, f"width_y_{k}").get()
|
||||
_img_idx = len(self.alignment_images) - 1
|
||||
print(
|
||||
f" Submit k={k}: px x={_raw_x:.1f} y={_raw_y:.1f} "
|
||||
f"w={_raw_w:.1f} h={_raw_h:.1f} img={_img_idx}"
|
||||
)
|
||||
self.roi_pixel_data.append([k, _raw_x, _raw_y, _raw_w, _raw_h, _img_idx])
|
||||
|
||||
# --- k=0: received FZP centre ----------------------------
|
||||
if k == 0:
|
||||
self.send_message("Please wait - moving sample in...")
|
||||
self.movement_buttons_enabled(False, False)
|
||||
self.gui.enable_submit_button(False)
|
||||
|
||||
self.lamni.loptics_out()
|
||||
#self._disable_rt_feedback()
|
||||
#time.sleep(0.3)
|
||||
#self._enable_rt_feedback()
|
||||
|
||||
self.update_frame(keep_shutter_open)
|
||||
|
||||
# Mark the FZP centre on the live view: it stays visible
|
||||
# as a fixed reference while the sample is aligned at
|
||||
# each subsequent rotation angle below.
|
||||
fzp_center_x = dev.omny_xray_gui.xval_x_0.get()
|
||||
fzp_center_y = dev.omny_xray_gui.yval_y_0.get()
|
||||
self.gui.set_crosshair_position(fzp_center_x, fzp_center_y)
|
||||
self.gui.show_crosshair()
|
||||
|
||||
self.send_message("Find the sample and submit its centre.")
|
||||
self.gui.enable_submit_button(True)
|
||||
self.movement_buttons_enabled(True, True)
|
||||
|
||||
# --- k=1: sample at 0° - compute base shift --------------
|
||||
elif k == 1:
|
||||
# Displacement from FZP centre to sample centre gives the
|
||||
# correction needed to bring the sample onto the beam axis.
|
||||
# Sign conventions match the old alignment.py.
|
||||
self.shift_xy[0] += (
|
||||
self.alignment_values[0][0] - self.alignment_values[1][0]
|
||||
) * 1000 # um
|
||||
self.shift_xy[1] += (
|
||||
self.alignment_values[1][1] - self.alignment_values[0][1]
|
||||
) * 1000 # um
|
||||
print(
|
||||
f"Base shift: "
|
||||
f"x={self.shift_xy[0]:.2f} um, "
|
||||
f"y={self.shift_xy[1]:.2f} um"
|
||||
)
|
||||
|
||||
self.send_message("Please wait - moving to scan centre...")
|
||||
self.movement_buttons_enabled(False, False)
|
||||
self.gui.enable_submit_button(False)
|
||||
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
shift_x=self.shift_xy[0] / 1000,
|
||||
shift_y=self.shift_xy[1] / 1000,
|
||||
angle=float(self.get_tomo_angle()),
|
||||
).wait()
|
||||
time.sleep(1)
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
shift_x=self.shift_xy[0] / 1000,
|
||||
shift_y=self.shift_xy[1] / 1000,
|
||||
angle=float(self.get_tomo_angle()),
|
||||
).wait()
|
||||
|
||||
dev.omny_xray_gui.angle.set(self.get_tomo_angle())
|
||||
self.update_frame(keep_shutter_open)
|
||||
self.send_message("Submit sample centre and FOV (0 deg).")
|
||||
self.gui.enable_submit_button(True)
|
||||
self.movement_buttons_enabled(True, False)
|
||||
self.update_fov(k)
|
||||
|
||||
# --- k=2..8: sample at 45, 90, ..., 315 deg -------------
|
||||
elif 1 < k <= 8:
|
||||
self.send_message("Please wait - rotating...")
|
||||
self.gui.enable_submit_button(False)
|
||||
self.movement_buttons_enabled(False, False)
|
||||
|
||||
target_angle = LAMNI_ALIGNMENT_ANGLES[k - 1] # 45...315 deg
|
||||
# Approach from halfway between previous and current angle
|
||||
# to reduce mechanical hysteresis (same as old alignment.py)
|
||||
prev_angle = LAMNI_ALIGNMENT_ANGLES[k - 2]
|
||||
approach_angle = prev_angle + (target_angle - prev_angle) / 2
|
||||
self._disable_rt_feedback()
|
||||
self.tomo_rotate(approach_angle)
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
shift_x=self.shift_xy[0] / 1000,
|
||||
shift_y=self.shift_xy[1] / 1000,
|
||||
angle=float(self.get_tomo_angle()),
|
||||
).wait()
|
||||
self._disable_rt_feedback()
|
||||
self.tomo_rotate(target_angle)
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
shift_x=self.shift_xy[0] / 1000,
|
||||
shift_y=self.shift_xy[1] / 1000,
|
||||
angle=float(self.get_tomo_angle()),
|
||||
).wait()
|
||||
|
||||
dev.omny_xray_gui.angle.set(self.get_tomo_angle())
|
||||
self.update_frame(keep_shutter_open)
|
||||
self.send_message(f"Submit sample centre ({target_angle} deg).")
|
||||
self.gui.enable_submit_button(True)
|
||||
self.movement_buttons_enabled(True, False)
|
||||
self.update_fov(k)
|
||||
|
||||
# --- k=9: all angles collected - finish ------------------
|
||||
elif k == 9:
|
||||
self.send_message("All angles collected - computing fit...")
|
||||
self.gui.enable_submit_button(False)
|
||||
self.movement_buttons_enabled(False, False)
|
||||
self.update_fov(k)
|
||||
self.gui.hide_crosshair()
|
||||
break
|
||||
|
||||
k += 1
|
||||
dev.omny_xray_gui.step.set(k)
|
||||
|
||||
# Handle live motor movement buttons.
|
||||
# Only active during k=0 (FZP) and k=1 (sample at 0 deg) – both
|
||||
# x and y movements accumulate into shift_xy and are applied via
|
||||
# lamni_move_to_scan_center so the laminography coordinate
|
||||
# transformation is correctly applied. This matches the old
|
||||
# alignment.py exactly.
|
||||
if k < 2:
|
||||
_mvx = dev.omny_xray_gui.mvx.get()
|
||||
_mvy = dev.omny_xray_gui.mvy.get()
|
||||
if _mvx != 0 or _mvy != 0:
|
||||
self.shift_xy[0] += _mvx
|
||||
self.shift_xy[1] += _mvy
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
shift_x=self.shift_xy[0] / 1000,
|
||||
shift_y=self.shift_xy[1] / 1000,
|
||||
angle=float(self.get_tomo_angle()),
|
||||
).wait()
|
||||
print(
|
||||
f"Current centre: "
|
||||
f"horizontal={self.shift_xy[0]:.2f} um, "
|
||||
f"vertical={self.shift_xy[1]:.2f} um"
|
||||
)
|
||||
dev.omny_xray_gui.mvx.set(0)
|
||||
dev.omny_xray_gui.mvy.set(0)
|
||||
self.update_frame(keep_shutter_open)
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# All submits received - compute offsets and push to GUI fit tab
|
||||
# ------------------------------------------------------------------
|
||||
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
|
||||
print(
|
||||
f"Largest FOV from X-ray eye alignment:\n"
|
||||
f" fovx = {fovx:.0f} um, fovy = {fovy:.0f} um"
|
||||
)
|
||||
print(
|
||||
f"Base shift (applied to all scan centres):\n"
|
||||
f" x = {self.shift_xy[0]:.2f} um, y = {self.shift_xy[1]:.2f} um"
|
||||
)
|
||||
self.client.set_global_var("tomo_fov_offset", self.shift_xy)
|
||||
|
||||
# Switch GUI to fit tab and wait for DAP to finish fitting.
|
||||
# A fixed sleep isn't reliable here -- how long the DAP service
|
||||
# takes to fit and forward a result varies -- so poll the actual
|
||||
# signal state instead of guessing a duration.
|
||||
self.lamni.lamnigui_show_xeyealign_fittab()
|
||||
print("Waiting for DAP sinusoidal fit to converge...")
|
||||
self._wait_for_dap_fit(timeout=30)
|
||||
|
||||
# Read fit parameters from the GUI into the global variable store
|
||||
print("Loading new alignment parameters from X-ray eye GUI fit.")
|
||||
try:
|
||||
self.lamni.read_xray_eye_correction_from_gui()
|
||||
finally:
|
||||
self.gui.set_dap_params_forwarding(False)
|
||||
|
||||
if keep_shutter_open:
|
||||
answer = input("Close the shutter now? [Y/n]: ").strip().lower()
|
||||
if answer in ("", "y", "yes"):
|
||||
dev.fsh.fshclose()
|
||||
self.gui.on_live_view_enabled(False)
|
||||
print("Shutter closed.")
|
||||
else:
|
||||
print("Shutter left open.")
|
||||
|
||||
# Return to 0 deg
|
||||
self._disable_rt_feedback()
|
||||
self.tomo_rotate(0)
|
||||
print(
|
||||
"Done. You are ready to remove the X-ray eye and start ptychography scans.\n"
|
||||
"Fine alignment: lamni.tomo_parameters() with offset 0, then lamni.sub_tomo_scan(1,0)"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HDF5 output
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _save_alignment_data(self, file_path: str, fit_data: np.ndarray | None = None):
|
||||
expanded = os.path.expanduser(file_path)
|
||||
os.makedirs(os.path.dirname(expanded), exist_ok=True)
|
||||
with h5py.File(expanded, "w") as f:
|
||||
f.create_dataset(
|
||||
"alignment_values", data=np.array(list(self.alignment_values.values()))
|
||||
)
|
||||
f.create_dataset("alignment_images", data=np.array(self.alignment_images))
|
||||
if self.roi_pixel_data:
|
||||
ds = f.create_dataset("roi_pixel_data", data=np.array(self.roi_pixel_data))
|
||||
ds.attrs["columns"] = ["step_k", "x_px", "y_px", "w_px", "h_px", "image_idx"]
|
||||
if fit_data is not None:
|
||||
ds = f.create_dataset("alignment_fit", data=fit_data)
|
||||
ds.attrs["rows"] = ["angles_deg", "fovx_offsets_um", "fovy_offsets_um"]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fit data preparation and submission
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def write_output(self):
|
||||
"""Compute x/y offsets for each angle and push them to the GUI fit tab.
|
||||
|
||||
Offsets are the displacement of the sample at each angle from the FZP
|
||||
centre (k=0) -- matching FlOMNI's own reference point
|
||||
(self.alignment_values[0] - self.alignment_values[k]). The FZP centre
|
||||
is the only point in the whole procedure that's independent of
|
||||
rotation angle, so it's the only valid zero-reference: using one of
|
||||
the 8 angle points as the reference instead (as earlier versions of
|
||||
this method did, first via an off-by-one at k=1, then via k=2) forces
|
||||
that one point's own offset to be exactly zero by construction -- not
|
||||
because the sample happens to be centred there, but because
|
||||
subtracting a value from itself is always zero -- which biases the
|
||||
whole sinusoidal fit rather than measuring it.
|
||||
x_offset[i] = (x_at_FZP - x_at_angle_i) * 1000 [um]
|
||||
y_offset[i] = (y_at_angle_i - y_at_FZP) * 1000 [um]
|
||||
|
||||
The array passed to submit_fit_array has shape (3, 8):
|
||||
row 0: angles [0, 45, ..., 315]
|
||||
row 1: x offsets [um]
|
||||
row 2: y offsets [um]
|
||||
|
||||
Also writes a timestamped HDF5 file alongside the archival text file,
|
||||
containing the full raw record of the alignment run: alignment_values
|
||||
(FZP centre + all 8 angle clicks, in mm), alignment_images (one frame
|
||||
per update_frame() call), roi_pixel_data (raw pixel coords/size at
|
||||
each submit), and this same fit array as alignment_fit.
|
||||
"""
|
||||
# Archival text file (backward compatible with any external scripts)
|
||||
file = os.path.expanduser("~/Data10/specES1/internal/xrayeye_alignmentvalues")
|
||||
os.makedirs(os.path.dirname(file), exist_ok=True)
|
||||
with open(file, "w") as f:
|
||||
f.write("angle\thorizontal\tvertical\n")
|
||||
for k in range(2, 10):
|
||||
angle_deg = LAMNI_ALIGNMENT_ANGLES[k - 2]
|
||||
x_off = (self.alignment_values[0][0] - self.alignment_values[k][0]) * 1000
|
||||
y_off = (self.alignment_values[k][1] - self.alignment_values[0][1]) * 1000
|
||||
f.write(f"{angle_deg}\t{x_off:.4f}\t{y_off:.4f}\n")
|
||||
print(
|
||||
f" Angle {angle_deg:3d} deg: "
|
||||
f"x_offset={x_off:.2f} um, y_offset={y_off:.2f} um"
|
||||
)
|
||||
|
||||
angles = np.array(LAMNI_ALIGNMENT_ANGLES, dtype=float)
|
||||
x_offsets = np.array(
|
||||
[
|
||||
(self.alignment_values[0][0] - self.alignment_values[k][0]) * 1000
|
||||
for k in range(2, 10)
|
||||
]
|
||||
)
|
||||
y_offsets = np.array(
|
||||
[
|
||||
(self.alignment_values[k][1] - self.alignment_values[0][1]) * 1000
|
||||
for k in range(2, 10)
|
||||
]
|
||||
)
|
||||
data = np.array([angles, x_offsets, y_offsets])
|
||||
|
||||
# Timestamped HDF5 archive of the full raw alignment run
|
||||
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
||||
file_h5 = f"~/data/raw/logs/xrayeye_alignmentvalues/xrayeye_alignmentvalues_{timestamp}.h5"
|
||||
self._save_alignment_data(file_h5, fit_data=data)
|
||||
|
||||
# Push to XRayEye widget: feeds waveform_x (row 1) and waveform_y (row 2)
|
||||
self.gui.submit_fit_array(data)
|
||||
print(f"Fit data submitted with shape {data.shape}:\n{data}")
|
||||
Binary file not shown.
@@ -46,10 +46,6 @@ def umv(*args):
|
||||
return scans.umv(*args, relative=False)
|
||||
|
||||
|
||||
class FlomniToolsError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class FlomniInitError(Exception):
|
||||
pass
|
||||
|
||||
@@ -3765,7 +3761,12 @@ class Flomni(
|
||||
csaxs_bec_basepath.parent / "bec_ipython_client" / "plugins" / "flomni" / logo_file_rel
|
||||
).resolve()
|
||||
print(logo_file)
|
||||
bec.messaging.scilog.new().add_attachment(logo_file, width=200).add_text(
|
||||
scilog = getattr(bec.messaging, "scilog", None)
|
||||
if scilog is None or not getattr(scilog, "_enabled", False):
|
||||
logger.warning("SciLog is not enabled; skipping PDF report entry.")
|
||||
return
|
||||
|
||||
scilog.new().add_attachment(logo_file, width=200).add_text(
|
||||
content.replace("\n", "<br>")
|
||||
).add_tags("tomoscan").send()
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class flomniGuiTools:
|
||||
|
||||
def set_client(self, client):
|
||||
self.client = client
|
||||
self.gui = self.client.gui
|
||||
self.gui = getattr(client, "gui", None)
|
||||
|
||||
def flomnigui_show_gui(self):
|
||||
if "flomni" in self.gui.windows:
|
||||
|
||||
@@ -10,6 +10,7 @@ import termios
|
||||
import threading
|
||||
import time
|
||||
import tty
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import epics
|
||||
@@ -323,11 +324,13 @@ class PtychoReconstructor:
|
||||
with open(json_file, "w") as f:
|
||||
json.dump(json_content, f, indent=4)
|
||||
|
||||
|
||||
class TomoIDManager:
|
||||
"""Registers a tomography measurement in the OMNY sample database
|
||||
and returns its assigned tomo ID.
|
||||
|
||||
Falls back to tomo ID 0 for non-production accounts (e.g. test
|
||||
accounts like "gac-x01dc") which the server rejects.
|
||||
|
||||
Usage:
|
||||
id_manager = TomoIDManager()
|
||||
tomo_id = id_manager.register(
|
||||
@@ -341,11 +344,14 @@ class TomoIDManager:
|
||||
)
|
||||
"""
|
||||
|
||||
# OMNY_URL = "https://omny.web.psi.ch/samples/newmeasurement.php"
|
||||
OMNY_URL = "https://v1p0zyg2w9n2k9c1.myfritz.net/samples/newmeasurement.php"
|
||||
OMNY_USER = ""
|
||||
OMNY_PASSWORD = ""
|
||||
TMP_FILE = "~/currsamplesnr.txt"
|
||||
FALLBACK_TOMO_ID = 0
|
||||
|
||||
@staticmethod
|
||||
def _is_valid_eaccount(eaccount: str) -> bool:
|
||||
"""True for real e-accounts (e.g. "e12345"), False for test accounts."""
|
||||
return bool(re.fullmatch(r"e\d{5}", eaccount.strip()))
|
||||
|
||||
def register(
|
||||
self,
|
||||
@@ -359,18 +365,16 @@ class TomoIDManager:
|
||||
) -> int:
|
||||
"""Register a new measurement and return the assigned tomo ID.
|
||||
|
||||
Args:
|
||||
sample_name (str): Name of the sample.
|
||||
date (str): Date string (e.g. "2024-03-08").
|
||||
eaccount (str): E-account identifier.
|
||||
scan_number (int): First scan number of the measurement.
|
||||
setup (str): Setup name (e.g. "lamni").
|
||||
additional_info (str): Any additional sample information.
|
||||
user (str): User name.
|
||||
|
||||
Returns:
|
||||
int: The tomo ID assigned by the OMNY database.
|
||||
Returns FALLBACK_TOMO_ID (0) if the account is not a real e-account
|
||||
or if the server cannot be reached / returns an unusable response.
|
||||
"""
|
||||
if not self._is_valid_eaccount(eaccount):
|
||||
logger.warning(
|
||||
f"Account '{eaccount}' is not a valid e-account; "
|
||||
f"skipping OMNY registration, using tomo ID {self.FALLBACK_TOMO_ID}."
|
||||
)
|
||||
return self.FALLBACK_TOMO_ID
|
||||
|
||||
url = (
|
||||
f"{self.OMNY_URL}"
|
||||
f"?sample={sample_name}"
|
||||
@@ -381,25 +385,20 @@ class TomoIDManager:
|
||||
f"&additional={additional_info}"
|
||||
f"&user={user}"
|
||||
)
|
||||
# subprocess.run(
|
||||
# f"wget --user={self.OMNY_USER} --password={self.OMNY_PASSWORD}"
|
||||
# f" -q -O {self.TMP_FILE} '{url}'",
|
||||
# shell=True,
|
||||
# )
|
||||
# print(url)
|
||||
|
||||
tmp_file = os.path.expanduser(self.TMP_FILE)
|
||||
result = subprocess.run(f"wget -q -O {tmp_file} '{url}'", shell=True)
|
||||
if result.returncode != 0:
|
||||
raise OMNYToolsError(
|
||||
f"wget failed (exit code {result.returncode}) fetching tomo ID from {self.OMNY_URL}"
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(f"wget -q -O {tmp_file} '{url}'", shell=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise OMNYToolsError(
|
||||
f"wget failed (exit code {result.returncode}) fetching tomo ID from {self.OMNY_URL}"
|
||||
)
|
||||
with open(tmp_file) as f:
|
||||
content = f.read().strip()
|
||||
return int(content)
|
||||
except FileNotFoundError as exc:
|
||||
raise OMNYToolsError(f"wget did not produce output file {tmp_file}") from exc
|
||||
except ValueError as exc:
|
||||
raise OMNYToolsError(
|
||||
f"Unexpected response from tomo ID server, got: {content!r}"
|
||||
) from exc
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError, OMNYToolsError) as exc:
|
||||
logger.warning(
|
||||
f"Could not obtain tomo ID from OMNY database ({exc}); "
|
||||
f"falling back to tomo ID {self.FALLBACK_TOMO_ID}."
|
||||
)
|
||||
return self.FALLBACK_TOMO_ID
|
||||
@@ -5,233 +5,424 @@ import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_get, epics_put, fshopen
|
||||
|
||||
logger = bec_logger.logger
|
||||
# import builtins to avoid linter errors
|
||||
|
||||
bec = builtins.__dict__.get("bec")
|
||||
dev = builtins.__dict__.get("dev")
|
||||
scans = builtins.__dict__.get("scans")
|
||||
|
||||
|
||||
def umv(*args):
|
||||
return scans.umv(*args, relative=False)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bec_ipython_client.plugins.omny import OMNY
|
||||
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
|
||||
|
||||
|
||||
# Laminography alignment angles: full 360° in 45° steps
|
||||
LAMNI_ALIGNMENT_ANGLES = [k * 45 for k in range(8)] # [0, 45, 90, 135, 180, 225, 270, 315]
|
||||
|
||||
|
||||
class XrayEyeAlign:
|
||||
# pixel calibration, multiply to get mm
|
||||
PIXEL_CALIBRATION = 0.2 / 218 # .2 with binning
|
||||
"""BEC-GUI-based X-ray eye alignment for LamNI.
|
||||
|
||||
def __init__(self, client, omny: OMNY) -> None:
|
||||
Replaces the old EPICS/LabView interface. Collects sample centre
|
||||
coordinates (x and y) at 8 equally-spaced laminography angles over the
|
||||
full 360°, submits them to the XRayEye widget's fit tab, and then reads
|
||||
the resulting sinusoidal fit parameters back via
|
||||
``lamni.align.read_xray_eye_correction_from_gui()``.
|
||||
|
||||
The key difference from the FlOMNI alignment is that LamNI needs *both*
|
||||
x and y fits because the tilted rotation axis (61°) couples both
|
||||
directions.
|
||||
"""
|
||||
|
||||
# Pixel calibration: multiply pixel coordinate by this to get mm
|
||||
PIXEL_CALIBRATION = 0.2 / 218 # mm/pixel (0.2 mm FOV, 218 px)
|
||||
|
||||
def __init__(self, client, lamni: LamNI) -> None:
|
||||
self.client = client
|
||||
self.omny = omny
|
||||
self.lamni = lamni
|
||||
self.device_manager = client.device_manager
|
||||
self.scans = client.scans
|
||||
self.alignment_values = {}
|
||||
self.omny.reset_correction()
|
||||
self.omny.reset_tomo_alignment_fit()
|
||||
# alignment_values[k] = [x_mm, y_mm]
|
||||
# k=0 : FZP centre
|
||||
# k=1 : sample at 0° (reference; shift_xy computed from k=0 vs k=1)
|
||||
# k=2 : sample at 45°
|
||||
# ...
|
||||
# k=8 : sample at 315°
|
||||
self.alignment_values: dict[int, list[float]] = {}
|
||||
# Reset correction state via the existing alignment object on lamni
|
||||
self.lamni.align.reset_correction()
|
||||
self.lamni.align.reset_xray_eye_correction()
|
||||
|
||||
def _reset_init_values(self):
|
||||
self.shift_xy = [0, 0]
|
||||
self._xray_fov_xy = [0, 0]
|
||||
|
||||
def save_frame(self):
|
||||
epics_put("XOMNYI-XEYE-SAVFRAME:0", 1)
|
||||
|
||||
def update_frame(self):
|
||||
epics_put("XOMNYI-XEYE-ACQDONE:0", 0)
|
||||
# start live
|
||||
epics_put("XOMNYI-XEYE-ACQ:0", 1)
|
||||
# wait for start live
|
||||
while epics_get("XOMNYI-XEYE-ACQDONE:0") == 0:
|
||||
time.sleep(0.5)
|
||||
print("waiting for live view to start...")
|
||||
fshopen()
|
||||
|
||||
epics_put("XOMNYI-XEYE-ACQDONE:0", 0)
|
||||
|
||||
while epics_get("XOMNYI-XEYE-ACQDONE:0") == 0:
|
||||
print("waiting for new frame...")
|
||||
time.sleep(0.5)
|
||||
|
||||
time.sleep(0.5)
|
||||
# stop live view
|
||||
epics_put("XOMNYI-XEYE-ACQ:0", 0)
|
||||
time.sleep(1)
|
||||
# fshclose
|
||||
print("got new frame")
|
||||
|
||||
def tomo_rotate(self, val: float):
|
||||
# pylint: disable=undefined-variable
|
||||
umv(self.device_manager.devices.osamroy, val)
|
||||
|
||||
def get_tomo_angle(self):
|
||||
return self.device_manager.devices.osamroy.readback.get()
|
||||
|
||||
def update_fov(self, k: int):
|
||||
self._xray_fov_xy[0] = max(epics_get(f"XOMNYI-XEYE-XWIDTH_X:{k}"), self._xray_fov_xy[0])
|
||||
self._xray_fov_xy[1] = max(0, self._xray_fov_xy[0])
|
||||
# ------------------------------------------------------------------
|
||||
# GUI shortcut
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def movement_buttons_enabled(self):
|
||||
return [epics_get("XOMNYI-XEYE-ENAMVX:0"), epics_get("XOMNYI-XEYE-ENAMVY:0")]
|
||||
def gui(self):
|
||||
"""Return the live XRayEye RPC handle stored by LamniGuiTools."""
|
||||
return self.lamni.xeyegui
|
||||
|
||||
@movement_buttons_enabled.setter
|
||||
def movement_buttons_enabled(self, enabled: bool):
|
||||
enabled = int(enabled)
|
||||
epics_put("XOMNYI-XEYE-ENAMVX:0", enabled)
|
||||
epics_put("XOMNYI-XEYE-ENAMVY:0", enabled)
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _reset_init_values(self):
|
||||
self.shift_xy = [0.0, 0.0] # base shift to bring sample to beam centre [µm]
|
||||
self._xray_fov_xy = [0.0, 0.0]
|
||||
|
||||
def tomo_rotate(self, val: float):
|
||||
umv(self.device_manager.devices.lsamrot, val)
|
||||
|
||||
def get_tomo_angle(self) -> float:
|
||||
return self.device_manager.devices.lsamrot.readback.read()["lsamrot"]["value"]
|
||||
|
||||
def _disable_rt_feedback(self):
|
||||
self.device_manager.devices.rtx.controller.feedback_disable()
|
||||
|
||||
def _enable_rt_feedback(self):
|
||||
self.device_manager.devices.rtx.controller.feedback_enable_with_reset()
|
||||
|
||||
def update_frame(self, keep_shutter_open: bool = False):
|
||||
"""Capture a fresh camera frame.
|
||||
|
||||
Args:
|
||||
keep_shutter_open: If True the shutter is left open and the GUI
|
||||
stays in live-view mode after the frame is taken. Useful when
|
||||
it is hard to locate the sample between rotations. The caller
|
||||
is responsible for closing the shutter afterwards.
|
||||
"""
|
||||
if not dev.cam_xeye.live_mode_enabled.get():
|
||||
dev.cam_xeye.live_mode_enabled.put(True)
|
||||
self.gui.on_live_view_enabled(True)
|
||||
dev.omnyfsh.fshopen()
|
||||
time.sleep(0.5)
|
||||
if not keep_shutter_open:
|
||||
self.gui.on_live_view_enabled(False)
|
||||
time.sleep(0.1)
|
||||
dev.omnyfsh.fshclose()
|
||||
print("Received new frame.")
|
||||
else:
|
||||
print("Received new frame. Shutter remains open and live view active.")
|
||||
|
||||
def update_fov(self, k: int):
|
||||
self._xray_fov_xy[0] = max(
|
||||
getattr(dev.omny_xray_gui, f"width_x_{k}").get(), self._xray_fov_xy[0]
|
||||
)
|
||||
self._xray_fov_xy[1] = max(
|
||||
getattr(dev.omny_xray_gui, f"width_y_{k}").get(), self._xray_fov_xy[1]
|
||||
)
|
||||
|
||||
def movement_buttons_enabled(self, enable_x: bool, enable_y: bool):
|
||||
self.gui.on_motors_enable(enable_x, enable_y)
|
||||
|
||||
def send_message(self, msg: str):
|
||||
epics_put("XOMNYI-XEYE-MESSAGE:0.DESC", msg)
|
||||
print(f"Alignment GUI: {msg}")
|
||||
self.gui.user_message = msg
|
||||
|
||||
def align(self):
|
||||
# reset shift xy and fov params
|
||||
# ------------------------------------------------------------------
|
||||
# Main alignment procedure
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def align(self, keep_shutter_open: bool = False):
|
||||
"""Run the full LamNI X-ray eye alignment.
|
||||
|
||||
Args:
|
||||
keep_shutter_open: If True the shutter is left open between angle
|
||||
steps so the sample remains visible in live view. At the end
|
||||
of the procedure the user is asked whether to close the shutter.
|
||||
Matches the equivalent FlOMNI option.
|
||||
|
||||
Procedure
|
||||
---------
|
||||
Step 0 – FZP centre
|
||||
Put FZP in, capture frame, user clicks FZP centre.
|
||||
|
||||
Step 1 – Sample at 0°
|
||||
Put sample in, capture frame, user clicks sample centre.
|
||||
Compute base shift (shift_xy) and move to scan centre.
|
||||
|
||||
Steps 2–8 – Sample at 45°, 90°, …, 315°
|
||||
Rotate to each angle (via lamni_move_to_scan_center so the base
|
||||
shift is applied), capture frame, user clicks sample centre.
|
||||
|
||||
After all submits
|
||||
Compute x/y offsets, push to GUI fit tab, wait for DAP fit,
|
||||
then load fit parameters into the global variable store.
|
||||
"""
|
||||
self.lamni.lamnigui_show_xeyealign()
|
||||
self.send_message("Getting things ready. Please wait...")
|
||||
|
||||
self.gui.enable_submit_button(False)
|
||||
|
||||
# Initialise EPICS GUI device state
|
||||
dev.omny_xray_gui.mvx.set(0)
|
||||
dev.omny_xray_gui.mvy.set(0)
|
||||
dev.omny_xray_gui.submit.set(0)
|
||||
|
||||
self.movement_buttons_enabled(False, False)
|
||||
self._reset_init_values()
|
||||
|
||||
self.tomo_rotate(0)
|
||||
epics_put("XOMNYI-XEYE-ANGLE:0", 0)
|
||||
# --- Step 0: FZP centre ------------------------------------------
|
||||
self._disable_rt_feedback()
|
||||
self.lamni.lfzp_in()
|
||||
self._enable_rt_feedback()
|
||||
|
||||
self.omny.oeye_xray_in()
|
||||
self.update_frame(keep_shutter_open)
|
||||
|
||||
self.omny.feedback_enable_with_reset()
|
||||
|
||||
# disable movement buttons
|
||||
self.movement_buttons_enabled = False
|
||||
|
||||
sample_name = dev.omny_samples.get_sample_name_in_samplestage()
|
||||
epics_put("XOMNYI-XEYE-SAMPLENAME:0.DESC", sample_name)
|
||||
|
||||
# this makes sure we are in a defined state
|
||||
self.omny.feedback_disable()
|
||||
|
||||
epics_put("XOMNYI-XEYE-PIXELSIZE:0", self.PIXEL_CALIBRATION)
|
||||
|
||||
osamx_in = self.omny.OMNYTools._get_user_param_safe("osamx", "in")
|
||||
umv(dev.osamx, osamx_in - 0.35)
|
||||
|
||||
self.omny.ofzp_in()
|
||||
self.update_frame()
|
||||
|
||||
# enable submit buttons
|
||||
self.movement_buttons_enabled = False
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", 0)
|
||||
epics_put("XOMNYI-XEYE-STEP:0", 0)
|
||||
self.send_message("Submit center value of FZP.")
|
||||
self.gui.enable_submit_button(True)
|
||||
dev.omny_xray_gui.step.set(0)
|
||||
self.send_message("Submit centre of FZP.")
|
||||
|
||||
k = 0
|
||||
while True:
|
||||
if epics_get("XOMNYI-XEYE-SUBMIT:0") == 1:
|
||||
val_x = epics_get(f"XOMNYI-XEYE-XVAL_X:{k}") / 2 * self.PIXEL_CALIBRATION # in mm
|
||||
self.alignment_values[k] = val_x
|
||||
print(f"Clicked position {k}: x {self.alignment_values[k]}")
|
||||
rtx_position = dev.rtx.readback.get() / 1000
|
||||
print(f"Current rtx position {rtx_position}")
|
||||
self.alignment_values[k] -= rtx_position
|
||||
print(f"Corrected position {k}: x {self.alignment_values[k]}")
|
||||
if dev.omny_xray_gui.submit.get() == 1:
|
||||
val_x = (
|
||||
getattr(dev.omny_xray_gui, f"xval_x_{k}").get()
|
||||
* self.PIXEL_CALIBRATION
|
||||
)
|
||||
val_y = (
|
||||
getattr(dev.omny_xray_gui, f"yval_y_{k}").get()
|
||||
* self.PIXEL_CALIBRATION
|
||||
)
|
||||
self.alignment_values[k] = [val_x, val_y]
|
||||
print(
|
||||
f"Clicked position {k}: "
|
||||
f"x={self.alignment_values[k][0]:.4f} mm, "
|
||||
f"y={self.alignment_values[k][1]:.4f} mm"
|
||||
)
|
||||
dev.omny_xray_gui.submit.set(0)
|
||||
|
||||
if k == 0: # received center value of FZP
|
||||
self.send_message("please wait ...")
|
||||
self.movement_buttons_enabled = False
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", -1) # disable submit button
|
||||
# --- k=0: received FZP centre ----------------------------
|
||||
if k == 0:
|
||||
self.send_message("Please wait - moving sample in...")
|
||||
self.movement_buttons_enabled(False, False)
|
||||
self.gui.enable_submit_button(False)
|
||||
|
||||
self.omny.feedback_disable()
|
||||
osamx_in = self.omny.OMNYTools._get_user_param_safe("osamx", "in")
|
||||
umv(dev.osamx, osamx_in)
|
||||
self.lamni.loptics_out()
|
||||
self._disable_rt_feedback()
|
||||
time.sleep(0.3)
|
||||
self._enable_rt_feedback()
|
||||
|
||||
self.omny.ofzp_out()
|
||||
self.update_frame(keep_shutter_open)
|
||||
self.send_message("Find the sample and submit its centre.")
|
||||
self.gui.enable_submit_button(True)
|
||||
self.movement_buttons_enabled(True, True)
|
||||
|
||||
self.update_frame()
|
||||
epics_put("XOMNYI-XEYE-RECBG:0", 1)
|
||||
while epics_get("XOMNYI-XEYE-RECBG:0") == 1:
|
||||
time.sleep(0.5)
|
||||
print("waiting for background frame...")
|
||||
# --- k=1: sample at 0° - compute base shift --------------
|
||||
elif k == 1:
|
||||
# Displacement from FZP centre to sample centre gives the
|
||||
# correction needed to bring the sample onto the beam axis.
|
||||
# Sign conventions match the old alignment.py.
|
||||
self.shift_xy[0] += (
|
||||
self.alignment_values[0][0] - self.alignment_values[1][0]
|
||||
) * 1000 # um
|
||||
self.shift_xy[1] += (
|
||||
self.alignment_values[1][1] - self.alignment_values[0][1]
|
||||
) * 1000 # um
|
||||
print(
|
||||
f"Base shift: "
|
||||
f"x={self.shift_xy[0]:.2f} um, "
|
||||
f"y={self.shift_xy[1]:.2f} um"
|
||||
)
|
||||
|
||||
umv(dev.osamx, osamx_in)
|
||||
time.sleep(0.5)
|
||||
self.omny.feedback_enable_with_reset()
|
||||
self.send_message("Please wait - moving to scan centre...")
|
||||
self.movement_buttons_enabled(False, False)
|
||||
self.gui.enable_submit_button(False)
|
||||
|
||||
self.update_frame()
|
||||
self.send_message("Adjust sample height and submit center")
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", 0)
|
||||
self.movement_buttons_enabled = True
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
self.shift_xy[0] / 1000,
|
||||
self.shift_xy[1] / 1000,
|
||||
self.get_tomo_angle(),
|
||||
).wait()
|
||||
time.sleep(1)
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
self.shift_xy[0] / 1000,
|
||||
self.shift_xy[1] / 1000,
|
||||
self.get_tomo_angle(),
|
||||
).wait()
|
||||
|
||||
elif 1 <= k < 5: # received sample center value at samroy 0 ... 315
|
||||
self.send_message("please wait ...")
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", -1)
|
||||
self.movement_buttons_enabled = False
|
||||
|
||||
umv(dev.rtx, 0)
|
||||
self.tomo_rotate(k * 45)
|
||||
epics_put("XOMNYI-XEYE-ANGLE:0", self.get_tomo_angle())
|
||||
self.update_frame()
|
||||
self.send_message("Submit sample center")
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", 0)
|
||||
epics_put("XOMNYI-XEYE-ENAMVX:0", 1)
|
||||
dev.omny_xray_gui.angle.set(self.get_tomo_angle())
|
||||
self.update_frame(keep_shutter_open)
|
||||
self.send_message("Submit sample centre and FOV (0 deg).")
|
||||
self.gui.enable_submit_button(True)
|
||||
self.movement_buttons_enabled(True, False)
|
||||
self.update_fov(k)
|
||||
|
||||
elif k == 5: # received sample center value at samroy 270 and done
|
||||
self.send_message("done...")
|
||||
epics_put("XOMNYI-XEYE-SUBMIT:0", -1) # disable submit button
|
||||
self.movement_buttons_enabled = False
|
||||
# --- k=2..8: sample at 45, 90, ..., 315 deg -------------
|
||||
elif 1 < k <= 8:
|
||||
self.send_message("Please wait - rotating...")
|
||||
self.gui.enable_submit_button(False)
|
||||
self.movement_buttons_enabled(False, False)
|
||||
|
||||
target_angle = LAMNI_ALIGNMENT_ANGLES[k - 1] # 45...315 deg
|
||||
# Approach from halfway between previous and current angle
|
||||
# to reduce mechanical hysteresis (same as old alignment.py)
|
||||
prev_angle = LAMNI_ALIGNMENT_ANGLES[k - 2]
|
||||
approach_angle = prev_angle + (target_angle - prev_angle) / 2
|
||||
self._disable_rt_feedback()
|
||||
self.tomo_rotate(approach_angle)
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
self.shift_xy[0] / 1000,
|
||||
self.shift_xy[1] / 1000,
|
||||
self.get_tomo_angle(),
|
||||
).wait()
|
||||
self._disable_rt_feedback()
|
||||
self.tomo_rotate(target_angle)
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
self.shift_xy[0] / 1000,
|
||||
self.shift_xy[1] / 1000,
|
||||
self.get_tomo_angle(),
|
||||
).wait()
|
||||
|
||||
dev.omny_xray_gui.angle.set(self.get_tomo_angle())
|
||||
self.update_frame(keep_shutter_open)
|
||||
self.send_message(f"Submit sample centre ({target_angle} deg).")
|
||||
self.gui.enable_submit_button(True)
|
||||
self.movement_buttons_enabled(True, False)
|
||||
self.update_fov(k)
|
||||
|
||||
# --- k=9: all angles collected - finish ------------------
|
||||
elif k == 9:
|
||||
self.send_message("All angles collected - computing fit...")
|
||||
self.gui.enable_submit_button(False)
|
||||
self.movement_buttons_enabled(False, False)
|
||||
self.update_fov(k)
|
||||
break
|
||||
|
||||
k += 1
|
||||
epics_put("XOMNYI-XEYE-STEP:0", k)
|
||||
|
||||
_xrayeyalignmvx = epics_get("XOMNYI-XEYE-MVX:0")
|
||||
if _xrayeyalignmvx != 0:
|
||||
umvr(dev.rtx, _xrayeyalignmvx)
|
||||
print(f"Current rtx position {dev.rtx.readback.get() / 1000}")
|
||||
epics_put("XOMNYI-XEYE-MVX:0", 0)
|
||||
if k > 0:
|
||||
epics_put(f"XOMNYI-XEYE-STAGEPOSX:{k}", dev.rtx.readback.get() / 1000)
|
||||
time.sleep(3)
|
||||
self.update_frame()
|
||||
dev.omny_xray_gui.step.set(k)
|
||||
|
||||
# Handle live motor movement buttons.
|
||||
# Only active during k=0 (FZP) and k=1 (sample at 0 deg) – both
|
||||
# x and y movements accumulate into shift_xy and are applied via
|
||||
# lamni_move_to_scan_center so the laminography coordinate
|
||||
# transformation is correctly applied. This matches the old
|
||||
# alignment.py exactly.
|
||||
if k < 2:
|
||||
# allow movements, store movements to calculate center
|
||||
_xrayeyalignmvy = epics_get("XOMNYI-XEYE-MVY:0")
|
||||
if _xrayeyalignmvy != 0:
|
||||
self.omny.feedback_disable()
|
||||
umvr(dev.osamy, _xrayeyalignmvy / 1000)
|
||||
time.sleep(2)
|
||||
epics_put("XOMNYI-XEYE-MVY:0", 0)
|
||||
self.omny.feedback_enable_with_reset()
|
||||
self.update_frame()
|
||||
_mvx = dev.omny_xray_gui.mvx.get()
|
||||
_mvy = dev.omny_xray_gui.mvy.get()
|
||||
if _mvx != 0 or _mvy != 0:
|
||||
self.shift_xy[0] += _mvx
|
||||
self.shift_xy[1] += _mvy
|
||||
self.scans.lamni_move_to_scan_center(
|
||||
self.shift_xy[0] / 1000,
|
||||
self.shift_xy[1] / 1000,
|
||||
self.get_tomo_angle(),
|
||||
).wait()
|
||||
print(
|
||||
f"Current centre: "
|
||||
f"horizontal={self.shift_xy[0]:.2f} um, "
|
||||
f"vertical={self.shift_xy[1]:.2f} um"
|
||||
)
|
||||
dev.omny_xray_gui.mvx.set(0)
|
||||
dev.omny_xray_gui.mvy.set(0)
|
||||
self.update_frame(keep_shutter_open)
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# All submits received - compute offsets and push to GUI fit tab
|
||||
# ------------------------------------------------------------------
|
||||
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
|
||||
|
||||
self.tomo_rotate(0)
|
||||
|
||||
umv(dev.rtx, 0)
|
||||
|
||||
# free camera
|
||||
epics_put("XOMNYI-XEYE-ACQ:0", 2)
|
||||
|
||||
print(
|
||||
f"The largest field of view from the xrayeyealign was \nfovx = {fovx:.0f} microns, fovy"
|
||||
f" = {fovy:.0f} microns"
|
||||
f"Largest FOV from X-ray eye alignment:\n"
|
||||
f" fovx = {fovx:.0f} um, fovy = {fovy:.0f} um"
|
||||
)
|
||||
print("Use the matlab routine to FIT the current alignment...")
|
||||
print(
|
||||
f"Base shift (applied to all scan centres):\n"
|
||||
f" x = {self.shift_xy[0]:.2f} um, y = {self.shift_xy[1]:.2f} um"
|
||||
)
|
||||
self.client.set_global_var("tomo_fov_offset", self.shift_xy)
|
||||
|
||||
print("Then LOAD ALIGNMENT PARAMETERS by running omny.read_alignment_offset()\n")
|
||||
# Switch GUI to fit tab and wait for DAP to finish fitting
|
||||
self.lamni.lamnigui_show_xeyealign_fittab()
|
||||
print("Waiting 5 s for DAP sinusoidal fit to converge...")
|
||||
time.sleep(5)
|
||||
|
||||
# Read fit parameters from the GUI into the global variable store
|
||||
print("Loading new alignment parameters from X-ray eye GUI fit.")
|
||||
self.lamni.align.read_xray_eye_correction_from_gui()
|
||||
|
||||
if keep_shutter_open:
|
||||
answer = input("Close the shutter now? [Y/n]: ").strip().lower()
|
||||
if answer in ("", "y", "yes"):
|
||||
dev.omnyfsh.fshclose()
|
||||
self.gui.on_live_view_enabled(False)
|
||||
print("Shutter closed.")
|
||||
else:
|
||||
print("Shutter left open.")
|
||||
|
||||
# Return to 0 deg and reset scan centre
|
||||
self._disable_rt_feedback()
|
||||
self.tomo_rotate(0)
|
||||
umv(dev.rtx, 0)
|
||||
print(
|
||||
"Done. You are ready to remove the X-ray eye and start ptychography scans.\n"
|
||||
"Fine alignment: lamni.tomo_parameters(), then lamni.tomo_alignment_scan()"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fit data preparation and submission
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def write_output(self):
|
||||
"""Compute x/y offsets for each angle and push them to the GUI fit tab.
|
||||
|
||||
Offsets are the displacement of the sample from its position at 0 deg
|
||||
(k=1 is the reference):
|
||||
x_offset[i] = (x_at_0deg - x_at_angle_i) * 1000 [um]
|
||||
y_offset[i] = (y_at_angle_i - y_at_0deg) * 1000 [um]
|
||||
|
||||
The array passed to submit_fit_array has shape (3, 8):
|
||||
row 0: angles [0, 45, ..., 315]
|
||||
row 1: x offsets [um]
|
||||
row 2: y offsets [um]
|
||||
"""
|
||||
# Archival text file (backward compatible with any external scripts)
|
||||
file = os.path.expanduser("~/Data10/specES1/internal/xrayeye_alignmentvalues")
|
||||
if not os.path.exists(file):
|
||||
os.makedirs(os.path.dirname(file), exist_ok=True)
|
||||
with open(file, "w") as alignment_values_file:
|
||||
alignment_values_file.write("angle\thorizontal\n")
|
||||
for k in range(1, 6):
|
||||
fovx_offset = self.alignment_values[0] - self.alignment_values[k]
|
||||
print(f"Writing to file new alignment: number {k}, value x {fovx_offset}")
|
||||
alignment_values_file.write(f"{(k-1)*45}\t{fovx_offset*1000}\n")
|
||||
os.makedirs(os.path.dirname(file), exist_ok=True)
|
||||
with open(file, "w") as f:
|
||||
f.write("angle\thorizontal\tvertical\n")
|
||||
for k in range(1, 9):
|
||||
angle_deg = LAMNI_ALIGNMENT_ANGLES[k - 1]
|
||||
x_off = (
|
||||
self.alignment_values[1][0] - self.alignment_values[k][0]
|
||||
) * 1000
|
||||
y_off = (
|
||||
self.alignment_values[k][1] - self.alignment_values[1][1]
|
||||
) * 1000
|
||||
f.write(f"{angle_deg}\t{x_off:.4f}\t{y_off:.4f}\n")
|
||||
print(
|
||||
f" Angle {angle_deg:3d} deg: "
|
||||
f"x_offset={x_off:.2f} um, y_offset={y_off:.2f} um"
|
||||
)
|
||||
|
||||
angles = np.array(LAMNI_ALIGNMENT_ANGLES, dtype=float)
|
||||
x_offsets = np.array(
|
||||
[
|
||||
(self.alignment_values[1][0] - self.alignment_values[k][0]) * 1000
|
||||
for k in range(1, 9)
|
||||
]
|
||||
)
|
||||
y_offsets = np.array(
|
||||
[
|
||||
(self.alignment_values[k][1] - self.alignment_values[1][1]) * 1000
|
||||
for k in range(1, 9)
|
||||
]
|
||||
)
|
||||
data = np.array([angles, x_offsets, y_offsets])
|
||||
|
||||
# Push to XRayEye widget: feeds waveform_x (row 1) and waveform_y (row 2)
|
||||
self.gui.submit_fit_array(data)
|
||||
print(f"Fit data submitted with shape {data.shape}:\n{data}")
|
||||
@@ -297,4 +297,18 @@ cam_xeye:
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: async
|
||||
readoutPriority: async
|
||||
userParameter:
|
||||
pixel_calibration: 0.0009174311926605505 # mm/pixel (= 0.2 / 218)
|
||||
|
||||
############################################################
|
||||
#################### GUI Signals ###########################
|
||||
############################################################
|
||||
omny_xray_gui:
|
||||
description: Gui signals
|
||||
deviceClass: csaxs_bec.devices.omny.xray_epics_gui.OMNYXRayAlignGUI
|
||||
deviceConfig: {}
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: on_request
|
||||
@@ -16,6 +16,18 @@
|
||||
# omny_panda, omny_xray_gui.
|
||||
############################################################
|
||||
|
||||
############################################################
|
||||
#################### GUI Signals ###########################
|
||||
############################################################
|
||||
omny_xray_gui:
|
||||
description: Gui signals
|
||||
deviceClass: csaxs_bec.devices.omny.xray_epics_gui.OMNYXRayAlignGUI
|
||||
deviceConfig: {}
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: on_request
|
||||
|
||||
############################################################
|
||||
#################### flOMNI Galil motors ###################
|
||||
############################################################
|
||||
|
||||
@@ -136,7 +136,7 @@ lsamrot:
|
||||
sign: 1
|
||||
sim_stppermm: 50154.32099
|
||||
sim_encpermm: 36000
|
||||
sim_velocity: 12
|
||||
sim_velocity: 48 #4x than hw
|
||||
sim_initial_position: 0
|
||||
deviceTags:
|
||||
- lamni
|
||||
@@ -328,7 +328,8 @@ cam_xeye:
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: async
|
||||
|
||||
userParameter:
|
||||
pixel_calibration: 0.0009174311926605505 # mm/pixel (= 0.2 / 218)
|
||||
ddg1:
|
||||
description: Simulated main delay generator for triggering
|
||||
deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.SimulatedDDG1
|
||||
@@ -347,3 +348,15 @@ fsh:
|
||||
onFailure: raise
|
||||
enabled: true
|
||||
readoutPriority: monitored
|
||||
|
||||
############################################################
|
||||
#################### GUI Signals ###########################
|
||||
############################################################
|
||||
omny_xray_gui:
|
||||
description: Gui signals
|
||||
deviceClass: csaxs_bec.devices.omny.xray_epics_gui.OMNYXRayAlignGUI
|
||||
deviceConfig: {}
|
||||
enabled: true
|
||||
onFailure: buffer
|
||||
readOnly: false
|
||||
readoutPriority: on_request
|
||||
@@ -13,7 +13,8 @@ class OMNYXRayAlignGUI(Device):
|
||||
sample_name = Cpt(Signal, value=0)
|
||||
angle = Cpt(Signal, value=0)
|
||||
pixel_size = Cpt(Signal, value=0)
|
||||
submit = Cpt(EpicsSignal, name="submit", read_pv="XOMNYI-XEYE-SUBMIT:0", auto_monitor=True)
|
||||
submit = Cpt(Signal, value=0)
|
||||
# submit = Cpt(EpicsSignal, name="submit", read_pv="XOMNYI-XEYE-SUBMIT:0", auto_monitor=True)
|
||||
step = Cpt(Signal, value=0)
|
||||
recbg = Cpt(Signal, value=0)
|
||||
mvx = Cpt(Signal, value=0)
|
||||
|
||||
@@ -180,28 +180,32 @@ class LamNIMoveToScanCenter(RequestBase, LamNIMixin):
|
||||
scan_name = "lamni_move_to_scan_center"
|
||||
scan_report_hint = None
|
||||
scan_type = "step"
|
||||
required_kwargs = []
|
||||
arg_input = {
|
||||
"shift_x": ScanArgType.FLOAT,
|
||||
"shift_y": ScanArgType.FLOAT,
|
||||
"angle": ScanArgType.FLOAT,
|
||||
}
|
||||
arg_bundle_size = {"bundle": len(arg_input), "min": 1, "max": 1}
|
||||
required_kwargs = ["shift_x", "shift_y", "angle"]
|
||||
arg_input = {}
|
||||
arg_bundle_size = {"bundle": 0, "min": 0, "max": 0}
|
||||
|
||||
def __init__(self, *args, parameter=None, **kwargs):
|
||||
"""
|
||||
Move LamNI to a new scan center.
|
||||
|
||||
Args:
|
||||
*args: shift x, shift y, tomo angle in deg
|
||||
shift_x (float): shift x in mm
|
||||
shift_y (float): shift y in mm
|
||||
angle (float): tomo angle in degrees
|
||||
|
||||
Examples:
|
||||
>>> scans.lamni_move_to_scan_center(1.2, 2.8, 12.5)
|
||||
>>> scans.lamni_move_to_scan_center(shift_x=1.2, shift_y=2.8, angle=12.5)
|
||||
"""
|
||||
super().__init__(parameter=parameter, **kwargs)
|
||||
scan_kwargs = parameter.get("kwargs", {})
|
||||
self.shift_x = float(scan_kwargs.get("shift_x", 0))
|
||||
self.shift_y = float(scan_kwargs.get("shift_y", 0))
|
||||
self.angle = float(scan_kwargs.get("angle", 0))
|
||||
|
||||
def run(self):
|
||||
center_x, center_y = self._lamni_compute_scan_center(*self.caller_args)
|
||||
center_x, center_y = self._lamni_compute_scan_center(
|
||||
self.shift_x, self.shift_y, self.angle
|
||||
)
|
||||
yield from self.lamni_new_scan_center_interferometer(center_x, center_y)
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
(user.ptychography.lamni)=
|
||||
|
||||
# LamNI
|
||||
|
||||
LamNI is an instrument for 3D ptychography via ptychographic X-ray computed lamninography (PyXL). The instrument is described in detail [here](https://www.dora.lib4ri.ch/psi/islandora/object/psi:33067).
|
||||
LamNI is an instrument for 3D ptychography via ptychographic X-ray computed laminography (PyXL). The instrument is described in detail [here](https://www.dora.lib4ri.ch/psi/islandora/object/psi:33067).
|
||||
|
||||
## How to LamNI
|
||||
## How to LamNI
|
||||
|
||||
… a step-by-step guide for _beamline staff and expert users_.
|
||||
… a step-by-step guide for *beamline staff and expert users*.
|
||||
|
||||
### Sample change and alignment
|
||||
|
||||
@@ -17,70 +18,89 @@ Mount the new sample. The X-ray eye is already in, but the X-ray optics needs to
|
||||
|
||||
#### Coarse axis alignment
|
||||
|
||||
The effective position of the axis of rotation shifts with sample thickness or mounting position of the sample along the axis of rotation. The position of the axis of rotation is controlled by user parameters __center__ of the __lsamx__ and __lsamy__ stages. To observe the axis of rotation obtain the position of the Fresnel zone plate on the X-ray eye, possibly in the _ueye gui_ by:
|
||||
The effective position of the axis of rotation shifts with sample thickness or mounting position of the sample along the axis of rotation. The position of the axis of rotation is controlled by user parameters **center** of the **lsamx** and **lsamy** stages. To observe the axis of rotation obtain the position of the Fresnel zone plate on the X-ray eye, possibly in the *ueye gui* by:
|
||||
|
||||
1. `lamni.lfzp_in()`, move the FZP in
|
||||
1. `dev.rtx.controller.feedback_disable()`, disable feedback to allow lsam movements
|
||||
1. `fshopen()`, open the shutter
|
||||
1. `umv(dev.lsamrot,90)` to rotate the sample. One might observe the center of rotation at 0 and 180 degress.
|
||||
1. `umvr(dev.lsamx,0.01)` to move lsamx and lsamy such that the center of rotation is at the center of the X-ray beam
|
||||
1. `dev.lsamx` and `dev.lsamy` will print current position and the center value. Update the center value by
|
||||
`dev.lsamx.update_user_parameter({'center':8.69})`
|
||||
`dev.lsamy.update_user_parameter({'center':8.69})`
|
||||
1. close the shutter: `dev.omnyfsh.fshclose()`
|
||||
2. `dev.rtx.controller.feedback_disable()`, disable feedback to allow lsam movements
|
||||
3. `fshopen()`, open the shutter
|
||||
4. `umv(dev.lsamrot,90)` to rotate the sample. One might observe the center of rotation at 0 and 180 degrees.
|
||||
5. `umvr(dev.lsamx,0.01)` to move lsamx and lsamy such that the center of rotation is at the center of the X-ray beam
|
||||
6. `dev.lsamx` and `dev.lsamy` will print current position and the center value. Update the center value by
|
||||
`dev.lsamx.update_user_parameter({'center':8.69})`
|
||||
`dev.lsamy.update_user_parameter({'center':8.69})`
|
||||
7. close the shutter: `dev.omnyfsh.fshclose()`
|
||||
|
||||
#### X-ray eye alignment
|
||||
|
||||
The GUI on the windows computer is used to obtain a coarse sample alignment. Start the alignment process (and clear any previous alignment) by
|
||||
`lamni.align.align()`. With LamNI it can be very difficult to follow a region of interest as the sample rotates. Therefore the X-ray shutter will be open during the entire process. Therefore the windows software has to be set on __FORCE__ to continuously update frames and not freeze frames after rotation.
|
||||
- run `SPEC_ptycho_align.m` (in matlab, use __force_ptychography = 0__)
|
||||
- `lamni.align.read_xray_eye_correction()` to read the alignment parameters. The correction is based on sinusoidal fits in x and y direction. The values are computed by
|
||||
`lamni_compute_additional_correction_xeye_mu(angle)`
|
||||
- If slits were opened during alignment, close the slits `slits 1 to around 0.3`
|
||||
- `lamni.leye_out()` remove the X-ray eye and move the flight tube in
|
||||
- _possibly check slit0wh, idgap_
|
||||
The BEC GUI is used to obtain a coarse sample alignment. Start the alignment process (which clears any previous alignment) by
|
||||
`lamni.xrayeye_alignment_start()`
|
||||
|
||||
To only see one frame on the Windows GUI run `lamni.align.update_frame()`
|
||||
This opens the X-ray eye widget automatically. The procedure collects the sample centre position at 8 angles (0°–315° in 45° steps, full 360° rotation). At each angle the user clicks the sample centre in the image and presses **Submit**. After all 8 angles the data is sent to the **Fit** tab of the GUI where a sinusoidal fit runs automatically in both x and y directions. The fit parameters are loaded automatically at the end of the procedure.
|
||||
|
||||
With LamNI it can be difficult to relocate the sample between rotations. To keep the shutter open throughout, pass:
|
||||
`lamni.xrayeye_alignment_start(keep_shutter_open=True)`
|
||||
|
||||
To manually reload the fit parameters after the procedure has completed:
|
||||
`lamni.read_xray_eye_correction_from_gui()`
|
||||
**Note:** this reads from the live GUI widget via the `omny_xray_gui` device. It only works as long as the XRayEye GUI window remains open. If the window has been closed, reload from the archived text files instead:
|
||||
`lamni.read_xray_eye_correction()`
|
||||
(these files are written to `~/Data10/specES1/internal/xrayeye_alignmentvalues` at the end of every alignment run)
|
||||
|
||||
The correction is applied at each projection angle via
|
||||
`lamni.lamni_compute_additional_correction_xeye_mu(angle)`
|
||||
which is called automatically inside `lamni.tomo_scan_projection()`.
|
||||
|
||||
To capture a single fresh frame without running the full alignment:
|
||||
`lamni.xrayeye_update_frame()`
|
||||
or with the shutter left open: `lamni.xrayeye_update_frame(keep_shutter_open=True)`
|
||||
|
||||
* If slits were opened during alignment, close the slits: `slits 1` to around 0.3
|
||||
* `lamni.leye_out()` remove the X-ray eye and move the flight tube in
|
||||
* *possibly check slit0wh, idgap*
|
||||
|
||||
#### Fine alignment
|
||||
|
||||
The sample fine alignment can be obtained using ptychography. For this a short laminogram has to be recorded.
|
||||
- `lamni.tomo_parameters()` adjust the parameters for a coarse scan: A large step size and large FOV. Especially select __FOV offset = 0__ and __number of projections = 96__ (only one sub-laminogram will be recorded).
|
||||
- `lamni.sub_tomo_scan(1,0)` record one sub-laminogram
|
||||
- use the corresponding scan numbers in `SPEC_ptycho_align.m`
|
||||
- Record a last projection for all scans to reconstruct `lamni.tomo_scan_projection(0)` and wait for the reconstructions to be complete
|
||||
- Run `SPEC_ptycho_align.m` (in Matlab, __force ptycho=1__, and __correct scan numbers__)
|
||||
- Click the sample position in the Matlab GUI and then load the generated file by, for example
|
||||
`lamni.align.read_additional_correction('/sls/X12SA/data/e20632/Data10/cxs_software/ptycho/correction_lamni_um_S05389_lamni_fit.txt')`
|
||||
- With this alignment a second iteration could be performed. To read the second correction file use `lamni.align.read_additional_correction_2()`
|
||||
|
||||
* `lamni.tomo_parameters()` adjust the parameters for a coarse scan: A large step size and large FOV. Especially select **FOV offset = 0** and **number of projections = 96** (only one sub-laminogram will be recorded).
|
||||
* `lamni.sub_tomo_scan(1,0)` record one sub-laminogram
|
||||
* use the corresponding scan numbers in `SPEC_ptycho_align.m`
|
||||
* Record a last projection for all scans to reconstruct `lamni.tomo_scan_projection(0)` and wait for the reconstructions to be complete
|
||||
* Run `SPEC_ptycho_align.m` (in Matlab, **force ptycho=1**, and **correct scan numbers**)
|
||||
* Click the sample position in the Matlab GUI and then load the generated file by, for example
|
||||
`lamni.read_additional_correction('/sls/X12SA/data/e20632/Data10/cxs_software/ptycho/correction_lamni_um_S05389_lamni_fit.txt')`
|
||||
* With this alignment a second iteration could be performed. To read the second correction file use `lamni.read_additional_correction_2()`
|
||||
|
||||
#### Shifting the FOV
|
||||
|
||||
- `lamni.align.tomo_fovx/y_offset=value` [mm] will shift the field of view. Perform this adjustment from projections collected at __lsamrot 0 degrees__.
|
||||
* `lamni.tomo_fovx/y_offset=value` [mm] will shift the field of view. Perform this adjustment from projections collected at **lsamrot 0 degrees**. This shift will rotate. In contrast the manual shift will be a constant shift, identical at all angles.
|
||||
|
||||
### Laminography scan
|
||||
|
||||
Start the laminography scan by
|
||||
|
||||
1. `lamni.tomo_parameters()` adjust the parameters to the desired settings.
|
||||
1. for test scans run
|
||||
`lamni.tomo_scan_projection(angle)`
|
||||
`lamni.tomo_reconstruct()`
|
||||
1. `lamni.tomo_scan()` to start the lamninography scan
|
||||
2. for test scans run
|
||||
`lamni.tomo_scan_projection(angle)`
|
||||
`lamni.tomo_reconstruct()`
|
||||
3. `lamni.tomo_scan()` to start the laminography scan
|
||||
|
||||
### Tips and Tricks
|
||||
|
||||
#### Reset corrections
|
||||
|
||||
- `lamni.align.reset_correction()`
|
||||
- `lamni.align.reset_correction_2()`
|
||||
- `lamni.align.reset_xray_eye_correction()`
|
||||
* `lamni.reset_correction()`
|
||||
* `lamni.reset_correction_2()`
|
||||
* `lamni.reset_xray_eye_correction()`
|
||||
|
||||
#### Adjusting beam size with feedback running
|
||||
|
||||
If the beam size needs to be changed with feedback running, e.g. to switch from near-field to far-field ptychography, following steps can be taken:
|
||||
If the beam size needs to be changed with feedback running, e.g. to switch from near-field to far-field ptychography, following steps can be taken:
|
||||
|
||||
1. `dev.loptz.enable_set=True` to enable loptz movements with feedback running
|
||||
1. `umvr(dev.lopz,_value_)` move loptz to the desired position.
|
||||
1. `lamni._manual_shift_x/y = _value_` correct the stage run out from motion along the optical axis (units of um). The exact value can be checked by comparing feature positions in projections before/after adjusting loptz.
|
||||
1. Potentially correct the alignment of the OSA
|
||||
2. `umvr(dev.lopz,_value_)` move loptz to the desired position.
|
||||
3. `lamni._manual_shift_x/y = _value_` correct the stage run out from motion along the optical axis (units of um). The exact value can be checked by comparing feature positions in projections before/after adjusting loptz.
|
||||
4. Potentially correct the alignment of the OSA
|
||||
|
||||
#### BEC tips
|
||||
|
||||
@@ -97,8 +117,8 @@ This part of the manual describes the software structure in more detail.
|
||||
The nano-positioning is controlled by a feedback loop running on a real-time linux based computer. With all related hardware connected, this loop has to be started manually.
|
||||
|
||||
1. Login to the computer by `ssh control@mpc2680`. The password is written on the physical machine.
|
||||
1. `cd OMNY/lamni/`
|
||||
1. `./startLAMNI`
|
||||
2. `cd OMNY/lamni/`
|
||||
3. `./startLAMNI`
|
||||
|
||||
Once the loop has started, it is possible to start bec with the LamNI configuration file.
|
||||
|
||||
@@ -123,60 +143,64 @@ The stages of LamNI are referenced in respect to their endswitches or reference
|
||||
Show the status of all galil controllers (all stepper motors and the UPR rotation stage)
|
||||
`dev.lsamx.controller.galil_show_all()`
|
||||
|
||||
The same holds true for the Smaract stages which control the OSA position. Their status can be checked by
|
||||
The same holds true for the Smaract stages which control the OSA position. Their status can be checked by
|
||||
`dev.losax.controller.show_all()`
|
||||
|
||||
In case referencing of the LamNI stages is required, run
|
||||
`lamni.init.lamni_init_stages()`
|
||||
This script will first verify that the stages are not in an initialized state, and then reference all stages in a safe way. The user will be warned in case of a potentially risky situation. This mainly involves a collision risk upstream with the exposure box exit window. It might be worth to check clearance prior to calling the init skript.
|
||||
This script will first verify that the stages are not in an initialized state, and then reference all stages in a safe way. The user will be warned in case of a potentially risky situation. This mainly involves a collision risk upstream with the exposure box exit window. It might be worth to check clearance prior to calling the init script.
|
||||
|
||||
### Interferometer
|
||||
|
||||
The position feedback in LamNI is controlled in closed loop to an interferometric position measurement. To show the signal of the interferometers:
|
||||
`lamni.show_signal_strength_interferometer()`
|
||||
Typical values with proper alignment are
|
||||
_TODO_
|
||||
*TODO*
|
||||
|
||||
#### Interferometer feedback commands
|
||||
|
||||
- `dev.rtx.feedback_enable_with_reset()`
|
||||
- `dev.rtx.feedback_disable()`
|
||||
- `dev.rtx.feedback_enable_without_reset()` *is only used internally by lamni methods
|
||||
- if reset of angle interferometer is required
|
||||
`dev.rtx.feedback_disable_and_even_reset_lamni_angle_interferometer()`
|
||||
- `dev.rtx.feedback_enable_with_reset()`
|
||||
* `dev.rtx.feedback_enable_with_reset()`
|
||||
* `dev.rtx.feedback_disable()`
|
||||
* `dev.rtx.feedback_enable_without_reset()` \*is only used internally by lamni methods
|
||||
* if reset of angle interferometer is required
|
||||
`dev.rtx.feedback_disable_and_even_reset_lamni_angle_interferometer()`
|
||||
* `dev.rtx.feedback_enable_with_reset()`
|
||||
|
||||
_ToDo Feedback status might be helpful. Plus make accessible via lamni.methods…_
|
||||
*ToDo Feedback status might be helpful. Plus make accessible via lamni.methods…*
|
||||
|
||||
### Scanning in 2D and sample alignment
|
||||
|
||||
The underlying scan function can be called as
|
||||
`scans.lamni_fermat_scan()`
|
||||
|
||||
Use `scans.lamni_fermat_scan?`for detailed information. A prerequisite for scanning is a running feedback system.
|
||||
Use `scans.lamni_fermat_scan?` for detailed information. A prerequisite for scanning is a running feedback system.
|
||||
|
||||
### GUI tools
|
||||
|
||||
During operation the BEC GUI will show the relevant cameras or progress information. To manually switch view TAB completion on 'lamni.lamnigui_' will show all options to control the GUI. Most useful
|
||||
'lamni.lamnigui_show_progress()' will show the measurement progress GUI
|
||||
'lamnigui_show_xeyealign()' will show the XrayEye alignment GUI
|
||||
During operation the BEC GUI will show the relevant cameras or progress information. TAB completion on `lamni.lamnigui_` will show all options to control the GUI. Most useful:
|
||||
|
||||
* `lamni.lamnigui_show_progress()` — shows the measurement progress with ring progress bar
|
||||
* `lamni.lamnigui_show_xeyealign()` — opens the X-ray eye alignment GUI on the Alignment tab
|
||||
* `lamni.lamnigui_show_xeyealign_fittab()` — opens the X-ray eye GUI on the Fit tab to inspect the sinusoidal fit
|
||||
* `lamni.lamnigui_idle()` — shows the LamNI idle splash screen
|
||||
* `lamni.lamnigui_docs()` — opens the PDF documentation viewer
|
||||
|
||||
### X-ray optics alignment
|
||||
|
||||
The positions of the optics stages are stored as stage parameters and are thus linked to the configuration file.
|
||||
Example: The OSAx “in” position can be reviewed by `dev.losax.user_parameter`
|
||||
Example: The OSAx "in" position can be reviewed by `dev.losax.user_parameter`
|
||||
Update the value by (example "losax", "in") by `dev.losax.update_user_parameter({"in":value})`
|
||||
|
||||
`lamni.lfzp_info()` shows info about the available FZPs at the current energy of the beamline. Optional parameter is the photon _energy_ in keV.
|
||||
`lamni.lfzp_info()` shows info about the available FZPs at the current energy of the beamline. Optional parameter is the photon *energy* in keV.
|
||||
Example: `lamni.lfzp_info(6.2)`
|
||||
|
||||
The laser feedback will be disabled and fine alignment lost if foptx/y are moved!
|
||||
|
||||
Following functions exist to move the optics in and out, the naming is self-explaining.
|
||||
- `lamni.lfzp_in()`
|
||||
- `lamni.loptics_in()`
|
||||
- `lamni.loptics_out()`
|
||||
- `lamni.losa_in()`
|
||||
- `lamni.losa_out()`
|
||||
- `lamni.lfzp_in()`
|
||||
|
||||
* `lamni.lfzp_in()`
|
||||
* `lamni.loptics_in()`
|
||||
* `lamni.loptics_out()`
|
||||
* `lamni.losa_in()`
|
||||
* `lamni.losa_out()`
|
||||
* `lamni.lfzp_in()`
|
||||
|
||||
@@ -214,7 +214,7 @@ This part of the manual describes the software structure in more detail.
|
||||
|
||||
The nano-positioning is controlled by a feedback loop running on a real-time linux based computer. With all related hardware connected, this loop has to be started manually.
|
||||
|
||||
1. Login to the computer by `ssh control@mpc3217`. The password is "engine".
|
||||
1. Login to the computer by `ssh control@mpc3217`. The password is written on the physical machine.
|
||||
1. `cd OMNY/OMNY/`
|
||||
1. `./startOMNY`
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ from unittest import mock
|
||||
|
||||
from bec_lib.device import DeviceBase
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.LamNI import LamNI, XrayEyeAlign
|
||||
from csaxs_bec.bec_ipython_client.plugins.LamNI import LamNI
|
||||
from csaxs_bec.bec_ipython_client.plugins.LamNI.x_ray_eye_align import XrayEyeAlign
|
||||
|
||||
# pylint: disable=unused-import
|
||||
|
||||
@@ -12,6 +13,8 @@ from csaxs_bec.bec_ipython_client.plugins.LamNI import LamNI, XrayEyeAlign
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=protected-access
|
||||
|
||||
XRAY_EYE_ALIGN = "csaxs_bec.bec_ipython_client.plugins.LamNI.x_ray_eye_align"
|
||||
|
||||
|
||||
class RTControllerMock:
|
||||
def feedback_disable(self):
|
||||
@@ -26,56 +29,54 @@ class RTMock(DeviceBase):
|
||||
enabled = True
|
||||
|
||||
|
||||
def test_save_frame(bec_client_mock):
|
||||
client = bec_client_mock
|
||||
def _make_align(client):
|
||||
client.device_manager.devices.xeye = DeviceBase(
|
||||
name="xeye",
|
||||
config={"enabled": True, "deviceClass": "test_class", "readoutPriority": "baseline"},
|
||||
)
|
||||
lamni = LamNI(client)
|
||||
align = XrayEyeAlign(client, lamni)
|
||||
with mock.patch(
|
||||
"csaxs_bec.bec_ipython_client.plugins.LamNI.alignment.epics_put"
|
||||
) as epics_put_mock:
|
||||
align.save_frame()
|
||||
epics_put_mock.assert_called_once_with("XOMNYI-XEYE-SAVFRAME:0", 1)
|
||||
|
||||
return XrayEyeAlign(client, lamni)
|
||||
|
||||
def test_update_frame(bec_client_mock):
|
||||
epics_put = "csaxs_bec.bec_ipython_client.plugins.LamNI.alignment.epics_put"
|
||||
epics_get = "csaxs_bec.bec_ipython_client.plugins.LamNI.alignment.epics_get"
|
||||
fshopen = "csaxs_bec.bec_ipython_client.plugins.LamNI.alignment.fshopen"
|
||||
client = bec_client_mock
|
||||
client.device_manager.devices.xeye = DeviceBase(
|
||||
name="xeye",
|
||||
config={"enabled": True, "deviceClass": "test_class", "readoutPriority": "baseline"},
|
||||
)
|
||||
lamni = LamNI(client)
|
||||
align = XrayEyeAlign(client, lamni)
|
||||
with mock.patch(epics_put) as epics_put_mock:
|
||||
with mock.patch(epics_get) as epics_get_mock:
|
||||
with mock.patch(fshopen) as fshopen_mock:
|
||||
align.update_frame()
|
||||
epics_put_mock.assert_has_calls(
|
||||
[
|
||||
mock.call("XOMNYI-XEYE-ACQDONE:0", 0),
|
||||
mock.call("XOMNYI-XEYE-ACQ:0", 1),
|
||||
mock.call("XOMNYI-XEYE-ACQDONE:0", 0),
|
||||
mock.call("XOMNYI-XEYE-ACQ:0", 0),
|
||||
]
|
||||
)
|
||||
fshopen_mock.assert_called_once()
|
||||
epics_get_mock.assert_called_with("XOMNYI-XEYE-ACQDONE:0")
|
||||
align = _make_align(client)
|
||||
align.lamni.xeyegui = mock.MagicMock()
|
||||
|
||||
dev_mock = mock.MagicMock()
|
||||
dev_mock.cam_xeye.live_mode_enabled.get.return_value = False
|
||||
|
||||
with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock):
|
||||
with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"):
|
||||
align.update_frame()
|
||||
|
||||
dev_mock.cam_xeye.live_mode_enabled.put.assert_called_once_with(True)
|
||||
dev_mock.fsh.fshopen.assert_called_once()
|
||||
dev_mock.fsh.fshclose.assert_called_once()
|
||||
dev_mock.cam_xeye.get_last_image.assert_called_once()
|
||||
assert len(align.alignment_images) == 1
|
||||
align.gui.on_live_view_enabled.assert_has_calls([mock.call(True), mock.call(False)])
|
||||
|
||||
|
||||
def test_update_frame_keeps_shutter_open(bec_client_mock):
|
||||
client = bec_client_mock
|
||||
align = _make_align(client)
|
||||
align.lamni.xeyegui = mock.MagicMock()
|
||||
|
||||
dev_mock = mock.MagicMock()
|
||||
dev_mock.cam_xeye.live_mode_enabled.get.return_value = True
|
||||
|
||||
with mock.patch(f"{XRAY_EYE_ALIGN}.dev", dev_mock):
|
||||
with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"):
|
||||
align.update_frame(keep_shutter_open=True)
|
||||
|
||||
dev_mock.cam_xeye.live_mode_enabled.put.assert_not_called()
|
||||
dev_mock.fsh.fshopen.assert_called_once()
|
||||
dev_mock.fsh.fshclose.assert_not_called()
|
||||
align.gui.on_live_view_enabled.assert_called_once_with(True)
|
||||
|
||||
def test_disable_rt_feedback(bec_client_mock):
|
||||
client = bec_client_mock
|
||||
client.device_manager.devices.xeye = DeviceBase(
|
||||
name="xeye",
|
||||
config={"enabled": True, "deviceClass": "test_class", "readoutPriority": "baseline"},
|
||||
)
|
||||
lamni = LamNI(client)
|
||||
align = XrayEyeAlign(client, lamni)
|
||||
align = _make_align(client)
|
||||
client.device_manager.devices.rtx = RTMock(
|
||||
name="rtx",
|
||||
config={"enabled": True, "deviceClass": "test_class", "readoutPriority": "baseline"},
|
||||
@@ -89,12 +90,7 @@ def test_disable_rt_feedback(bec_client_mock):
|
||||
|
||||
def test_enable_rt_feedback(bec_client_mock):
|
||||
client = bec_client_mock
|
||||
client.device_manager.devices.xeye = DeviceBase(
|
||||
name="xeye",
|
||||
config={"enabled": True, "deviceClass": "test_class", "readoutPriority": "baseline"},
|
||||
)
|
||||
lamni = LamNI(client)
|
||||
align = XrayEyeAlign(client, lamni)
|
||||
align = _make_align(client)
|
||||
client.device_manager.devices.rtx = RTMock(
|
||||
name="rtx",
|
||||
config={"enabled": True, "deviceClass": "test_class", "readoutPriority": "baseline"},
|
||||
@@ -107,23 +103,12 @@ def test_enable_rt_feedback(bec_client_mock):
|
||||
|
||||
|
||||
def test_tomo_rotate(bec_client_mock):
|
||||
import builtins
|
||||
|
||||
client = bec_client_mock
|
||||
client._ip = mock.MagicMock()
|
||||
client._update_namespace_callback = mock.MagicMock()
|
||||
client.callbacks = mock.MagicMock()
|
||||
client.load_high_level_interface("bec_hli")
|
||||
client.device_manager.devices.xeye = DeviceBase(
|
||||
name="xeye",
|
||||
config={"enabled": True, "deviceClass": "test_class", "readoutPriority": "baseline"},
|
||||
)
|
||||
lamni = LamNI(client)
|
||||
align = XrayEyeAlign(client, lamni)
|
||||
align = _make_align(client)
|
||||
client.device_manager.devices.lsamrot = RTMock(
|
||||
name="lsamrot",
|
||||
config={"enabled": True, "deviceClass": "test_class", "readoutPriority": "baseline"},
|
||||
)
|
||||
with mock.patch.object(builtins, "umv") as umv:
|
||||
with mock.patch(f"{XRAY_EYE_ALIGN}.umv") as umv:
|
||||
align.tomo_rotate(5)
|
||||
umv.assert_called_once_with(client.device_manager.devices.lsamrot, 5)
|
||||
umv.assert_called_once_with(client.device_manager.devices.lsamrot, 5)
|
||||
Reference in New Issue
Block a user