adjust gripper sensor voltage in config #232
@@ -19,6 +19,16 @@ class cSAXSSlits:
|
||||
User-visible slits namespace.
|
||||
"""
|
||||
|
||||
# device name prefix -> human-readable label
|
||||
_SLIT_LABELS = {
|
||||
"sl1": "Slit 1 (frontend)",
|
||||
"sl2": "Slit 2 (optics, slit 1)",
|
||||
"sl3": "Slit 3 (optics, slit 2)",
|
||||
"sl4": "Slit 4 (exposure box 1, entrance)",
|
||||
"sl5": "Slit 5 (exposure box 1, exit)",
|
||||
"sl6": "Slit 6 (exposure box 2)",
|
||||
}
|
||||
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
self.device_manager = client.device_manager
|
||||
@@ -28,27 +38,20 @@ class cSAXSSlits:
|
||||
# --------------------------------------------------------
|
||||
|
||||
def _sl_get_position(self, device_name: str) -> Optional[float]:
|
||||
"""
|
||||
Safely return the position of a device or None.
|
||||
"""
|
||||
try:
|
||||
device = self.device_manager.devices[device_name]
|
||||
except Exception:
|
||||
return None
|
||||
"""
|
||||
Safely return the position of a device or None.
|
||||
"""
|
||||
try:
|
||||
device = self.device_manager.devices[device_name]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
if hasattr(device, "readback"):
|
||||
return device.readback.get()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if hasattr(device, "get"):
|
||||
return device.get()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
try:
|
||||
reading = device.read()
|
||||
value = reading[device_name]["value"]
|
||||
return float(value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _sl_fmt(value: Optional[float]) -> str:
|
||||
@@ -62,53 +65,27 @@ class cSAXSSlits:
|
||||
|
||||
def slits_show_all(self):
|
||||
"""
|
||||
Show all slits with center and width.
|
||||
Show all slits with x/y center and size.
|
||||
"""
|
||||
|
||||
print("")
|
||||
print("========== cSAXS Slits Overview ==========")
|
||||
print("")
|
||||
|
||||
# Slit 1
|
||||
print("Slit 1 (frontend):")
|
||||
|
||||
ch = self._sl_get_position("sl1xc")
|
||||
wh = self._sl_get_position("sl1xs")
|
||||
cv = self._sl_get_position("sl1yc")
|
||||
wv = self._sl_get_position("sl1ys")
|
||||
|
||||
print(
|
||||
f" center horizontal = {self._sl_fmt(ch)} "
|
||||
f"width horizontal = {self._sl_fmt(wh)}"
|
||||
)
|
||||
print(
|
||||
f" center vertical = {self._sl_fmt(cv)} "
|
||||
f"width vertical = {self._sl_fmt(wv)}"
|
||||
)
|
||||
print("")
|
||||
|
||||
# Slits 3–5
|
||||
slit_map = {
|
||||
3: "exposure box 2 entrance",
|
||||
4: "exposure box 2 exit",
|
||||
5: "exposure box 3",
|
||||
}
|
||||
|
||||
for slit, label in slit_map.items():
|
||||
print(f"Slit {slit} ({label}):")
|
||||
|
||||
ch = self._sl_get_position(f"sl{slit}ch")
|
||||
wh = self._sl_get_position(f"sl{slit}wh")
|
||||
cv = self._sl_get_position(f"sl{slit}cv")
|
||||
wv = self._sl_get_position(f"sl{slit}wv")
|
||||
for prefix, label in self._SLIT_LABELS.items():
|
||||
xc = self._sl_get_position(f"{prefix}xc")
|
||||
xs = self._sl_get_position(f"{prefix}xs")
|
||||
yc = self._sl_get_position(f"{prefix}yc")
|
||||
ys = self._sl_get_position(f"{prefix}ys")
|
||||
|
||||
print(f"{label}:")
|
||||
print(
|
||||
f" center horizontal = {self._sl_fmt(ch)} "
|
||||
f"width horizontal = {self._sl_fmt(wh)}"
|
||||
f" x center = {self._sl_fmt(xc)} "
|
||||
f"x size = {self._sl_fmt(xs)}"
|
||||
)
|
||||
print(
|
||||
f" center vertical = {self._sl_fmt(cv)} "
|
||||
f"width vertical = {self._sl_fmt(wv)}"
|
||||
f" y center = {self._sl_fmt(yc)} "
|
||||
f"y size = {self._sl_fmt(ys)}"
|
||||
)
|
||||
print("")
|
||||
|
||||
|
||||
BIN
Binary file not shown.
@@ -54,34 +54,6 @@ class FlomniError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# class FlomniTools:
|
||||
# def yesno(self, message: str, default="none", autoconfirm=0) -> bool:
|
||||
# if autoconfirm and default == "y":
|
||||
# self.printgreen(message + " Automatically confirming default: yes")
|
||||
# return True
|
||||
# elif autoconfirm and default == "n":
|
||||
# self.printgreen(message + " Automatically confirming default: no")
|
||||
# return False
|
||||
# if default == "y":
|
||||
# message_ending = " [Y]/n? "
|
||||
# elif default == "n":
|
||||
# message_ending = " y/[N]? "
|
||||
# else:
|
||||
# message_ending = " y/n? "
|
||||
# while True:
|
||||
# user_input = input(self.OKBLUE + message + message_ending + self.ENDC)
|
||||
# if (
|
||||
# user_input == "Y" or user_input == "y" or user_input == "yes" or user_input == "Yes"
|
||||
# ) or (default == "y" and user_input == ""):
|
||||
# return True
|
||||
# if (
|
||||
# user_input == "N" or user_input == "n" or user_input == "no" or user_input == "No"
|
||||
# ) or (default == "n" and user_input == ""):
|
||||
# return False
|
||||
# else:
|
||||
# print("Please expicitely confirm y or n.")
|
||||
|
||||
|
||||
class FlomniInitStagesMixin:
|
||||
|
||||
def flomni_init_stages(self):
|
||||
@@ -452,6 +424,7 @@ class FlomniSampleTransferMixin:
|
||||
if not sample_in_position:
|
||||
raise FlomniError("There is no sample in the sample stage. Aborting.")
|
||||
self.reset_correction()
|
||||
self.reset_tomo_alignment_fit()
|
||||
dev.rtx.controller.feedback_disable()
|
||||
self.ensure_fheater_up()
|
||||
self.ensure_gripper_up()
|
||||
@@ -488,6 +461,56 @@ class FlomniSampleTransferMixin:
|
||||
def laser_tracker_off(self):
|
||||
dev.rtx.controller.laser_tracker_off()
|
||||
|
||||
def umvr_fsamy_tracked(self, shift: float, step: float = 0.01):
|
||||
"""
|
||||
Relative move of fsamy by `shift` mm, broken into steps of at most
|
||||
`step` mm (default 0.01 mm = 10 um), calling laser_tracker_on()
|
||||
after every step so the tracker stays locked during the move
|
||||
(fsamy travels a lot during sample change and the tracker can't
|
||||
keep up in one big jump). fsamy itself moves in mm, so shift/step
|
||||
are taken directly in mm -- no unit conversion needed.
|
||||
|
||||
Uses dev.rtx.controller.laser_tracker_on() directly rather than
|
||||
self.laser_tracker_on() (which adds a 0.2s sleep plus a
|
||||
signal-strength check) since this runs once per small step and that
|
||||
overhead would add up across what can be 100+ steps for a typical
|
||||
sample-change-sized move; the goal here is just to keep the tracker
|
||||
re-acquiring as fsamy moves, not to verify signal strength at every
|
||||
single step.
|
||||
|
||||
Args:
|
||||
shift: relative move distance in mm (signed; negative moves the
|
||||
other direction).
|
||||
step: maximum step size in mm per tracker re-acquisition.
|
||||
Must be positive. Default 0.01 mm (10 um).
|
||||
"""
|
||||
if step <= 0:
|
||||
raise ValueError("step must be a positive number of mm.")
|
||||
|
||||
direction = 1 if shift >= 0 else -1
|
||||
remaining = abs(shift)
|
||||
while remaining > 0:
|
||||
this_step = min(step, remaining)
|
||||
scans.umv(dev.fsamy, direction * this_step, relative=True)
|
||||
dev.rtx.controller.laser_tracker_on()
|
||||
remaining -= this_step
|
||||
|
||||
def umv_fsamy_tracked(self, target: float, step: float = 0.01):
|
||||
"""
|
||||
Absolute move of fsamy to `target` mm (same units as dev.fsamy's
|
||||
readback / dev.fsamy.user_parameter.get("in")), broken into steps of
|
||||
at most `step` mm via umvr_fsamy_tracked().
|
||||
|
||||
Args:
|
||||
target: absolute target position in mm.
|
||||
step: maximum step size in mm per tracker re-acquisition.
|
||||
Must be positive (validated in umvr_fsamy_tracked).
|
||||
Default 0.01 mm (10 um).
|
||||
"""
|
||||
current = dev.fsamy.readback.get()
|
||||
shift_mm = target - current
|
||||
self.umvr_fsamy_tracked(shift_mm, step)
|
||||
|
||||
def show_signal_strength_interferometer(self):
|
||||
dev.rtx.controller.show_signal_strength_interferometer()
|
||||
|
||||
@@ -534,16 +557,13 @@ class FlomniSampleTransferMixin:
|
||||
|
||||
self.check_tray_in()
|
||||
|
||||
self.laser_tracker_off()
|
||||
time.sleep(0.05)
|
||||
fsamy_in = dev.fsamy.user_parameter.get("in")
|
||||
if fsamy_in is None:
|
||||
raise FlomniError(
|
||||
"Could not find an 'IN' position for fsamy. Please check your config."
|
||||
)
|
||||
umv(dev.fsamy, fsamy_in)
|
||||
time.sleep(0.05)
|
||||
self.laser_tracker_on()
|
||||
|
||||
self.umv_fsamy_tracked(fsamy_in)
|
||||
time.sleep(0.05)
|
||||
self.laser_tracker_off()
|
||||
time.sleep(0.05)
|
||||
@@ -618,7 +638,7 @@ class FlomniSampleTransferMixin:
|
||||
self.check_sensor_connected()
|
||||
|
||||
sample_in_gripper = dev.flomni_samples.is_sample_in_gripper()
|
||||
# bool(float(dev.flomni_samples.sample_in_gripper.get()))
|
||||
# dev.flomni_samples.sample_in_gripper.get()
|
||||
if not sample_in_gripper:
|
||||
raise FlomniError("The gripper does not carry a sample.")
|
||||
|
||||
@@ -991,6 +1011,46 @@ class FlomniAlignmentMixin:
|
||||
/ default_correction_file_rel
|
||||
).resolve()
|
||||
|
||||
# --- additional y-correction curves: global-var-backed so they survive
|
||||
# a BEC client restart, same pattern as every other persistent setting
|
||||
# in this class. NOTE: must NOT be pre-assigned in Flomni.__init__ -
|
||||
# doing so would overwrite the persisted value with [] on every restart.
|
||||
@property
|
||||
def corr_pos_y(self):
|
||||
val = self.client.get_global_var("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("corr_pos_y", val)
|
||||
|
||||
@property
|
||||
def corr_angle_y(self):
|
||||
val = self.client.get_global_var("corr_angle_y")
|
||||
return [] if val is None else val
|
||||
|
||||
@corr_angle_y.setter
|
||||
def corr_angle_y(self, val: list):
|
||||
self.client.set_global_var("corr_angle_y", val)
|
||||
|
||||
@property
|
||||
def corr_pos_y_2(self):
|
||||
val = self.client.get_global_var("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("corr_pos_y_2", val)
|
||||
|
||||
@property
|
||||
def corr_angle_y_2(self):
|
||||
val = self.client.get_global_var("corr_angle_y_2")
|
||||
return [] if val is None else val
|
||||
|
||||
@corr_angle_y_2.setter
|
||||
def corr_angle_y_2(self, val: list):
|
||||
self.client.set_global_var("corr_angle_y_2", val)
|
||||
|
||||
def reset_correction(self, use_default_correction=True):
|
||||
"""
|
||||
Reset the correction to the default values.
|
||||
@@ -1238,6 +1298,7 @@ class _ProgressProxy:
|
||||
"estimated_remaining_time": None,
|
||||
"estimated_finish_time": None,
|
||||
"heartbeat": None,
|
||||
"accumulated_idle_time": 0.0,
|
||||
}
|
||||
|
||||
def __init__(self, client):
|
||||
@@ -1287,6 +1348,63 @@ class _ProgressProxy:
|
||||
return self._load()
|
||||
|
||||
|
||||
class _TomoQueueProxy:
|
||||
"""List-like proxy that persists a queue of tomo parameter snapshots as a
|
||||
BEC global variable, mirroring the pattern used by :class:`_ProgressProxy`.
|
||||
|
||||
Each entry is a plain dict: ``{"label": ..., "params": {...}, "status":
|
||||
..., "added_at": ...}``, where ``params`` holds a snapshot of all
|
||||
global-var-backed tomo scan parameters (see
|
||||
``Flomni._TOMO_QUEUE_PARAM_NAMES``). Stored as a single list under
|
||||
``tomo_queue`` so the queue is visible from any BEC client session via
|
||||
``client.get_global_var("tomo_queue")`` and survives a kernel restart.
|
||||
"""
|
||||
|
||||
_GLOBAL_VAR_KEY = "tomo_queue"
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def _load(self) -> list:
|
||||
val = self._client.get_global_var(self._GLOBAL_VAR_KEY)
|
||||
if val is None:
|
||||
return []
|
||||
return val
|
||||
|
||||
def _save(self, jobs: list) -> None:
|
||||
self._client.set_global_var(self._GLOBAL_VAR_KEY, jobs)
|
||||
|
||||
def as_list(self) -> list:
|
||||
"""Return a plain copy of the current queue."""
|
||||
return self._load()
|
||||
|
||||
def append(self, job: dict) -> int:
|
||||
jobs = self._load()
|
||||
jobs.append(job)
|
||||
self._save(jobs)
|
||||
return len(jobs) - 1
|
||||
|
||||
def pop(self, index: int) -> dict:
|
||||
jobs = self._load()
|
||||
job = jobs.pop(index)
|
||||
self._save(jobs)
|
||||
return job
|
||||
|
||||
def update(self, index: int, **kwargs) -> None:
|
||||
jobs = self._load()
|
||||
jobs[index].update(kwargs)
|
||||
self._save(jobs)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._save([])
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._load())
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self._load()[index]
|
||||
|
||||
|
||||
class Flomni(
|
||||
FlomniInitStagesMixin,
|
||||
FlomniSampleTransferMixin,
|
||||
@@ -1305,12 +1423,17 @@ class Flomni(
|
||||
self.special_angle_tolerance = 20
|
||||
self._current_special_angles = []
|
||||
self._beam_is_okay = True
|
||||
self.corr_pos_y = []
|
||||
self.corr_angle_y = []
|
||||
self.corr_pos_y_2 = []
|
||||
self.corr_angle_y_2 = []
|
||||
self._progress_proxy = _ProgressProxy(self.client)
|
||||
self._progress_proxy.reset()
|
||||
# Deliberately NOT calling reset() here: this dict is persisted via a
|
||||
# BEC global var specifically so it survives a kernel restart (e.g.
|
||||
# after a crash), which is what tomo_scan_resume() relies on.
|
||||
# Resetting it unconditionally on every Flomni() instantiation wiped
|
||||
# tomo_start_time (and everything else) on exactly the restarts where
|
||||
# resuming matters most. A genuinely new tomo_scan() call already
|
||||
# resets the relevant fields itself (see the "new scan" branch
|
||||
# below); use tomo_progress_reset() if you want to explicitly clear
|
||||
# stale progress without starting a new scan.
|
||||
self._tomo_queue_proxy = _TomoQueueProxy(self.client)
|
||||
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni_webpage_generator import (
|
||||
FlomniWebpageGenerator,
|
||||
)
|
||||
@@ -1606,6 +1729,27 @@ class Flomni(
|
||||
def golden_projections_at_0_deg_for_damage_estimation(self, val: float):
|
||||
self.client.set_global_var("golden_projections_at_0_deg_for_damage_estimation", val)
|
||||
|
||||
@property
|
||||
def zero_deg_reference_at_each_subtomo(self):
|
||||
"""If True (tomo_type == 1 only), an additional projection at exactly
|
||||
0 degrees is acquired at the start of every odd (forward)
|
||||
sub-tomogram - i.e. every time the rotation passes back through
|
||||
0 degrees - and once more after the final (8th) sub-tomogram
|
||||
completes. Together with sub-tomogram 1's own first projection
|
||||
(which already lands exactly on 0 degrees), this gives a 0-degree
|
||||
reference shot at every natural pass through 0 across the full
|
||||
tomogram, useful for tracking radiation damage over time. Mirrors
|
||||
golden_projections_at_0_deg_for_damage_estimation, which provides
|
||||
similar functionality for tomo_type 2/3."""
|
||||
val = self.client.get_global_var("zero_deg_reference_at_each_subtomo")
|
||||
if val is None:
|
||||
return False
|
||||
return val
|
||||
|
||||
@zero_deg_reference_at_each_subtomo.setter
|
||||
def zero_deg_reference_at_each_subtomo(self, val: bool):
|
||||
self.client.set_global_var("zero_deg_reference_at_each_subtomo", val)
|
||||
|
||||
@property
|
||||
def golden_ratio_bunch_size(self):
|
||||
val = self.client.get_global_var("golden_ratio_bunch_size")
|
||||
@@ -1700,7 +1844,7 @@ class Flomni(
|
||||
|
||||
umv(dev.fsamroy, 0)
|
||||
self.OMNYTools.printgreenbold(
|
||||
"\n\nAlignment scan finished. Please run SPEC_ptycho_align and load the new fit by flomni.read_alignment_offset() ."
|
||||
"\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit by flomni.read_alignment_offset() ."
|
||||
)
|
||||
|
||||
# summary of alignment scan numbers
|
||||
@@ -1751,26 +1895,65 @@ class Flomni(
|
||||
start_angle (float, optional): The start angle of the scan. Defaults to None.
|
||||
"""
|
||||
|
||||
if start_angle is not None:
|
||||
explicit_start_angle = start_angle is not None
|
||||
if explicit_start_angle:
|
||||
print(f"Sub tomo scan with start angle {start_angle} requested.")
|
||||
|
||||
# tomo_angle_range / tomo_angle_stepsize is not guaranteed to be a
|
||||
# whole number (e.g. a "total number of projections" that isn't a
|
||||
# multiple of 8 was configured). N is the actual, integer number of
|
||||
# projections per sub-tomogram; step is the step size that's
|
||||
# ACTUALLY achievable while landing exactly on N evenly-spaced
|
||||
# points across tomo_angle_range. This corrected step - not the
|
||||
# raw, configured tomo_angle_stepsize - is used for BOTH the
|
||||
# per-point ramp AND the inter-sub-tomogram phase offsets below.
|
||||
# Using the raw stepsize for the phase offsets while the ramp used
|
||||
# the corrected one is what caused the combined/interlaced
|
||||
# tomogram to have an inconsistent angular spacing whenever N
|
||||
# wasn't already a whole number for the raw stepsize.
|
||||
N = int(self.tomo_angle_range / self.tomo_angle_stepsize)
|
||||
step = self.tomo_angle_range / N
|
||||
|
||||
# Phase offset (degrees) for this sub-tomogram's position in the
|
||||
# bit-reversal interlacing order - needed below to correctly
|
||||
# recover the loop index i when resuming with an explicit
|
||||
# start_angle (see the i==0 block further down).
|
||||
phase_eighths = {1: 0, 2: 4, 3: 2, 4: 6, 5: 1, 6: 5, 7: 3, 8: 7}
|
||||
phase = step / 8.0 * phase_eighths[subtomo_number]
|
||||
|
||||
if start_angle is None:
|
||||
if subtomo_number == 1:
|
||||
start_angle = 0
|
||||
elif subtomo_number == 2:
|
||||
start_angle = self.tomo_angle_stepsize / 8.0 * 4
|
||||
start_angle = step / 8.0 * 4
|
||||
elif subtomo_number == 3:
|
||||
start_angle = self.tomo_angle_stepsize / 8.0 * 2
|
||||
start_angle = step / 8.0 * 2
|
||||
elif subtomo_number == 4:
|
||||
start_angle = self.tomo_angle_stepsize / 8.0 * 6
|
||||
start_angle = step / 8.0 * 6
|
||||
elif subtomo_number == 5:
|
||||
start_angle = self.tomo_angle_stepsize / 8.0 * 1
|
||||
start_angle = step / 8.0 * 1
|
||||
elif subtomo_number == 6:
|
||||
start_angle = self.tomo_angle_stepsize / 8.0 * 5
|
||||
start_angle = step / 8.0 * 5
|
||||
elif subtomo_number == 7:
|
||||
start_angle = self.tomo_angle_stepsize / 8.0 * 3
|
||||
start_angle = step / 8.0 * 3
|
||||
elif subtomo_number == 8:
|
||||
start_angle = self.tomo_angle_stepsize / 8.0 * 7
|
||||
start_angle = step / 8.0 * 7
|
||||
|
||||
if not subtomo_number % 2: # even = reverse
|
||||
# The table above gives the LOW end of this sub-tomogram's
|
||||
# angular phase (same convention as the forward/odd
|
||||
# sub-tomograms - it's what makes the combined 8 sub-tomograms
|
||||
# interlace into one fine angular grid). A reverse sweep must
|
||||
# begin at the HIGH end of that span and descend. The literal
|
||||
# high end (phase + tomo_angle_range) would itself be rejected
|
||||
# by _tomo_scan_at_angle's own "angle < tomo_angle_range + 0.05"
|
||||
# gate for every sub-tomogram whose phase is nonzero (it lands
|
||||
# just past the range), so start one step below that instead -
|
||||
# this is the angle that will actually be the first one
|
||||
# accepted. This step is skipped when start_angle is given
|
||||
# explicitly (i.e. we are resuming mid sub-tomogram), since
|
||||
# then the value is already the literal current angle.
|
||||
start_angle = start_angle + self.tomo_angle_range - step
|
||||
|
||||
# _tomo_shift_angles (potential global variable)
|
||||
_tomo_shift_angles = 0
|
||||
@@ -1778,38 +1961,19 @@ class Flomni(
|
||||
|
||||
start = start_angle + _tomo_shift_angles
|
||||
|
||||
if subtomo_number % 2: # odd = forward
|
||||
max_allowed_angle = self.tomo_angle_range + 0.05 + self.tomo_angle_stepsize
|
||||
proposed_end = start + self.tomo_angle_range
|
||||
angle_end = min(proposed_end, max_allowed_angle)
|
||||
span = angle_end - start
|
||||
|
||||
else: # even = reverse
|
||||
min_allowed_angle = 0
|
||||
proposed_end = start - self.tomo_angle_range
|
||||
angle_end = max(proposed_end, min_allowed_angle)
|
||||
span = start - angle_end
|
||||
|
||||
# number of projections needed to maintain step size
|
||||
N = int(span / self.tomo_angle_stepsize) + 1
|
||||
|
||||
angles = np.linspace(start, angle_end, num=N, endpoint=True)
|
||||
|
||||
if subtomo_number % 2: # odd subtomos → forward direction
|
||||
# clamp end angle to max allowed
|
||||
max_allowed_angle = self.tomo_angle_range + 0.05 + self.tomo_angle_stepsize
|
||||
proposed_end = start + self.tomo_angle_range
|
||||
angle_end = min(proposed_end, max_allowed_angle)
|
||||
|
||||
angles = np.linspace(start, angle_end, num=N, endpoint=True)
|
||||
|
||||
else: # even subtomos → reverse direction
|
||||
# go FROM start_angle down toward 0
|
||||
min_allowed_angle = 0
|
||||
proposed_end = start - self.tomo_angle_range
|
||||
angle_end = max(proposed_end, min_allowed_angle)
|
||||
|
||||
angles = np.linspace(start, angle_end, num=N, endpoint=True)
|
||||
# Every sub-tomogram covers exactly N projections, matching
|
||||
# subtomo_total_projections elsewhere in this class - generated by
|
||||
# plain arithmetic at the exact (corrected) step size, with no
|
||||
# clamping. This deliberately never generates the boundary point at
|
||||
# start +/- tomo_angle_range: that point is silently rejected by
|
||||
# _tomo_scan_at_angle's own range gate for every sub-tomogram whose
|
||||
# phase is nonzero anyway, so generating it only ever produced an
|
||||
# inconsistent extra projection for the phase==0 sub-tomogram while
|
||||
# every other sub-tomogram was silently one projection short.
|
||||
if subtomo_number % 2: # odd = forward: low -> high
|
||||
angles = start + np.arange(N) * step
|
||||
else: # even = reverse: high -> low
|
||||
angles = start - np.arange(N) * step
|
||||
|
||||
for i, angle in enumerate(angles):
|
||||
|
||||
@@ -1817,33 +1981,41 @@ class Flomni(
|
||||
|
||||
# --- NEW LOGIC FOR OFFSET WHEN start_angle IS SPECIFIED ---
|
||||
if i == 0:
|
||||
step = self.tomo_angle_stepsize
|
||||
sa = start_angle
|
||||
|
||||
if start_angle is None:
|
||||
if not explicit_start_angle:
|
||||
# normal operation: always start at zero
|
||||
self._subtomo_offset = 0
|
||||
|
||||
else:
|
||||
# Explicitly subtract the phase before dividing, rather
|
||||
# than relying on an algebraic shortcut: for subtomo 2,
|
||||
# phase/step is exactly 0.5 (its phase_eighths value is
|
||||
# 4), landing precisely on the float rounding tie-break
|
||||
# boundary - floating-point noise there unpredictably
|
||||
# rounds up or down, which previously gave the wrong
|
||||
# offset (off by +1) in ~44% of resumes within subtomo 2
|
||||
# specifically (verified by exhaustive sweep). Every
|
||||
# other sub-tomogram's phase fraction is far enough from
|
||||
# 0.5 that the old shortcut never broke for them.
|
||||
if subtomo_number % 2: # odd = forward direction
|
||||
self._subtomo_offset = round(sa / step)
|
||||
self._subtomo_offset = round((start_angle - phase) / step)
|
||||
else: # even = reverse direction
|
||||
self._subtomo_offset = round((self.tomo_angle_range - sa) / step)
|
||||
self._subtomo_offset = round(
|
||||
((phase + self.tomo_angle_range - step) - start_angle) / step
|
||||
)
|
||||
|
||||
# progress index must always increase
|
||||
self.progress["subtomo_projection"] = self._subtomo_offset + i
|
||||
# ------------------------------------------------------------
|
||||
|
||||
# existing progress fields
|
||||
self.progress["subtomo_total_projections"] = int(
|
||||
self.tomo_angle_range / self.tomo_angle_stepsize
|
||||
)
|
||||
# existing progress fields. N is already an int (by
|
||||
# construction, see above), so total_projections = N * 8 is
|
||||
# exact - no float round-trip noise, and no separate round()/
|
||||
# int() cast needed here.
|
||||
self.progress["subtomo_total_projections"] = N
|
||||
self.progress["projection"] = (subtomo_number - 1) * self.progress[
|
||||
"subtomo_total_projections"
|
||||
] + self.progress["subtomo_projection"]
|
||||
self.progress["total_projections"] = (
|
||||
self.tomo_angle_range / self.tomo_angle_stepsize
|
||||
) * 8
|
||||
self.progress["total_projections"] = N * 8
|
||||
self.progress["angle"] = angle
|
||||
|
||||
# finally do the scan at this angle
|
||||
@@ -1853,7 +2025,29 @@ class Flomni(
|
||||
def _tomo_scan_at_angle(self, angle, subtomo_number):
|
||||
|
||||
if 0 <= angle < self.tomo_angle_range + 0.05:
|
||||
self.progress["heartbeat"] = datetime.datetime.now().isoformat()
|
||||
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 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
|
||||
# below. The 5x/60s margins are a heuristic, not a precise
|
||||
# timing model -- tune if it over/under-triggers in practice.
|
||||
normal_cadence = max(60.0, 5 * self.tomo_countingtime * self.frames_per_trigger)
|
||||
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 flOMNI scan for angle {angle} in subtomo {subtomo_number}")
|
||||
self._print_progress()
|
||||
|
||||
@@ -1924,15 +2118,34 @@ class Flomni(
|
||||
# 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:
|
||||
# 8 equally spaced sub-tomograms
|
||||
self.progress["tomo_type"] = "Equally spaced sub-tomograms"
|
||||
for ii in range(subtomo_start, 9):
|
||||
if start_angle is None and ii % 2 and self.zero_deg_reference_at_each_subtomo:
|
||||
# Dedicated reference shot at exactly 0 degrees, taken
|
||||
# every time the rotation passes back through 0 (i.e.
|
||||
# at the start of every odd/forward sub-tomogram), for
|
||||
# tracking radiation damage over the full tomogram.
|
||||
# Skipped when resuming mid-sub-tomogram (start_angle
|
||||
# given explicitly) since we're not actually passing
|
||||
# through 0 deg at that moment.
|
||||
self._tomo_scan_at_angle(0, ii)
|
||||
self.sub_tomo_scan(ii, start_angle=start_angle)
|
||||
start_angle = None
|
||||
|
||||
if self.zero_deg_reference_at_each_subtomo:
|
||||
# Final reference shot at exactly 0 degrees once the whole
|
||||
# tomogram is complete, giving a clean "before vs after"
|
||||
# pair together with sub-tomogram 1's own first projection
|
||||
# (angle 0) for radiation-damage comparison across the
|
||||
# full acquisition.
|
||||
self._tomo_scan_at_angle(0, 8)
|
||||
|
||||
elif self.tomo_type == 2:
|
||||
# Golden ratio tomography
|
||||
previous_subtomo_number = -1
|
||||
@@ -2030,6 +2243,58 @@ class Flomni(
|
||||
self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"]
|
||||
self._print_progress()
|
||||
self.OMNYTools.printgreenbold("Tomoscan finished")
|
||||
print(
|
||||
f"Total measurement time lost to detected gaps: {self._format_duration(self.progress.get('accumulated_idle_time', 0.0))}"
|
||||
)
|
||||
|
||||
def tomo_scan_resume(self) -> None:
|
||||
"""Resume a tomo_scan() that crashed or was interrupted, picking up
|
||||
automatically from wherever ``progress`` last reported -- no need to
|
||||
read the last subtomo/angle/projection off the progress printout by
|
||||
hand and pass it to tomo_scan() yourself.
|
||||
|
||||
Re-attempts the exact angle/projection that was in progress at the
|
||||
moment of interruption, rather than skipping ahead to the next one:
|
||||
from here we can't be sure whether that last point was actually
|
||||
acquired before the crash, and re-acquiring one extra projection is
|
||||
harmless.
|
||||
|
||||
Works standalone for a normal tomo_scan(), independent of the tomo
|
||||
queue. tomo_queue_execute() also calls this internally when resuming
|
||||
a job that previously failed partway through, so a failed queue job
|
||||
picks up mid-scan rather than restarting from subtomo 1 / angle 0.
|
||||
"""
|
||||
if self.progress.get("tomo_start_time") is None:
|
||||
print("No tomo scan in progress to resume -- nothing to do.")
|
||||
return
|
||||
|
||||
if self.tomo_type == 1:
|
||||
subtomo_start = self.progress["subtomo"]
|
||||
start_angle = self.progress["angle"]
|
||||
if subtomo_start < 1:
|
||||
print("No tomo scan in progress to resume -- nothing to do.")
|
||||
return
|
||||
print(f"Resuming tomo scan at subtomo {subtomo_start}, angle {start_angle:.3f} deg.")
|
||||
self.tomo_scan(subtomo_start=subtomo_start, start_angle=start_angle)
|
||||
elif self.tomo_type in (2, 3):
|
||||
projection_number = self.progress["projection"]
|
||||
print(f"Resuming tomo scan at projection {projection_number}.")
|
||||
self.tomo_scan(projection_number=projection_number)
|
||||
else:
|
||||
raise FlomniError("undefined tomo type")
|
||||
|
||||
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 anymore on Flomni() startup -- that used to
|
||||
wipe an in-progress scan's state on every kernel restart, which is
|
||||
exactly the state tomo_scan_resume() needs. 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:
|
||||
@@ -2051,6 +2316,11 @@ class Flomni(
|
||||
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
|
||||
@@ -2065,8 +2335,8 @@ class Flomni(
|
||||
print("\x1b[95mProgress report:")
|
||||
print(f"Tomo type: ....................... {self.progress['tomo_type']}")
|
||||
print(f"Projection: ...................... {self.progress['projection']:.0f}")
|
||||
print(f"Total projections expected ....... {self.progress['total_projections']}")
|
||||
print(f"Angle: ........................... {self.progress['angle']}")
|
||||
print(f"Total projections expected ....... {self.progress['total_projections']:.0f}")
|
||||
print(f"Angle: ........................... {self.progress['angle']:.3f}")
|
||||
print(f"Current subtomo: ................. {self.progress['subtomo']}")
|
||||
print(f"Current projection within subtomo: {self.progress['subtomo_projection']}")
|
||||
print(f"Estimated remaining time: ........ {eta_str}")
|
||||
@@ -2147,13 +2417,18 @@ class Flomni(
|
||||
|
||||
return angle, subtomo_number
|
||||
|
||||
def tomo_reconstruct(self, base_path="~/data/raw/logs/reconstruction_queue"):
|
||||
def tomo_reconstruct(
|
||||
self,
|
||||
base_path="~/data/raw/logs/reconstruction_queue",
|
||||
probe_propagation: float | None = None,
|
||||
):
|
||||
"""write the tomo reconstruct file for the reconstruction queue"""
|
||||
bec = builtins.__dict__.get("bec")
|
||||
self.reconstructor.write(
|
||||
scan_list=self._current_scan_list,
|
||||
next_scan_number=bec.queue.next_scan_number,
|
||||
base_path=base_path,
|
||||
probe_file_propagation=probe_propagation,
|
||||
)
|
||||
|
||||
def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None:
|
||||
@@ -2183,6 +2458,9 @@ class Flomni(
|
||||
+ self.manual_shift_y
|
||||
)
|
||||
sum_offset_z = offsets[2]
|
||||
# TODO this fix is while the tracker z is broken
|
||||
probe_propagation = -sum_offset_z * 1e-6
|
||||
sum_offset_z = 0
|
||||
|
||||
self._current_scan_list = []
|
||||
|
||||
@@ -2221,7 +2499,7 @@ class Flomni(
|
||||
|
||||
scans.flomni_fermat_scan(**scan_kwargs)
|
||||
|
||||
self.tomo_reconstruct()
|
||||
self.tomo_reconstruct(probe_propagation=probe_propagation)
|
||||
|
||||
def tomo_acquire_at_angle(self, angle: float, frames_per_trigger: int | None = None):
|
||||
"""
|
||||
@@ -2271,20 +2549,40 @@ class Flomni(
|
||||
dev.rtx.controller.move_samx_to_scan_region(sum_offset_x)
|
||||
|
||||
if tracker_signal == "low":
|
||||
logger.warning(
|
||||
"Signal strength of the laser tracker is low. Realignment recommended!"
|
||||
)
|
||||
logger.warning("Signal strength of the laser tracker is low. Realignment recommended!")
|
||||
elif tracker_signal == "toolow":
|
||||
raise FlomniError(
|
||||
"Signal strength of the laser tracker is too low for scanning. Realignment required!"
|
||||
)
|
||||
|
||||
# --- acquire ---
|
||||
n_frames = (
|
||||
frames_per_trigger if frames_per_trigger is not None else self.frames_per_trigger
|
||||
)
|
||||
n_frames = frames_per_trigger if frames_per_trigger is not None else self.frames_per_trigger
|
||||
scans.acquire(exp_time=self.tomo_countingtime, frames_per_trigger=n_frames)
|
||||
|
||||
def _tomo_type1_actual_grid(self) -> tuple[int, float, int]:
|
||||
"""Compute the actual (achievable) tomo_type==1 grid from the
|
||||
currently stored self.tomo_angle_stepsize -- the SAME way
|
||||
sub_tomo_scan() does it. Returns (N, step, total_projections):
|
||||
N: integer number of projections per sub-tomogram
|
||||
step: the achievable per-projection angular step (range / N) --
|
||||
this is what the scan actually runs at, NOT
|
||||
self.tomo_angle_stepsize itself
|
||||
total_projections: N * 8
|
||||
|
||||
self.tomo_angle_stepsize is stored as a raw, uncorrected value
|
||||
(derived once from whatever total was originally typed in); reading
|
||||
it back directly does not generally equal the achievable grid. Any
|
||||
code that displays or prompts using these quantities should go
|
||||
through this helper rather than recomputing the formula locally --
|
||||
that duplication (display using a different formula than the one
|
||||
sub_tomo_scan() actually uses) is exactly what previously caused
|
||||
the displayed "angular step within sub-tomogram" to silently differ
|
||||
from the angle the scan was actually acquiring at.
|
||||
"""
|
||||
N = int(self.tomo_angle_range / self.tomo_angle_stepsize)
|
||||
step = self.tomo_angle_range / N
|
||||
return N, step, N * 8
|
||||
|
||||
def tomo_parameters(self):
|
||||
"""print and update the tomo parameters"""
|
||||
print("Current settings:")
|
||||
@@ -2301,11 +2599,20 @@ class Flomni(
|
||||
if self.tomo_type == 1:
|
||||
print("\x1b[1mTomo type 1:\x1b[0m 8 equally spaced sub-tomograms")
|
||||
print(f"Angular range = {self.tomo_angle_range} degrees")
|
||||
print(f"Total number of projections: {(self.tomo_angle_range/self.tomo_angle_stepsize)*8}")
|
||||
print(f"Angular step within sub-tomogram: {self.tomo_angle_stepsize} degrees")
|
||||
# N, step, total_projections all come from the same helper
|
||||
# sub_tomo_scan() effectively uses internally - see
|
||||
# _tomo_type1_actual_grid() for why this can't just read
|
||||
# self.tomo_angle_stepsize directly.
|
||||
_, achievable_step, total_projections = self._tomo_type1_actual_grid()
|
||||
print(f"Total number of projections: {total_projections}")
|
||||
print(f"Angular step within sub-tomogram: {achievable_step:.3f} degrees")
|
||||
print(
|
||||
"Angular step of the final (combined) tomogram:"
|
||||
f" {self.tomo_angle_range/((self.tomo_angle_range/self.tomo_angle_stepsize)*8)} degrees"
|
||||
f" {self.tomo_angle_range / total_projections:.3f} degrees"
|
||||
)
|
||||
print(
|
||||
"0-deg reference shots (odd sub-tomo start + end) ="
|
||||
f" {self.zero_deg_reference_at_each_subtomo}"
|
||||
)
|
||||
if self.tomo_type == 2:
|
||||
print("\x1b[1mTomo type 2:\x1b[0m Golden ratio tomography")
|
||||
@@ -2322,8 +2629,8 @@ class Flomni(
|
||||
print(
|
||||
"\x1b[1mTomo type 3:\x1b[0m Equally spaced tomography, golden ratio starting angle"
|
||||
)
|
||||
print(f"Number of projections per sub-tomogram: {180/self.tomo_angle_stepsize}")
|
||||
print(f"Angular step within sub-tomogram: {self.tomo_angle_stepsize} degrees")
|
||||
print(f"Number of projections per sub-tomogram: {180 / self.tomo_angle_stepsize:.3f}")
|
||||
print(f"Angular step within sub-tomogram: {self.tomo_angle_stepsize:.3f} degrees")
|
||||
if self.golden_max_number_of_projections > 0:
|
||||
print(f"ending after {self.golden_max_number_of_projections} projections")
|
||||
else:
|
||||
@@ -2354,6 +2661,7 @@ class Flomni(
|
||||
self.ptycho_reconstruct_foldername = self._get_val(
|
||||
"Reconstruction queue ", self.ptycho_reconstruct_foldername, str
|
||||
)
|
||||
self.manual_shift_y = self._get_val("<manual_shift_y> um", self.manual_shift_y, float)
|
||||
self.frames_per_trigger = self._get_val(
|
||||
"Frames per trigger (burst)", self.frames_per_trigger, int
|
||||
)
|
||||
@@ -2384,18 +2692,49 @@ class Flomni(
|
||||
self.tomo_angle_range = self._get_val(
|
||||
"Angular range (180 or 360)", self.tomo_angle_range, int
|
||||
)
|
||||
# Default shown here must be the actual achievable total
|
||||
# (int(range/stepsize)*8), not the raw algebraic inverse of
|
||||
# the stored stepsize -- the latter just reconstructs
|
||||
# whatever total was typed in on a PREVIOUS call (e.g. 63),
|
||||
# even if that got silently adjusted to 56 at the time; this
|
||||
# is also what was causing the next default shown here to
|
||||
# never reflect a prior adjustment.
|
||||
_, _, current_total = self._tomo_type1_actual_grid()
|
||||
tomo_numberofprojections = self._get_val(
|
||||
"Total number of projections",
|
||||
(self.tomo_angle_range / self.tomo_angle_stepsize) * 8,
|
||||
int,
|
||||
"Total number of projections", current_total, int
|
||||
)
|
||||
self.tomo_angle_stepsize = (self.tomo_angle_range / tomo_numberofprojections) * 8
|
||||
|
||||
# Now report what was ACTUALLY achieved, via the same helper
|
||||
# sub_tomo_scan() effectively uses -- not the raw value just
|
||||
# typed in or the raw stored stepsize, either of which can
|
||||
# silently disagree with what the scan will actually run
|
||||
# (this was previously the case: e.g. requesting 63 stored a
|
||||
# stepsize whose raw value differed from the achievable
|
||||
# per-projection step the scan actually used, 22.857 vs
|
||||
# 25.714 in one observed case).
|
||||
_, achievable_step, actual_total = self._tomo_type1_actual_grid()
|
||||
if actual_total != tomo_numberofprojections:
|
||||
print(
|
||||
f"Note: {tomo_numberofprojections} projections does not divide evenly "
|
||||
f"into 8 equally spaced sub-tomograms over {self.tomo_angle_range} "
|
||||
f"degrees; adjusted to the nearest achievable total of {actual_total} "
|
||||
"projections to keep the angular grid uniform."
|
||||
)
|
||||
print(
|
||||
f"The angular step within a sub-tomogram will be {self.tomo_angle_stepsize} degrees"
|
||||
f"The angular step within a sub-tomogram will be {achievable_step:.3f} degrees"
|
||||
)
|
||||
print(
|
||||
"The angular step of the final (combined) tomogram will be"
|
||||
f" {self.tomo_angle_range / tomo_numberofprojections} degrees"
|
||||
f" {self.tomo_angle_range / actual_total:.3f} degrees"
|
||||
)
|
||||
self.zero_deg_reference_at_each_subtomo = bool(
|
||||
self._get_val(
|
||||
"Take 0-deg reference shots (start of each odd sub-tomo + end) for"
|
||||
" damage estimation 1/0?",
|
||||
int(self.zero_deg_reference_at_each_subtomo),
|
||||
int,
|
||||
)
|
||||
)
|
||||
|
||||
if self.tomo_type == 2:
|
||||
@@ -2437,6 +2776,177 @@ class Flomni(
|
||||
def _get_val(msg: str, default_value, data_type):
|
||||
return data_type(input(f"{msg} ({default_value}): ") or default_value)
|
||||
|
||||
# Ordered set of all global-var-backed tomo scan parameters: exactly the
|
||||
# settings shown by tomo_parameters(), plus manual_shift_y/
|
||||
# tomo_stitch_overlap/corridor_size which also affect the scan but are
|
||||
# only set directly as properties. This is the full "parameter set" that
|
||||
# tomo_queue_add()/tomo_queue_execute() snapshot and restore.
|
||||
_TOMO_QUEUE_PARAM_NAMES = (
|
||||
"tomo_countingtime",
|
||||
"tomo_shellstep",
|
||||
"fovx",
|
||||
"fovy",
|
||||
"stitch_x",
|
||||
"stitch_y",
|
||||
"tomo_stitch_overlap",
|
||||
"ptycho_reconstruct_foldername",
|
||||
"manual_shift_y",
|
||||
"frames_per_trigger",
|
||||
"single_point_instead_of_fermat_scan",
|
||||
"tomo_type",
|
||||
"tomo_angle_range",
|
||||
"tomo_angle_stepsize",
|
||||
"golden_ratio_bunch_size",
|
||||
"golden_max_number_of_projections",
|
||||
"golden_projections_at_0_deg_for_damage_estimation",
|
||||
"zero_deg_reference_at_each_subtomo",
|
||||
"corridor_size",
|
||||
)
|
||||
|
||||
def tomo_queue_add(self, label: str = None) -> int:
|
||||
"""Snapshot the currently set tomo parameters and append them as a
|
||||
new job to the tomo queue (persisted, survives a kernel restart).
|
||||
|
||||
Typical usage::
|
||||
|
||||
flomni.tomo_parameters() # set up parameter set #1
|
||||
flomni.tomo_queue_add("fast overview scan")
|
||||
flomni.tomo_parameters() # change to parameter set #2
|
||||
flomni.tomo_queue_add("hires scan")
|
||||
flomni.tomo_queue_show()
|
||||
flomni.tomo_queue_execute() # runs both, in order, on this sample
|
||||
|
||||
Args:
|
||||
label: Optional name for the job, shown by tomo_queue_show().
|
||||
Defaults to "job_<n>".
|
||||
|
||||
Returns:
|
||||
The index of the newly added job.
|
||||
"""
|
||||
params = {name: getattr(self, name) for name in self._TOMO_QUEUE_PARAM_NAMES}
|
||||
index = len(self._tomo_queue_proxy)
|
||||
job = {
|
||||
"label": label or f"job_{index + 1}",
|
||||
"params": params,
|
||||
"status": "pending",
|
||||
"added_at": datetime.datetime.now().isoformat(),
|
||||
}
|
||||
self._tomo_queue_proxy.append(job)
|
||||
print(f"Added tomo queue job #{index} ({job['label']}).")
|
||||
return index
|
||||
|
||||
def tomo_queue_delete(self, *indices: int) -> None:
|
||||
"""Delete one or more jobs from the tomo queue by index.
|
||||
|
||||
Accepts any number of indices, e.g. ``flomni.tomo_queue_delete(2, 5)``
|
||||
to drop several jobs in one call. All indices are resolved against
|
||||
the queue as it currently stands and deleted highest-index-first, so
|
||||
passing several indices in any order is safe and won't shift the
|
||||
meaning of the indices still to be deleted.
|
||||
"""
|
||||
if not indices:
|
||||
print("No index given.")
|
||||
return
|
||||
for index in sorted(set(indices), reverse=True):
|
||||
job = self._tomo_queue_proxy.pop(index)
|
||||
print(f"Deleted tomo queue job #{index} ({job['label']}).")
|
||||
|
||||
def tomo_queue_clear(self) -> None:
|
||||
"""Empty the tomo queue."""
|
||||
self._tomo_queue_proxy.clear()
|
||||
print("Tomo queue cleared.")
|
||||
|
||||
def tomo_queue_show(self) -> list:
|
||||
"""Print and return the current tomo queue, one line per job."""
|
||||
jobs = self._tomo_queue_proxy.as_list()
|
||||
if not jobs:
|
||||
print("Tomo queue is empty.")
|
||||
return jobs
|
||||
for i, job in enumerate(jobs):
|
||||
p = job["params"]
|
||||
print(
|
||||
f"[{i}] {job['status']:>10s} {job['label']:<20s} "
|
||||
f"type={p['tomo_type']} fov={p['fovx']}/{p['fovy']}um "
|
||||
f"step={p['tomo_shellstep']}um ctime={p['tomo_countingtime']}s "
|
||||
f"range={p['tomo_angle_range']}deg"
|
||||
)
|
||||
return jobs
|
||||
|
||||
def tomo_queue_execute(self, start_index: int = 0) -> None:
|
||||
"""Run all pending tomo queue jobs in sequence, on the current sample.
|
||||
|
||||
For each job, restores its snapshotted parameters onto the live
|
||||
properties (exactly as if set by hand) and then calls
|
||||
``tomo_scan()`` -- or, for a job that didn't run to completion last
|
||||
time, ``tomo_scan_resume()``, so it picks back up mid-scan instead
|
||||
of restarting from subtomo 1 / angle 0. Jobs already marked "done"
|
||||
are skipped on the next call, so simply calling tomo_queue_execute()
|
||||
again resumes from where it stopped (e.g. after fixing a hardware
|
||||
issue).
|
||||
|
||||
A job is considered not-yet-complete (and so gets resumed rather
|
||||
than restarted) if its status is "incomplete" (a Python exception
|
||||
was caught and execution stopped cleanly) OR "running" (the queue
|
||||
process itself died -- killed kernel, dropped connection, power
|
||||
loss, ... -- before it had a chance to record anything; in that
|
||||
case the status is whatever was last written, which is "running",
|
||||
not "incomplete", since the except block below never got to run).
|
||||
Without treating "running" as resumable too, a real crash would
|
||||
cause this method to silently restart that job from scratch on the
|
||||
next call instead of resuming it.
|
||||
|
||||
If you've already manually called flomni.tomo_scan_resume()
|
||||
yourself to recover from a crash (bypassing the queue), that scan
|
||||
is now actually finished even though the queue still has the job
|
||||
marked "incomplete" or "running" -- mark it done yourself before
|
||||
calling this again, or it will be re-run from scratch:
|
||||
flomni._tomo_queue_proxy.update(job_index, status="done")
|
||||
|
||||
Args:
|
||||
start_index: Queue index to start from. Defaults to 0, but jobs
|
||||
already marked "done" are skipped automatically either way.
|
||||
"""
|
||||
jobs = self._tomo_queue_proxy.as_list()
|
||||
if not jobs:
|
||||
print("Tomo queue is empty.")
|
||||
return
|
||||
|
||||
if not self.OMNYTools.yesno(
|
||||
f"Starting automatic execution of {len(jobs) - start_index} queued tomo scan(s) on"
|
||||
f" sample '{self.sample_name}'. OK?",
|
||||
"y",
|
||||
):
|
||||
print("Aborted.")
|
||||
return
|
||||
|
||||
for i in range(start_index, len(jobs)):
|
||||
job = jobs[i]
|
||||
if job["status"] == "done":
|
||||
continue
|
||||
resume_job = job["status"] in ("incomplete", "running")
|
||||
|
||||
print(f"\n=== Tomo queue job {i + 1}/{len(jobs)}: {job['label']} ===")
|
||||
for name, value in job["params"].items():
|
||||
setattr(self, name, value)
|
||||
|
||||
self._tomo_queue_proxy.update(i, status="running")
|
||||
try:
|
||||
if resume_job:
|
||||
self.tomo_scan_resume()
|
||||
else:
|
||||
self.tomo_scan()
|
||||
except Exception as exc:
|
||||
self._tomo_queue_proxy.update(i, status="incomplete")
|
||||
print(f"Tomo queue job {i} ({job['label']}) did not complete: {exc}")
|
||||
print(
|
||||
"Queue paused. Fix the issue and call tomo_queue_execute() "
|
||||
"again to resume from this job."
|
||||
)
|
||||
raise
|
||||
self._tomo_queue_proxy.update(i, status="done")
|
||||
|
||||
print("\nTomo queue finished -- all jobs done.")
|
||||
|
||||
def rt_off(self):
|
||||
dev.rtx.enabled = False
|
||||
dev.rty.enabled = False
|
||||
@@ -2537,4 +3047,4 @@ if __name__ == "__main__":
|
||||
builtins.__dict__["bec"] = bec
|
||||
builtins.__dict__["umv"] = umv
|
||||
flomni = Flomni(bec)
|
||||
flomni.start_x_ray_eye_alignment()
|
||||
flomni.start_x_ray_eye_alignment()
|
||||
@@ -30,12 +30,14 @@ class FlomniOpticsMixin:
|
||||
|
||||
# move rotation stage to zero to avoid problems with wires
|
||||
umv(dev.fsamroy, 0)
|
||||
# umv(dev.fttrx1, 9.2)
|
||||
fttrx_in = self._get_user_param_safe("feyex", "fttrx_in")
|
||||
umv(dev.fttrx1, fttrx_in)
|
||||
|
||||
def feye_in(self):
|
||||
bec.queue.next_dataset_number += 1
|
||||
# umv(dev.fttrx1, -17)
|
||||
|
||||
fttrx_out = self._get_user_param_safe("feyex", "fttrx_out")
|
||||
umv(dev.fttrx1, fttrx_out)
|
||||
|
||||
feyex_in = self._get_user_param_safe("feyex", "in")
|
||||
feyey_in = self._get_user_param_safe("feyey", "in")
|
||||
|
||||
@@ -148,7 +150,6 @@ class FlomniOpticsMixin:
|
||||
# --- expected IN positions ---
|
||||
foptx_in = self._get_user_param_safe("foptx", "in")
|
||||
fopty_in = self._get_user_param_safe("fopty", "in")
|
||||
foptz_in = self._get_user_param_safe("foptz", "in")
|
||||
|
||||
# --- expected OUT condition for the X-ray eye ---
|
||||
# eye is OUT when it is *not within tolerance* of its IN position
|
||||
@@ -159,7 +160,6 @@ class FlomniOpticsMixin:
|
||||
|
||||
cx_foptx = dev.foptx.readback.get()
|
||||
cx_fopty = dev.fopty.readback.get()
|
||||
cx_foptz = dev.foptz.readback.get()
|
||||
|
||||
# --- check eye OUT ---
|
||||
eye_out = (
|
||||
@@ -169,8 +169,7 @@ class FlomniOpticsMixin:
|
||||
# --- check optics IN ---
|
||||
optics_in = (
|
||||
np.isclose(cx_foptx, foptx_in, atol=tol) and
|
||||
np.isclose(cx_fopty, fopty_in, atol=tol) and
|
||||
np.isclose(cx_foptz, foptz_in, atol=tol)
|
||||
np.isclose(cx_fopty, fopty_in, atol=tol)
|
||||
)
|
||||
|
||||
fosax_in = self._get_user_param_safe("fosax", "in")
|
||||
@@ -205,7 +204,7 @@ class FlomniOpticsMixin:
|
||||
|
||||
if mokev_val == -1:
|
||||
try:
|
||||
mokev_val = dev.mokev.readback.get()
|
||||
mokev_val = dev.ccm_energy.get().user_readback
|
||||
except:
|
||||
print(
|
||||
"Device mokev does not exist. You can specify the energy in keV as an argument instead."
|
||||
|
||||
@@ -179,6 +179,102 @@ def _gvar(bec_client, key, fmt=None, suffix=""):
|
||||
except Exception:
|
||||
return str(val)
|
||||
|
||||
def _read_beamline_states_info(bec_client) -> dict:
|
||||
"""Return a diagnostic snapshot of all configured beamline states, for
|
||||
display only (NOT used to derive the 'blocked' experiment status — that
|
||||
is computed from the queue's own locks via _read_queue_locks(), which is
|
||||
the authoritative source; see its docstring for why).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"enabled": bool | None, # bec.builtin_actors.scan_interlock.enabled
|
||||
"states": [
|
||||
{
|
||||
"name": str,
|
||||
"status": str, # 'valid' | 'invalid' | 'warning' | 'unknown'
|
||||
"label": str,
|
||||
"watched": bool, # True if present in states_watched
|
||||
"accepted": list[str] | None, # accepted statuses if watched
|
||||
"mismatched": bool, # True if watched AND status not in accepted
|
||||
},
|
||||
...
|
||||
],
|
||||
}
|
||||
|
||||
Beamline states are registered dynamically and can differ from
|
||||
experiment to experiment, so names are read from the manager's _states
|
||||
dict (the same one show_all() iterates over internally — there is no
|
||||
public "list names" method as of this BEC version). states_watched and
|
||||
enabled are read through the public scan_interlock HLI properties.
|
||||
"""
|
||||
manager = getattr(bec_client, "beamline_states", None)
|
||||
if manager is None:
|
||||
return {"enabled": None, "states": []}
|
||||
|
||||
try:
|
||||
names = list(getattr(manager, "_states", {}).keys())
|
||||
except Exception:
|
||||
names = []
|
||||
|
||||
try:
|
||||
interlock = bec_client.builtin_actors.scan_interlock
|
||||
enabled = interlock.enabled
|
||||
states_watched = interlock.states_watched or {}
|
||||
except Exception:
|
||||
enabled = None
|
||||
states_watched = {}
|
||||
|
||||
states = []
|
||||
for name in names:
|
||||
try:
|
||||
state_obj = getattr(manager, name, None)
|
||||
if state_obj is None:
|
||||
continue
|
||||
info = state_obj.get() # {"status": ..., "label": ...}
|
||||
status = info.get("status", "unknown")
|
||||
label = info.get("label", name)
|
||||
except Exception:
|
||||
status, label = "unknown", name
|
||||
|
||||
accepted = states_watched.get(name)
|
||||
watched = accepted is not None
|
||||
mismatched = watched and status not in accepted
|
||||
|
||||
states.append({
|
||||
"name": name,
|
||||
"status": status,
|
||||
"label": label,
|
||||
"watched": watched,
|
||||
"accepted": accepted,
|
||||
"mismatched": mismatched,
|
||||
})
|
||||
|
||||
return {"enabled": enabled, "states": states}
|
||||
|
||||
|
||||
def _read_queue_locks(primary) -> list:
|
||||
"""Return a list of {"identifier": str, "reason": str} for every lock
|
||||
currently applied to the primary scan queue.
|
||||
|
||||
BEC's scan_queue.status becomes "LOCKED" (saving the prior status to
|
||||
restore later) whenever any lock is added, e.g. by the ScanInterlockActor
|
||||
when a watched beamline state goes out of spec (see scan_interlock.py:
|
||||
add_queue_lock(queue="primary", reason=..., lock_id="ScanInterlockActor")).
|
||||
Reading the lock list directly is more robust than re-deriving "blocked"
|
||||
from beamline states ourselves: it reflects whatever is actually holding
|
||||
the queue, regardless of which actor (interlock or otherwise) caused it,
|
||||
and survives changes to which beamline states are configured/watched.
|
||||
"""
|
||||
if primary is None:
|
||||
return []
|
||||
try:
|
||||
return [
|
||||
{"identifier": lock.identifier, "reason": lock.reason}
|
||||
for lock in (primary.locks or [])
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status derivation
|
||||
@@ -189,19 +285,27 @@ def _derive_status(
|
||||
queue_has_active_scan: bool,
|
||||
last_active_time,
|
||||
had_activity: bool,
|
||||
queue_locks: list | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Returns one of:
|
||||
scanning -- tomo heartbeat fresh (< _TOMO_HEARTBEAT_STALE_S), OR
|
||||
heartbeat recently seen AND queue still has active scan
|
||||
(handles long individual projections > heartbeat timeout)
|
||||
running -- queue has active scan but heartbeat has never been seen
|
||||
blocked -- queue has active scan, heartbeat stale (beyond the long-
|
||||
projection grace window), AND the primary queue has at
|
||||
least one lock applied (e.g. by BEC's ScanInterlockActor
|
||||
when a watched beamline state goes out of spec). Only
|
||||
reported when it is actually preventing a queued/active
|
||||
scan from progressing.
|
||||
running -- queue has active scan but heartbeat has never been seen,
|
||||
and the queue is not locked
|
||||
idle -- not scanning, last_active_time known
|
||||
unknown -- no activity ever seen since generator started
|
||||
|
||||
'unknown' is ONLY returned before any scan activity has been observed.
|
||||
Once activity has been seen the status goes directly:
|
||||
scanning/running -> idle
|
||||
scanning/running/blocked -> idle
|
||||
never through 'unknown'.
|
||||
"""
|
||||
hb_age = _heartbeat_age_s(progress.get("heartbeat"))
|
||||
@@ -213,6 +317,8 @@ def _derive_status(
|
||||
if queue_has_active_scan and hb_age < _TOMO_HEARTBEAT_STALE_S * 10:
|
||||
return "scanning"
|
||||
if queue_has_active_scan:
|
||||
if queue_locks:
|
||||
return "blocked"
|
||||
return "running"
|
||||
if last_active_time is not None or had_activity:
|
||||
return "idle"
|
||||
@@ -852,7 +958,10 @@ class WebpageGeneratorBase:
|
||||
progress = self._bec.get_global_var("tomo_progress") or {}
|
||||
|
||||
# ── Queue ────────────────────────────────────────────────────
|
||||
# queue_status is always 'RUNNING' while BEC is alive.
|
||||
# queue_status reflects BEC's ScanQueueStatus: 'RUNNING', 'PAUSED',
|
||||
# or 'LOCKED' (the latter set whenever any lock — e.g. from
|
||||
# ScanInterlockActor — is applied to the queue; the prior status is
|
||||
# restored once the lock is released).
|
||||
# A scan is actually executing only when info is non-empty AND
|
||||
# active_request_block is set on the first entry.
|
||||
try:
|
||||
@@ -864,9 +973,30 @@ class WebpageGeneratorBase:
|
||||
and len(primary.info) > 0
|
||||
and primary.info[0].active_request_block is not None
|
||||
)
|
||||
queue_locks = _read_queue_locks(primary)
|
||||
except Exception:
|
||||
queue_status = "unknown"
|
||||
queue_has_active_scan = False
|
||||
queue_locks = []
|
||||
|
||||
# ── Beamline states (diagnostic display only; not used for the
|
||||
# 'blocked' experiment_status decision — see _read_queue_locks) ──
|
||||
try:
|
||||
beamline_states_info = _read_beamline_states_info(self._bec)
|
||||
except Exception as exc:
|
||||
self._log(VERBOSITY_VERBOSE,
|
||||
f"beamline_states read error: {exc}", level="warning")
|
||||
beamline_states_info = {"enabled": None, "states": []}
|
||||
|
||||
# Current scan number (the BEC scan in progress right now, e.g. for
|
||||
# comparison against ptycho reconstruction filenames like S06770).
|
||||
# Only meaningful while a scan is actually active; None otherwise.
|
||||
current_scan_number = None
|
||||
if queue_has_active_scan:
|
||||
try:
|
||||
current_scan_number = primary.info[0].active_request_block.scan_number
|
||||
except Exception:
|
||||
current_scan_number = None
|
||||
|
||||
# ── Idle tracking ────────────────────────────────────────────
|
||||
hb_age = _heartbeat_age_s(progress.get("heartbeat"))
|
||||
@@ -893,6 +1023,7 @@ class WebpageGeneratorBase:
|
||||
exp_status = _derive_status(
|
||||
progress, queue_has_active_scan,
|
||||
self._last_active_time, self._had_activity,
|
||||
queue_locks,
|
||||
)
|
||||
idle_for_s = (
|
||||
None if self._last_active_time is None
|
||||
@@ -928,6 +1059,8 @@ class WebpageGeneratorBase:
|
||||
"queue_has_active_scan": queue_has_active_scan,
|
||||
"idle_for_s": idle_for_s,
|
||||
"idle_for_human": _format_duration(idle_for_s),
|
||||
"queue_locks": queue_locks,
|
||||
"beamline_states": beamline_states_info,
|
||||
"progress": {
|
||||
"tomo_type": progress.get("tomo_type", "N/A"),
|
||||
"projection": progress.get("projection", 0),
|
||||
@@ -937,10 +1070,13 @@ class WebpageGeneratorBase:
|
||||
"subtomo_total_projections": progress.get("subtomo_total_projections", 1),
|
||||
"angle": progress.get("angle", 0),
|
||||
"tomo_start_time": progress.get("tomo_start_time"),
|
||||
"tomo_start_scan_number": progress.get("tomo_start_scan_number"),
|
||||
"current_scan_number": current_scan_number,
|
||||
"estimated_remaining_s": progress.get("estimated_remaining_time"),
|
||||
"estimated_remaining_human": _format_duration(
|
||||
progress.get("estimated_remaining_time")
|
||||
),
|
||||
"estimated_finish_time": progress.get("estimated_finish_time"),
|
||||
"tomo_heartbeat": progress.get("heartbeat"),
|
||||
"tomo_heartbeat_age_s": round(hb_age, 1) if hb_age != float("inf") else None,
|
||||
},
|
||||
@@ -985,12 +1121,16 @@ class WebpageGeneratorBase:
|
||||
self._uploader.upload_changed_async(self._output_dir)
|
||||
|
||||
# ── Console feedback ──────────────────────────────────────────
|
||||
blocked_summary = (
|
||||
f" locked_by={','.join(l['identifier'] for l in queue_locks)}"
|
||||
if queue_locks else ""
|
||||
)
|
||||
self._log(VERBOSITY_VERBOSE,
|
||||
f"[{_now_iso()}] {exp_status:<12} active={queue_has_active_scan} "
|
||||
f"proj={payload['progress']['projection']}/"
|
||||
f"{payload['progress']['total_projections']} "
|
||||
f"hb={payload['progress']['tomo_heartbeat_age_s']}s "
|
||||
f"idle={_format_duration(idle_for_s)}")
|
||||
f"idle={_format_duration(idle_for_s)}" + blocked_summary)
|
||||
self._log(VERBOSITY_DEBUG,
|
||||
f" payload:\n{json.dumps(payload, indent=4, default=str)}")
|
||||
|
||||
@@ -1331,6 +1471,8 @@ def _render_html(phone_numbers: list) -> str:
|
||||
--c-scanning: #89dceb;
|
||||
--c-running: #a6e3a1;
|
||||
--c-idle-short: #f9e2af;
|
||||
--c-idle-long: #f38ba8;
|
||||
--c-blocked: #fab387;
|
||||
--c-error: #f38ba8;
|
||||
--status-color: #6c7a9c;
|
||||
--ring-blend: #4a5568;
|
||||
@@ -1373,6 +1515,7 @@ def _render_html(phone_numbers: list) -> str:
|
||||
body.scanning {{ --status-color: var(--c-scanning); --ring-blend: #3a6b74; }}
|
||||
body.running {{ --status-color: var(--c-running); --ring-blend: #3a6644; }}
|
||||
body.idle {{ --status-color: var(--c-idle-short); --ring-blend: #7a6e44; }}
|
||||
body.blocked {{ --status-color: var(--c-blocked); --ring-blend: #7a5a3a; }}
|
||||
body.error {{ --status-color: var(--c-error); --ring-blend: #7a3a44; }}
|
||||
body.unknown {{ --status-color: var(--text-dim); --ring-blend: #4a5568; }}
|
||||
|
||||
@@ -1585,6 +1728,36 @@ def _render_html(phone_numbers: list) -> str:
|
||||
border-radius: 4px; box-shadow: 0 8px 40px rgba(0,0,0,0.6);
|
||||
}}
|
||||
|
||||
/* Help dialog */
|
||||
#help-dialog {{
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 1.5rem 1.75rem; max-width: 560px; width: 90vw;
|
||||
max-height: 80vh; overflow-y: auto;
|
||||
font-family: var(--sans); font-weight: 300;
|
||||
}}
|
||||
#help-dialog::backdrop {{ background: rgba(0,0,0,0.6); }}
|
||||
.help-header {{
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border); padding-bottom: 0.75rem; margin-bottom: 1rem;
|
||||
}}
|
||||
.help-close {{
|
||||
background: none; border: none; color: var(--text-dim);
|
||||
font-size: 1.4rem; line-height: 1; padding: 0 0.3rem; min-height: auto;
|
||||
cursor: pointer;
|
||||
}}
|
||||
.help-close:hover {{ color: var(--text); background: none; }}
|
||||
.help-body h3 {{
|
||||
font-family: var(--mono); font-size: 0.7rem; font-weight: 700;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim);
|
||||
margin: 1.1rem 0 0.5rem;
|
||||
}}
|
||||
.help-body h3:first-child {{ margin-top: 0; }}
|
||||
.help-body p {{ font-size: 0.85rem; line-height: 1.6; color: var(--text-dim); margin-bottom: 0.5rem; }}
|
||||
.help-body ul {{ margin: 0 0 0.5rem 1.1rem; padding: 0; }}
|
||||
.help-body li {{ font-size: 0.85rem; line-height: 1.6; color: var(--text-dim); margin-bottom: 0.4rem; }}
|
||||
.help-body strong {{ color: var(--text); font-weight: 600; }}
|
||||
|
||||
/* ── Instrument details ── */
|
||||
.instrument-grid {{
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem 2rem;
|
||||
@@ -1602,6 +1775,37 @@ def _render_html(phone_numbers: list) -> str:
|
||||
.kv-table td:last-child {{ color: var(--text); font-weight: 600; text-align: right; }}
|
||||
.temp-null {{ color: var(--text-dim) !important; font-weight: 400 !important; }}
|
||||
|
||||
/* ── Beamline states ── */
|
||||
.blstates-summary {{
|
||||
font-size: 0.82rem; color: var(--text-dim); margin-bottom: 0.9rem;
|
||||
}}
|
||||
.blstates-summary strong {{ color: var(--text); font-weight: 600; }}
|
||||
.blstates-table {{ width: 100%; border-collapse: collapse; }}
|
||||
.blstates-table th {{
|
||||
font-family: var(--mono); font-size: 0.6rem; font-weight: 700;
|
||||
letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-dim);
|
||||
text-align: left; padding: 0 0.6rem 0.5rem 0; border-bottom: 1px solid var(--border);
|
||||
}}
|
||||
.blstates-table td {{
|
||||
padding: 0.45rem 0.6rem 0.45rem 0; font-size: 0.82rem;
|
||||
border-bottom: 1px solid var(--border); vertical-align: middle;
|
||||
}}
|
||||
.blstates-table tr:last-child td {{ border-bottom: none; }}
|
||||
.blstates-table tr.mismatched td {{ color: var(--c-blocked); }}
|
||||
.bl-name {{ font-family: var(--mono); font-size: 0.78rem; color: var(--text); white-space: nowrap; }}
|
||||
.bl-badge {{
|
||||
display: inline-block; font-family: var(--mono); font-size: 0.6rem; font-weight: 700;
|
||||
letter-spacing: 0.06em; text-transform: uppercase; padding: 0.15rem 0.5rem;
|
||||
border-radius: 100px; white-space: nowrap;
|
||||
}}
|
||||
.bl-badge.bl-valid {{ background: color-mix(in srgb, var(--c-running) 22%, transparent); color: var(--c-running); }}
|
||||
.bl-badge.bl-invalid {{ background: color-mix(in srgb, var(--c-error) 22%, transparent); color: var(--c-error); }}
|
||||
.bl-badge.bl-warning {{ background: color-mix(in srgb, var(--c-idle-short) 25%, transparent); color: var(--c-idle-short); }}
|
||||
.bl-badge.bl-unknown {{ background: var(--surface2); color: var(--text-dim); }}
|
||||
.bl-badge.bl-watched-yes {{ background: color-mix(in srgb, var(--c-scanning) 18%, transparent); color: var(--c-scanning); }}
|
||||
.bl-badge.bl-watched-no {{ background: var(--surface2); color: var(--text-dim); }}
|
||||
.blstates-none {{ font-family: var(--mono); font-size: 0.75rem; color: var(--text-dim); padding: 0.5rem 0; }}
|
||||
|
||||
/* ── Audio card ── */
|
||||
.audio-card {{
|
||||
display: flex; align-items: center;
|
||||
@@ -1628,6 +1832,9 @@ def _render_html(phone_numbers: list) -> str:
|
||||
.led.led-warning {{ background: var(--c-idle-long); border-color: var(--c-idle-long);
|
||||
box-shadow: 0 0 8px var(--c-idle-long);
|
||||
animation: led-pulse 1s ease-in-out infinite; }}
|
||||
.led.led-blocked {{ background: var(--c-blocked); border-color: var(--c-blocked);
|
||||
box-shadow: 0 0 8px var(--c-blocked);
|
||||
animation: led-pulse 1s ease-in-out infinite; }}
|
||||
@keyframes led-pulse {{
|
||||
0%,100% {{ opacity: 1; }} 50% {{ opacity: 0.4; }}
|
||||
}}
|
||||
@@ -1716,6 +1923,7 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<span class="logo-suffix">· STATUS</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="theme-btn" id="help-btn" onclick="openHelp()" title="What does this page show?">?</button>
|
||||
<div class="theme-switcher">
|
||||
<span class="ts-label">Theme</span>
|
||||
<button class="theme-btn" id="theme-auto" onclick="setTheme('auto')" >Auto</button>
|
||||
@@ -1753,7 +1961,8 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<div class="info-item"><span class="label">Sub-tomo</span><span class="value" id="pi-subtomo">-</span></div>
|
||||
<div class="info-item"><span class="label">Angle</span><span class="value" id="pi-angle">-</span></div>
|
||||
<div class="info-item"><span class="label">Tomo type</span><span class="value" id="pi-type">-</span></div>
|
||||
<div class="info-item"><span class="label">ETA</span><span class="value" id="pi-eta">-</span></div>
|
||||
<div class="info-item"><span class="label">Remaining</span><span class="value" id="pi-remaining">-</span></div>
|
||||
<div class="info-item"><span class="label">Finish time</span><span class="value" id="pi-finish">-</span></div>
|
||||
<div class="info-item"><span class="label">Started</span><span class="value" id="pi-start">-</span></div>
|
||||
</div>
|
||||
|
||||
@@ -1765,6 +1974,39 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<img id="ptycho-lightbox-img" src="" alt="">
|
||||
</div>
|
||||
|
||||
<!-- Help dialog -->
|
||||
<dialog id="help-dialog">
|
||||
<div class="help-header">
|
||||
<span class="card-title" style="border-bottom:none;margin-bottom:0;padding-bottom:0">About this page</span>
|
||||
<button class="help-close" onclick="closeHelp()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="help-body">
|
||||
<h3>Measurement status</h3>
|
||||
<p>The coloured pill at the top reflects what the instrument is currently doing:</p>
|
||||
<ul>
|
||||
<li><strong>Scanning</strong> — a tomography scan is actively running (a heartbeat from the scan loop has been seen recently).</li>
|
||||
<li><strong>Running</strong> — the scan queue has an active item, but it is not a tomo scan with a heartbeat (e.g. an alignment scan).</li>
|
||||
<li><strong>Blocked</strong> — a scan is queued or active, but the scan queue itself has been locked (for example by BEC's scan interlock when a watched beamline condition, such as the shutter or ring current, is out of spec). This is usually external and self-resolves once the condition clears.</li>
|
||||
<li><strong>Idle</strong> — nothing is running or queued right now.</li>
|
||||
<li><strong>Unknown</strong> — shown only before any activity has been observed since the generator started.</li>
|
||||
</ul>
|
||||
<h3>Tomography progress</h3>
|
||||
<p>The rings show overall progress (outer) and progress within the current sub-tomogram (inner). <strong>Remaining</strong> is the estimated duration left; <strong>Finish time</strong> is the estimated wall-clock time the measurement will complete. The projection count is followed by the BEC scan number(s) in parentheses, e.g. (S06650→S06770), for direct comparison with reconstruction filenames.</p>
|
||||
<h3>Audio warnings</h3>
|
||||
<ul>
|
||||
<li><strong>System LED</strong> — on when audio is enabled on this device.</li>
|
||||
<li><strong>Watch LED</strong> — armed (green) while a scan is running; pulses orange if a scan stops unexpectedly, with a chime every 30s until confirmed.</li>
|
||||
<li><strong>Blocked LED</strong> — pulses orange while the queue is locked. A chime fires automatically after 60 continuous minutes blocked, then repeats hourly until cleared. No confirmation needed — it clears itself once the block resolves.</li>
|
||||
<li><strong>Live LED</strong> — green while this page's data feed is fresh; pulses orange if the feed goes stale or a fetch fails.</li>
|
||||
</ul>
|
||||
<p>On iPhone/iPad, tap <strong>Enable</strong> once to unlock audio — this is a browser requirement and only needs to be done once per visit.</p>
|
||||
<h3>Beamline states</h3>
|
||||
<p>Lists every beamline condition currently registered with BEC, its live status (valid/invalid/warning/unknown), and whether the scan interlock is <strong>watching</strong> it. A watched state that is not in its accepted status is what actually causes the <strong>Blocked</strong> status above — states that are not watched (like an unrelated simulated shutter) can be invalid without blocking anything.</p>
|
||||
<h3>Other features</h3>
|
||||
<p>Cards below the fixed status/progress section can be dragged by their ⋮⋮ handle to reorder; the order is remembered on this device. The theme switcher (top right) follows your OS by default, or can be forced to light/dark.</p>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- Draggable cards container -->
|
||||
<div id="card-container">
|
||||
|
||||
@@ -1780,6 +2022,10 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<div class="led" id="led-watch"></div>
|
||||
<span class="led-label">Watch</span>
|
||||
</div>
|
||||
<div class="led-group">
|
||||
<div class="led" id="led-blocked"></div>
|
||||
<span class="led-label">Blocked</span>
|
||||
</div>
|
||||
<div class="led-group">
|
||||
<div class="led" id="led-conn"></div>
|
||||
<span class="led-label">Live</span>
|
||||
@@ -1822,7 +2068,20 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<div class="instrument-grid" id="instrument-grid"></div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Contacts -->
|
||||
<!-- 4. Beamline states -->
|
||||
<div class="card draggable-card" id="blstates-card" data-card-id="blstates">
|
||||
<div class="drag-handle" title="Drag to reorder">⋮⋮</div>
|
||||
<div class="card-title">Beamline states</div>
|
||||
<div class="blstates-summary" id="blstates-summary"></div>
|
||||
<table class="blstates-table" id="blstates-table">
|
||||
<thead>
|
||||
<tr><th>State</th><th>Status</th><th>Watched</th></tr>
|
||||
</thead>
|
||||
<tbody id="blstates-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 5. Contacts -->
|
||||
<div class="card draggable-card" data-card-id="contacts">
|
||||
<div class="drag-handle" title="Drag to reorder">⋮⋮</div>
|
||||
<div class="card-title">Contacts</div>
|
||||
@@ -1860,7 +2119,7 @@ function setTheme(t) {{
|
||||
|
||||
// ── Drag-and-drop card ordering ──────────────────────────────────────────
|
||||
const CARD_ORDER_KEY = 'cardOrder';
|
||||
const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','contacts'];
|
||||
const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','blstates','contacts'];
|
||||
let _dragSrc = null;
|
||||
|
||||
function savedOrder() {{
|
||||
@@ -1932,6 +2191,9 @@ initDrag();
|
||||
let audioCtx=null, audioEnabled=false;
|
||||
let audioArmed=false, warningActive=false, warningTimer=null, lastStatus=null;
|
||||
let staleActive=false, staleTimer=null, staleConfirmed=false;
|
||||
let blockedSince=null, blockedWarningActive=false, blockedChimeTimer=null;
|
||||
const BLOCKED_WARNING_DELAY_MS=60*60*1000; // first chime after 60 min continuously blocked
|
||||
const BLOCKED_CHIME_REPEAT_MS=60*60*1000; // repeat every 60 min while still blocked
|
||||
|
||||
function getCtx(){{
|
||||
if(!audioCtx) audioCtx=new(window.AudioContext||window.webkitAudioContext)();
|
||||
@@ -1967,6 +2229,12 @@ function staleChime(){{
|
||||
setTimeout(()=>beep(1200,0.12,0.35),180);
|
||||
setTimeout(()=>beep(1200,0.25,0.35),360);
|
||||
}}
|
||||
function blockedChime(){{
|
||||
// Distinct from warningChime/staleChime: a slow low->low->high triple knock.
|
||||
beep(330,0.25,0.4);
|
||||
setTimeout(()=>beep(330,0.25,0.4),300);
|
||||
setTimeout(()=>beep(523,0.4, 0.4),600);
|
||||
}}
|
||||
|
||||
function testSound(){{
|
||||
// Gesture handler — unlock first, then delay beeps 80ms for resume().
|
||||
@@ -1986,8 +2254,10 @@ function toggleAudio(){{
|
||||
document.getElementById('btn-confirm').style.display='none';
|
||||
stopStaleWarning(); staleActive=false; staleConfirmed=false;
|
||||
document.getElementById('btn-confirm-stale').style.display='none';
|
||||
stopBlockedChime(); blockedWarningActive=false;
|
||||
}} else {{
|
||||
if(lastStatus==='scanning' && !audioArmed) audioArmed=true;
|
||||
if(blockedSince!==null && blockedChimeTimer===null) scheduleBlockedChime();
|
||||
}}
|
||||
updateAudioUI();
|
||||
}}
|
||||
@@ -2048,9 +2318,45 @@ function handleStale(isStale){{
|
||||
}}
|
||||
}}
|
||||
|
||||
function stopBlockedChime(){{
|
||||
if(blockedChimeTimer){{clearTimeout(blockedChimeTimer);blockedChimeTimer=null;}}
|
||||
}}
|
||||
|
||||
function scheduleBlockedChime(){{
|
||||
// Fires once after BLOCKED_WARNING_DELAY_MS of continuous 'blocked' status,
|
||||
// then repeats every BLOCKED_CHIME_REPEAT_MS for as long as it stays blocked.
|
||||
// No confirm button — purely informational, auto-clears on recovery, since
|
||||
// blocked states are external (beamline) and typically self-resolve.
|
||||
blockedChimeTimer=setTimeout(()=>{{
|
||||
if(audioEnabled) blockedChime();
|
||||
blockedWarningActive=true;
|
||||
updateAudioUI();
|
||||
scheduleBlockedChime();
|
||||
}}, blockedWarningActive ? BLOCKED_CHIME_REPEAT_MS : BLOCKED_WARNING_DELAY_MS);
|
||||
}}
|
||||
|
||||
function handleBlocked(isBlocked){{
|
||||
if(isBlocked){{
|
||||
if(blockedSince===null){{
|
||||
blockedSince=Date.now();
|
||||
blockedWarningActive=false;
|
||||
stopBlockedChime();
|
||||
if(audioEnabled) scheduleBlockedChime();
|
||||
}}
|
||||
}}else{{
|
||||
if(blockedSince!==null){{
|
||||
blockedSince=null;
|
||||
blockedWarningActive=false;
|
||||
stopBlockedChime();
|
||||
updateAudioUI();
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
|
||||
function updateAudioUI(){{
|
||||
const ledSys=document.getElementById('led-system'),
|
||||
ledWatch=document.getElementById('led-watch'),
|
||||
ledBlocked=document.getElementById('led-blocked'),
|
||||
ledConn=document.getElementById('led-conn'),
|
||||
btn=document.getElementById('btn-toggle'),
|
||||
txt=document.getElementById('audio-text');
|
||||
@@ -2060,6 +2366,7 @@ function updateAudioUI(){{
|
||||
btn.classList.toggle('active',audioEnabled);
|
||||
|
||||
ledConn.className='led'+(staleActive?' led-warning':' led-live');
|
||||
ledBlocked.className='led'+(blockedSince!==null?' led-blocked':'');
|
||||
|
||||
const scanRunning=(lastStatus==='scanning');
|
||||
if(!audioEnabled){{
|
||||
@@ -2074,6 +2381,11 @@ function updateAudioUI(){{
|
||||
}}else if(staleActive){{
|
||||
ledWatch.className='led';
|
||||
txt.textContent='Live feed lost \u2014 confirm to silence';
|
||||
}}else if(blockedSince!==null){{
|
||||
ledWatch.className='led';
|
||||
txt.textContent=blockedWarningActive
|
||||
? 'Beamline blocked \u2014 chiming hourly until cleared'
|
||||
: 'Beamline blocked \u2014 will chime after 60 min if unresolved';
|
||||
}}else if(audioArmed && scanRunning){{
|
||||
ledWatch.className='led led-armed';
|
||||
txt.textContent='Armed \u2014 will warn when measurement stops';
|
||||
@@ -2107,6 +2419,17 @@ function handleAudioForStatus(status, prevStatus){{
|
||||
}}
|
||||
}}
|
||||
|
||||
// ── Help dialog ──────────────────────────────────────────────────────────
|
||||
function openHelp(){{
|
||||
document.getElementById('help-dialog').showModal();
|
||||
}}
|
||||
function closeHelp(){{
|
||||
document.getElementById('help-dialog').close();
|
||||
}}
|
||||
document.getElementById('help-dialog').addEventListener('click', e=>{{
|
||||
if(e.target.id==='help-dialog') closeHelp(); // click on backdrop area
|
||||
}});
|
||||
|
||||
// ── Ptychography images ────────────────────────────────────────────
|
||||
let _ptychoScanId = null;
|
||||
|
||||
@@ -2157,11 +2480,13 @@ function renderPtycho(ptycho){{
|
||||
}}
|
||||
|
||||
// ── Status rendering ──────────────────────────────────────────────────────
|
||||
const LABELS={{scanning:'SCANNING',running:'RUNNING',idle:'IDLE',error:'STOPPED',unknown:'UNKNOWN'}};
|
||||
const LABELS={{scanning:'SCANNING',running:'RUNNING',idle:'IDLE',blocked:'BLOCKED',error:'STOPPED',unknown:'UNKNOWN'}};
|
||||
const DETAILS={{
|
||||
scanning: d=>'Tomo scan in progress · projection '+(d.progress.projection||0)+' of '+(d.progress.total_projections||0)+' · '+(d.progress.tomo_type||''),
|
||||
running: d=>'Queue active · outside tomo heartbeat window',
|
||||
idle: d=>'Idle for <strong>'+d.idle_for_human+'</strong>',
|
||||
blocked: d=>'Queue locked'+((d.queue_locks&&d.queue_locks.length>1)?' by multiple locks':'')+': <strong>'
|
||||
+(d.queue_locks||[]).map(l=>esc(l.reason||l.identifier)).join('; ')+'</strong>',
|
||||
error: d=>'Queue stopped unexpectedly · idle for <strong>'+(d.idle_for_human||'?')+'</strong>',
|
||||
unknown: d=>'Waiting for first data\u2026',
|
||||
}};
|
||||
@@ -2169,6 +2494,12 @@ const DETAILS={{
|
||||
function setRing(id,circ,pct){{document.getElementById(id).style.strokeDashoffset=circ*(1-Math.min(Math.max(pct,0),1));}}
|
||||
function fmtAngle(v){{const n=parseFloat(v);return isNaN(n)?'-':n.toFixed(2)+'\u00b0';}}
|
||||
function fmtTime(iso){{if(!iso)return'-';try{{return new Date(iso).toLocaleTimeString([],{{hour:'2-digit',minute:'2-digit'}});}}catch{{return iso;}}}}
|
||||
function fmtScan(n){{
|
||||
if(n==null) return null;
|
||||
const i=parseInt(n,10);
|
||||
if(isNaN(i)) return null;
|
||||
return 'S'+String(i).padStart(5,'0');
|
||||
}}
|
||||
function esc(s){{return String(s==null?'N/A':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}}
|
||||
|
||||
function renderInstrument(setup){{
|
||||
@@ -2192,6 +2523,38 @@ function renderInstrument(setup){{
|
||||
grid.innerHTML=html;
|
||||
}}
|
||||
|
||||
function renderBeamlineStates(bl){{
|
||||
const summary=document.getElementById('blstates-summary'),
|
||||
tbody=document.getElementById('blstates-tbody');
|
||||
const states=(bl&&bl.states)||[];
|
||||
const enabledTxt = bl&&bl.enabled===true ? 'enabled' : (bl&&bl.enabled===false ? 'disabled' : 'unknown');
|
||||
const mismatched = states.filter(s=>s.mismatched);
|
||||
|
||||
if(states.length===0){{
|
||||
summary.innerHTML='Scan interlock <strong>'+enabledTxt+'</strong> · no beamline states configured';
|
||||
tbody.innerHTML='<tr><td colspan="4"><div class="blstates-none">No beamline states configured</div></td></tr>';
|
||||
return;
|
||||
}}
|
||||
|
||||
summary.innerHTML = 'Scan interlock <strong>'+enabledTxt+'</strong>'
|
||||
+ (mismatched.length>0
|
||||
? ' · <strong>'+mismatched.length+'</strong> watched state'+(mismatched.length>1?'s':'')+' currently blocking'
|
||||
: ' · all watched states OK');
|
||||
|
||||
let html='';
|
||||
states.forEach(s=>{{
|
||||
const statusCls='bl-badge bl-'+(s.status||'unknown');
|
||||
const watchedCls='bl-badge '+(s.watched?'bl-watched-yes':'bl-watched-no');
|
||||
const rowCls=s.mismatched?' class="mismatched"':'';
|
||||
html+='<tr'+rowCls+'>'
|
||||
+'<td class="bl-name">'+esc(s.name)+'</td>'
|
||||
+'<td><span class="'+statusCls+'">'+esc(s.status)+'</span></td>'
|
||||
+'<td><span class="'+watchedCls+'">'+(s.watched?'watched':'not watched')+'</span></td>'
|
||||
+'</tr>';
|
||||
}});
|
||||
tbody.innerHTML=html;
|
||||
}}
|
||||
|
||||
function render(d){{
|
||||
const s=d.experiment_status||'unknown',p=d.progress||{{}};
|
||||
document.body.className=s;
|
||||
@@ -2201,11 +2564,17 @@ function render(d){{
|
||||
const sPct=p.subtomo_total_projections>0?p.subtomo_projection/p.subtomo_total_projections:0;
|
||||
setRing('ring-outer',289.03,oPct); setRing('ring-inner',213.63,sPct);
|
||||
document.getElementById('ring-pct').textContent=Math.round(oPct*100)+'%';
|
||||
document.getElementById('pi-proj').textContent=(p.projection||0)+' / '+(p.total_projections||0);
|
||||
let projText=(p.projection||0)+' / '+(p.total_projections||0);
|
||||
const startScan=fmtScan(p.tomo_start_scan_number), curScan=fmtScan(p.current_scan_number);
|
||||
if(startScan && curScan && startScan!==curScan) projText+=' ('+startScan+'\u2192'+curScan+')';
|
||||
else if(curScan) projText+=' ('+curScan+')';
|
||||
else if(startScan) projText+=' ('+startScan+')';
|
||||
document.getElementById('pi-proj').textContent=projText;
|
||||
document.getElementById('pi-subtomo').textContent=p.subtomo||'-';
|
||||
document.getElementById('pi-angle').textContent=fmtAngle(p.angle);
|
||||
document.getElementById('pi-type').textContent=p.tomo_type||'-';
|
||||
document.getElementById('pi-eta').textContent=p.estimated_remaining_human||'-';
|
||||
document.getElementById('pi-remaining').textContent=p.estimated_remaining_human||'-';
|
||||
document.getElementById('pi-finish').textContent=fmtTime(p.estimated_finish_time);
|
||||
document.getElementById('pi-start').textContent=fmtTime(p.tomo_start_time);
|
||||
|
||||
if(d.recon){{
|
||||
@@ -2216,18 +2585,21 @@ function render(d){{
|
||||
document.getElementById('recon-path').textContent=d.recon.folder_path||'';
|
||||
}}
|
||||
renderInstrument(d.setup);
|
||||
renderBeamlineStates(d.beamline_states);
|
||||
renderPtycho(d.ptycho);
|
||||
document.getElementById('last-update').textContent='updated '+new Date(d.generated_at).toLocaleTimeString();
|
||||
const ageS=(Date.now()/1000)-d.generated_at_epoch;
|
||||
const isStale=ageS>STALE_S;
|
||||
document.getElementById('outdated-banner').classList.toggle('visible',isStale);
|
||||
handleStale(isStale);
|
||||
handleBlocked(s==='blocked');
|
||||
document.getElementById('footer-gen').textContent='generator: '+((d.generator||{{}}).owner_id||'-');
|
||||
document.getElementById('footer-hb').textContent='tomo_heartbeat: '+(p.tomo_heartbeat_age_s!=null?p.tomo_heartbeat_age_s+'s ago':'none');
|
||||
|
||||
const prevStatus=lastStatus;
|
||||
lastStatus=s;
|
||||
handleAudioForStatus(s, prevStatus);
|
||||
updateAudioUI();
|
||||
}}
|
||||
|
||||
let _fetchFailCount=0;
|
||||
|
||||
@@ -31,14 +31,30 @@ class XrayEyeAlign:
|
||||
test_wo_movements = False
|
||||
PIXEL_CALIBRATION = 0.1 / 113 # .2 with binning
|
||||
|
||||
# Sign for the automatic vertical-centering move in the height-centering
|
||||
# branch of _align_impl (search for `_height_centered`).
|
||||
|
||||
HEIGHT_CENTERING_SIGN = -1
|
||||
|
||||
def __init__(self, client, flomni: Flomni) -> None:
|
||||
self.client = client
|
||||
self.flomni = flomni
|
||||
self.device_manager = client.device_manager
|
||||
self.scans = client.scans
|
||||
self.alignment_values = {}
|
||||
self.flomni.reset_correction()
|
||||
self.flomni.reset_tomo_alignment_fit()
|
||||
# Deliberately NOT calling self.flomni.reset_correction() /
|
||||
# reset_tomo_alignment_fit() here: XrayEyeAlign is constructed every
|
||||
# time Flomni() is instantiated (i.e. every new client session, not
|
||||
# just every alignment run), so doing the reset here was silently
|
||||
# wiping corr_pos_y/corr_angle_y/corr_pos_y_2/corr_angle_y_2 and the
|
||||
# stored tomo_alignment_fit on every restart -- corr_pos_y/
|
||||
# corr_angle_y appeared to survive only because reset_correction()
|
||||
# immediately reloads the default file into them; corr_pos_y_2/
|
||||
# corr_angle_y_2 have no such reload, so the wipe was directly
|
||||
# visible there. These resets belong at the start of an actual
|
||||
# alignment run instead (_align_impl(), below) - matching what
|
||||
# start_x_ray_eye_alignment() already tells the user is happening:
|
||||
# "Deleting any potential existing alignment for this sample."
|
||||
|
||||
@property
|
||||
def gui(self):
|
||||
@@ -47,6 +63,11 @@ class XrayEyeAlign:
|
||||
def _reset_init_values(self):
|
||||
self.shift_xy = [0, 0]
|
||||
self._xray_fov_xy = [0, 0]
|
||||
# Guards the height-centering branch below: True once the automatic
|
||||
# vertical-centering move has been applied for this alignment run,
|
||||
# so a second submission at step==1 is treated as the real angle-0
|
||||
# fit point instead of triggering another height correction.
|
||||
self._height_centered = False
|
||||
|
||||
def update_frame(self, keep_shutter_open=False):
|
||||
if self.flomni._flomnigui_check_attribute_not_exists("xeyegui"):
|
||||
@@ -59,7 +80,7 @@ class XrayEyeAlign:
|
||||
|
||||
dev.fsh.fshopen()
|
||||
|
||||
time.sleep(0.5)
|
||||
time.sleep(1)
|
||||
# stop live view
|
||||
if not keep_shutter_open:
|
||||
self.gui.on_live_view_enabled(False)
|
||||
@@ -94,6 +115,8 @@ class XrayEyeAlign:
|
||||
def align(self, keep_shutter_open=False):
|
||||
self.flomni.flomnigui_show_xeyealign()
|
||||
self.gui.set_dap_params_forwarding(True)
|
||||
self.gui.reset_zoom()
|
||||
|
||||
try:
|
||||
self._align_impl(keep_shutter_open)
|
||||
finally:
|
||||
@@ -101,6 +124,10 @@ class XrayEyeAlign:
|
||||
self.gui.set_dap_params_forwarding(False)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {exc}")
|
||||
try:
|
||||
self.gui.hide_crosshair()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"Failed to hide XRayEye alignment crosshair: {exc}")
|
||||
|
||||
def _align_impl(self, keep_shutter_open=False):
|
||||
if not keep_shutter_open:
|
||||
@@ -123,6 +150,15 @@ class XrayEyeAlign:
|
||||
# reset shift xy and fov params
|
||||
self._reset_init_values()
|
||||
|
||||
# Moved here from __init__: this is the actual start of a fresh
|
||||
# alignment run (matching what start_x_ray_eye_alignment() already
|
||||
# tells the user is happening - "Deleting any potential existing
|
||||
# alignment for this sample"), not merely XrayEyeAlign being
|
||||
# constructed. See the comment in __init__ for why this can't live
|
||||
# there.
|
||||
self.flomni.reset_correction()
|
||||
self.flomni.reset_tomo_alignment_fit()
|
||||
|
||||
self.flomni.lights_off()
|
||||
|
||||
if not self.test_wo_movements:
|
||||
@@ -145,12 +181,11 @@ class XrayEyeAlign:
|
||||
|
||||
if not self.test_wo_movements:
|
||||
self.flomni.fosa_out()
|
||||
|
||||
self.flomni.ffzp_in()
|
||||
self.flomni.feedback_disable()
|
||||
fsamx_in = self.flomni._get_user_param_safe("fsamx", "in")
|
||||
umv(dev.fsamx, fsamx_in - 0.25)
|
||||
|
||||
self.flomni.ffzp_in()
|
||||
|
||||
self.update_frame(keep_shutter_open)
|
||||
|
||||
self.gui.enable_submit_button(True)
|
||||
@@ -171,6 +206,12 @@ class XrayEyeAlign:
|
||||
print(f"Corrected position {k}: x {self.alignment_values[k]}")
|
||||
# reset submit channel
|
||||
dev.omny_xray_gui.submit.set(0)
|
||||
|
||||
# Controls whether `k` advances to the next step below. Left
|
||||
# True except for the height-centering submission, which
|
||||
# reuses k==1 for a second, real submission afterwards.
|
||||
advance_step = True
|
||||
|
||||
if k == 0: # received center value of FZP
|
||||
self.send_message("please wait ...")
|
||||
self.movement_buttons_enabled(False, False)
|
||||
@@ -188,10 +229,58 @@ class XrayEyeAlign:
|
||||
self.flomni.feedback_enable_with_reset()
|
||||
|
||||
self.update_frame(keep_shutter_open)
|
||||
self.send_message("Step 1/5: Adjust sample height and submit center")
|
||||
|
||||
# Mark the FZP center on the live view: it stays visible as a
|
||||
# fixed reference while the sample is aligned at each
|
||||
# subsequent rotation angle (steps 1-4 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(
|
||||
"Adjust sample height with the arrows if needed, then mark "
|
||||
"the sample and submit - height will be centered automatically"
|
||||
)
|
||||
self.gui.enable_submit_button(True)
|
||||
self.movement_buttons_enabled(True, True)
|
||||
|
||||
elif k == 1 and not self._height_centered:
|
||||
# First submission after the FZP center: a ROI mark of
|
||||
# the sample, used only to auto-center fsamy against the
|
||||
# FZP-center crosshair (not yet a real fit point - see
|
||||
# HEIGHT_CENTERING_SIGN above for the open sign question).
|
||||
self.send_message("please wait ...")
|
||||
self.gui.enable_submit_button(False)
|
||||
self.movement_buttons_enabled(False, False)
|
||||
|
||||
fzp_center_y = dev.omny_xray_gui.yval_y_0.get()
|
||||
mark_y = dev.omny_xray_gui.yval_y_1.get()
|
||||
delta_y_mm = (
|
||||
self.HEIGHT_CENTERING_SIGN
|
||||
* (fzp_center_y - mark_y)
|
||||
/ 2
|
||||
* self.PIXEL_CALIBRATION
|
||||
)
|
||||
print(
|
||||
f"Height centering: fzp_center_y={fzp_center_y}, mark_y={mark_y}, "
|
||||
f"delta_fsamy={delta_y_mm:.5f} mm"
|
||||
)
|
||||
|
||||
self.flomni.feedback_disable()
|
||||
if not self.test_wo_movements and delta_y_mm != 0:
|
||||
self.flomni.umvr_fsamy_tracked(delta_y_mm)
|
||||
time.sleep(2)
|
||||
self.flomni.feedback_enable_with_reset()
|
||||
|
||||
self._height_centered = True
|
||||
self.update_frame(keep_shutter_open)
|
||||
|
||||
self.send_message("Step 1/5: Submit sample center")
|
||||
self.gui.enable_submit_button(True)
|
||||
self.movement_buttons_enabled(True, False)
|
||||
advance_step = False
|
||||
|
||||
elif 1 <= k < 5: # received sample center value at samroy 0 ... 315
|
||||
self.send_message("please wait ...")
|
||||
self.gui.enable_submit_button(False)
|
||||
@@ -211,10 +300,12 @@ class XrayEyeAlign:
|
||||
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)
|
||||
if advance_step:
|
||||
k += 1
|
||||
dev.omny_xray_gui.step.set(k)
|
||||
|
||||
_xrayeyalignmvx = dev.omny_xray_gui.mvx.get()
|
||||
if _xrayeyalignmvx != 0:
|
||||
@@ -230,7 +321,7 @@ class XrayEyeAlign:
|
||||
if _xrayeyalignmvy != 0:
|
||||
self.flomni.feedback_disable()
|
||||
if not self.test_wo_movements:
|
||||
umvr(dev.fsamy, _xrayeyalignmvy / 1000)
|
||||
self.flomni.umvr_fsamy_tracked(_xrayeyalignmvy / 1000)
|
||||
time.sleep(2)
|
||||
dev.omny_xray_gui.mvy.set(0)
|
||||
self.flomni.feedback_enable_with_reset()
|
||||
@@ -298,4 +389,4 @@ class XrayEyeAlign:
|
||||
)
|
||||
self.gui.submit_fit_array(data)
|
||||
print(f"fit submited with {data}")
|
||||
# self.flomni.flomnigui_show_xeyealign_fittab()
|
||||
# self.flomni.flomnigui_show_xeyealign_fittab()
|
||||
@@ -189,7 +189,13 @@ class PtychoReconstructor:
|
||||
logger.warning("Failed to compare active account to system user.")
|
||||
return False
|
||||
|
||||
def write(self, scan_list: list, next_scan_number: int, base_path: str = "~/data/raw/analysis/"):
|
||||
def write(
|
||||
self,
|
||||
scan_list: list,
|
||||
next_scan_number: int,
|
||||
base_path: str = "~/data/raw/analysis/",
|
||||
probe_file_propagation: float | None = None,
|
||||
):
|
||||
"""Write a reconstruction queue file for the given scan list.
|
||||
|
||||
Args:
|
||||
@@ -198,6 +204,13 @@ class PtychoReconstructor:
|
||||
next_scan_number (int): The current next scan number, used to
|
||||
name the queue file.
|
||||
base_path (str): Root path under which the queue folder lives.
|
||||
probe_file_propagation (float, optional): Distance [m] by which the
|
||||
reconstruction should numerically propagate the probe, used in
|
||||
place of a physical sample z-move (e.g. when the z stage/piezo
|
||||
is unavailable). Written as `p.probe_file_propagation` if given;
|
||||
omitted entirely otherwise, matching the old reconstruction.mac
|
||||
behavior of only writing this parameter when
|
||||
progagateprobeinsteadofsample==1.
|
||||
"""
|
||||
if not self._accounts_match():
|
||||
logger.warning("Active BEC account does not match system user — skipping queue file write.")
|
||||
@@ -214,6 +227,8 @@ class PtychoReconstructor:
|
||||
with open(queue_file, "w") as f:
|
||||
scans = " ".join(str(s) for s in scan_list)
|
||||
f.write(f"p.scan_number {scans}\n")
|
||||
if probe_file_propagation is not None:
|
||||
f.write(f"p.probe_file_propagation {probe_file_propagation:.6f}\n")
|
||||
f.write("p.check_nextscan_started 1\n")
|
||||
|
||||
|
||||
@@ -238,7 +253,7 @@ class TomoIDManager:
|
||||
OMNY_URL = "https://v1p0zyg2w9n2k9c1.myfritz.net/samples/newmeasurement.php"
|
||||
OMNY_USER = ""
|
||||
OMNY_PASSWORD = ""
|
||||
TMP_FILE = "/tmp/currsamplesnr.txt"
|
||||
TMP_FILE = "~/currsamplesnr.txt"
|
||||
|
||||
def register(
|
||||
self,
|
||||
@@ -280,9 +295,22 @@ class TomoIDManager:
|
||||
# shell=True,
|
||||
# )
|
||||
#print(url)
|
||||
subprocess.run(
|
||||
f"wget -q -O {self.TMP_FILE} '{url}'",
|
||||
tmp_file = os.path.expanduser(self.TMP_FILE)
|
||||
result = subprocess.run(
|
||||
f"wget -q -O {tmp_file} '{url}'",
|
||||
shell=True,
|
||||
)
|
||||
with open(self.TMP_FILE) as f:
|
||||
return int(f.read())
|
||||
if result.returncode != 0:
|
||||
raise OMNYToolsError(
|
||||
f"wget failed (exit code {result.returncode}) fetching tomo ID from {self.OMNY_URL}"
|
||||
)
|
||||
try:
|
||||
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
|
||||
|
||||
@@ -116,6 +116,63 @@ class XRayEye(RPCBase):
|
||||
None
|
||||
"""
|
||||
|
||||
@rpc_timeout(20)
|
||||
@rpc_call
|
||||
def show_crosshair(self):
|
||||
"""
|
||||
Show the alignment target crosshair on the image view.
|
||||
"""
|
||||
|
||||
@rpc_timeout(20)
|
||||
@rpc_call
|
||||
def hide_crosshair(self):
|
||||
"""
|
||||
Hide the alignment target crosshair on the image view.
|
||||
"""
|
||||
|
||||
@property
|
||||
@rpc_call
|
||||
def crosshair_visible(self) -> "bool":
|
||||
"""
|
||||
Whether the alignment target crosshair is currently shown.
|
||||
"""
|
||||
|
||||
@crosshair_visible.setter
|
||||
@rpc_call
|
||||
def crosshair_visible(self) -> "bool":
|
||||
"""
|
||||
Whether the alignment target crosshair is currently shown.
|
||||
"""
|
||||
|
||||
@rpc_timeout(20)
|
||||
@rpc_call
|
||||
def set_crosshair_position(self, x: "float", y: "float"):
|
||||
"""
|
||||
Move the alignment target crosshair to (x, y). Does not change visibility.
|
||||
|
||||
Args:
|
||||
x(float): x position, in the same image/data coordinates as ROIs
|
||||
(see e.g. ``omny_xray_gui.xval_x_*``).
|
||||
y(float): y position, in the same image/data coordinates as ROIs
|
||||
(see e.g. ``omny_xray_gui.yval_y_*``).
|
||||
"""
|
||||
|
||||
@rpc_call
|
||||
def crosshair_position(self) -> "tuple[float, float]":
|
||||
"""
|
||||
Current position of the alignment target crosshair as (x, y).
|
||||
"""
|
||||
|
||||
@rpc_timeout(20)
|
||||
@rpc_call
|
||||
def reset_zoom(self):
|
||||
"""
|
||||
Reset the image view to fit the current frame, discarding any manual
|
||||
zoom/pan. Intended to be called once at the start of an alignment
|
||||
routine; live view re-enabling does not reset zoom on its own (see
|
||||
on_live_view_enabled).
|
||||
"""
|
||||
|
||||
|
||||
class XRayEye2DControl(RPCBase):
|
||||
_IMPORT_MODULE = "csaxs_bec.bec_widgets.widgets.xray_eye.x_ray_eye"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_qthemes import material_icon
|
||||
@@ -31,6 +32,109 @@ logger = bec_logger.logger
|
||||
CAMERA = ("cam_xeye", "image")
|
||||
|
||||
|
||||
class TargetCrosshair:
|
||||
"""
|
||||
Fixed, RPC-positionable crosshair overlay for an image plot item.
|
||||
|
||||
This is intentionally separate from bec_widgets' built-in mouse-tracking
|
||||
``Crosshair`` (toggled from the image toolbar): that one follows the
|
||||
cursor and is purely a UI convenience. This crosshair never reacts to the
|
||||
mouse - it marks a single target position that is shown, hidden and
|
||||
moved entirely under program control (e.g. from ``xray_eye_align.py`` or
|
||||
any other BEC client via RPC), so it can be used to mark a reference
|
||||
position (such as a previously submitted alignment center) on the live
|
||||
view while the user works through subsequent steps.
|
||||
|
||||
Position is expressed in the same image/data coordinate system used by
|
||||
the widget's ROIs (see ``XRayEye.submit``), so values read back from
|
||||
``omny_xray_gui.xval_x_*`` / ``yval_y_*`` can be passed in directly.
|
||||
"""
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem):
|
||||
self.plot_item = plot_item
|
||||
pen = pg.mkPen(color="#ff2e2e", width=2, style=Qt.PenStyle.DashLine)
|
||||
self.v_line = pg.InfiniteLine(angle=90, movable=False, pen=pen)
|
||||
self.h_line = pg.InfiniteLine(angle=0, movable=False, pen=pen)
|
||||
for line in (self.v_line, self.h_line):
|
||||
line.skip_auto_range = True
|
||||
line.setVisible(False)
|
||||
self.plot_item.addItem(line, ignoreBounds=True)
|
||||
|
||||
def set_position(self, x: float, y: float):
|
||||
"""Move the crosshair to (x, y) in image/data coordinates."""
|
||||
self.v_line.setPos(x)
|
||||
self.h_line.setPos(y)
|
||||
|
||||
def position(self) -> tuple[float, float]:
|
||||
"""Current crosshair position as (x, y) in image/data coordinates."""
|
||||
return (self.v_line.value(), self.h_line.value())
|
||||
|
||||
def set_visible(self, visible: bool):
|
||||
"""Show or hide the crosshair without changing its position."""
|
||||
self.v_line.setVisible(visible)
|
||||
self.h_line.setVisible(visible)
|
||||
|
||||
def is_visible(self) -> bool:
|
||||
return self.v_line.isVisible()
|
||||
|
||||
def cleanup(self):
|
||||
self.plot_item.removeItem(self.v_line)
|
||||
self.plot_item.removeItem(self.h_line)
|
||||
|
||||
|
||||
class ImageZoomControl(QWidget):
|
||||
"""
|
||||
Discrete zoom controls for an Image widget's view.
|
||||
|
||||
Mouse-wheel zoom steps can be far too coarse to use precisely over a
|
||||
remote desktop connection, so this provides explicit zoom in/out buttons
|
||||
plus a "fit to view" button that resets pan/zoom to frame the current
|
||||
image (the same operation as ``XRayEye.reset_zoom()``).
|
||||
"""
|
||||
|
||||
# scaleBy() factor for one "zoom in" click; >1 (its inverse) zooms out.
|
||||
ZOOM_STEP_FACTOR = 0.8
|
||||
|
||||
def __init__(self, parent=None, image_widget: Image | None = None, *args, **kwargs):
|
||||
super().__init__(parent=parent, *args, **kwargs)
|
||||
self._image_widget = image_widget
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
|
||||
self.zoom_out_button = QToolButton(parent=self)
|
||||
self.zoom_out_button.setIcon(material_icon("zoom_out"))
|
||||
self.zoom_out_button.setToolTip("Zoom out")
|
||||
layout.addWidget(self.zoom_out_button)
|
||||
|
||||
self.zoom_in_button = QToolButton(parent=self)
|
||||
self.zoom_in_button.setIcon(material_icon("zoom_in"))
|
||||
self.zoom_in_button.setToolTip("Zoom in")
|
||||
layout.addWidget(self.zoom_in_button)
|
||||
|
||||
self.fit_view_button = QToolButton(parent=self)
|
||||
self.fit_view_button.setIcon(material_icon("fit_screen"))
|
||||
self.fit_view_button.setToolTip("Reset zoom/pan to fit the image")
|
||||
layout.addWidget(self.fit_view_button)
|
||||
|
||||
self.zoom_in_button.clicked.connect(lambda: self.zoom(self.ZOOM_STEP_FACTOR))
|
||||
self.zoom_out_button.clicked.connect(lambda: self.zoom(1 / self.ZOOM_STEP_FACTOR))
|
||||
self.fit_view_button.clicked.connect(self.reset_view)
|
||||
|
||||
def zoom(self, factor: float):
|
||||
"""Scale the view by `factor` around the current view center."""
|
||||
if self._image_widget is None:
|
||||
return
|
||||
self._image_widget.plot_item.vb.scaleBy((factor, factor))
|
||||
|
||||
def reset_view(self):
|
||||
"""Reset pan/zoom to fit the current image."""
|
||||
if self._image_widget is None:
|
||||
return
|
||||
self._image_widget.auto_range(True)
|
||||
|
||||
|
||||
class XRayEye2DControl(BECWidget, QWidget):
|
||||
def __init__(self, parent=None, step_size: int = 100, *arg, **kwargs):
|
||||
super().__init__(parent=parent, *arg, **kwargs)
|
||||
@@ -142,9 +246,20 @@ class XRayEye(BECWidget, QWidget):
|
||||
"switch_tab",
|
||||
"set_dap_params_forwarding",
|
||||
"submit_fit_array",
|
||||
"show_crosshair",
|
||||
"hide_crosshair",
|
||||
"crosshair_visible",
|
||||
"crosshair_visible.setter",
|
||||
"set_crosshair_position",
|
||||
"crosshair_position",
|
||||
"reset_zoom",
|
||||
]
|
||||
PLUGIN = True
|
||||
|
||||
# Styling for ROIs drawn in the (single, compact-mode) alignment view.
|
||||
ROI_LINE_COLOR = "blue"
|
||||
ROI_LINE_WIDTH = 2
|
||||
|
||||
def __init__(self, parent=None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self._connected_motor = None
|
||||
@@ -157,6 +272,7 @@ class XRayEye(BECWidget, QWidget):
|
||||
self.get_bec_shortcuts()
|
||||
|
||||
self._init_ui()
|
||||
self.target_crosshair = TargetCrosshair(self.image.plot_item)
|
||||
self._make_connections()
|
||||
|
||||
# Connection to redis endpoints
|
||||
@@ -200,7 +316,11 @@ class XRayEye(BECWidget, QWidget):
|
||||
|
||||
# ROI toolbar + Live toggle (header row)
|
||||
self.roi_manager = ROIPropertyTree(
|
||||
parent=self, image_widget=self.image, compact=True, compact_orientation="horizontal"
|
||||
parent=self,
|
||||
image_widget=self.image,
|
||||
compact=True,
|
||||
compact_orientation="horizontal",
|
||||
compact_color=self.ROI_LINE_COLOR,
|
||||
)
|
||||
header_row = QHBoxLayout()
|
||||
header_row.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -242,6 +362,12 @@ class XRayEye(BECWidget, QWidget):
|
||||
self.motor_control_2d, 0, Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter
|
||||
)
|
||||
|
||||
# Zoom controls (mouse-wheel zoom steps are too coarse over remote desktop)
|
||||
self.zoom_control = ImageZoomControl(parent=self, image_widget=self.image)
|
||||
self.control_panel_layout.addWidget(
|
||||
self.zoom_control, 0, Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter
|
||||
)
|
||||
|
||||
# separator
|
||||
self.control_panel_layout.addWidget(self._create_separator())
|
||||
|
||||
@@ -341,6 +467,14 @@ class XRayEye(BECWidget, QWidget):
|
||||
lambda x: self.motor_control_2d.setProperty("step_size", x)
|
||||
)
|
||||
self.submit_button.clicked.connect(self.submit)
|
||||
# ROIPropertyTree's compact_color only styles the line color; line width
|
||||
# still needs to be forced per-ROI here.
|
||||
self.roi_manager.controller.roiAdded.connect(self._style_new_roi)
|
||||
|
||||
@SafeSlot(object)
|
||||
def _style_new_roi(self, roi):
|
||||
"""Force a thinner outline on newly drawn ROIs (color is set via compact_color)."""
|
||||
roi.line_width = self.ROI_LINE_WIDTH
|
||||
|
||||
def _create_separator(self):
|
||||
sep = QFrame(parent=self)
|
||||
@@ -478,6 +612,58 @@ class XRayEye(BECWidget, QWidget):
|
||||
else:
|
||||
self.tab_widget.setCurrentIndex(0)
|
||||
|
||||
@SafeSlot()
|
||||
@rpc_timeout(20)
|
||||
def show_crosshair(self):
|
||||
"""Show the alignment target crosshair on the image view."""
|
||||
self.target_crosshair.set_visible(True)
|
||||
|
||||
@SafeSlot()
|
||||
@rpc_timeout(20)
|
||||
def hide_crosshair(self):
|
||||
"""Hide the alignment target crosshair on the image view."""
|
||||
self.target_crosshair.set_visible(False)
|
||||
|
||||
@SafeProperty(bool)
|
||||
def crosshair_visible(self) -> bool:
|
||||
"""Whether the alignment target crosshair is currently shown."""
|
||||
return self.target_crosshair.is_visible()
|
||||
|
||||
@crosshair_visible.setter
|
||||
@rpc_timeout(20)
|
||||
def crosshair_visible(self, visible: bool):
|
||||
self.target_crosshair.set_visible(visible)
|
||||
|
||||
@SafeSlot(float, float)
|
||||
@rpc_timeout(20)
|
||||
def set_crosshair_position(self, x: float, y: float):
|
||||
"""
|
||||
Move the alignment target crosshair to (x, y). Does not change visibility.
|
||||
|
||||
Args:
|
||||
x(float): x position, in the same image/data coordinates as ROIs
|
||||
(see e.g. ``omny_xray_gui.xval_x_*``).
|
||||
y(float): y position, in the same image/data coordinates as ROIs
|
||||
(see e.g. ``omny_xray_gui.yval_y_*``).
|
||||
"""
|
||||
self.target_crosshair.set_position(x, y)
|
||||
|
||||
@SafeSlot()
|
||||
def crosshair_position(self) -> tuple[float, float]:
|
||||
"""Current position of the alignment target crosshair as (x, y)."""
|
||||
return self.target_crosshair.position()
|
||||
|
||||
@SafeSlot()
|
||||
@rpc_timeout(20)
|
||||
def reset_zoom(self):
|
||||
"""
|
||||
Reset the image view to fit the current frame, discarding any manual
|
||||
zoom/pan. Intended to be called once at the start of an alignment
|
||||
routine; live view re-enabling does not reset zoom on its own (see
|
||||
on_live_view_enabled).
|
||||
"""
|
||||
self.zoom_control.reset_view()
|
||||
|
||||
@SafeSlot()
|
||||
def get_roi_coordinates(self) -> dict | None:
|
||||
"""Get the coordinates of the currently active ROI."""
|
||||
@@ -499,6 +685,15 @@ class XRayEye(BECWidget, QWidget):
|
||||
if enabled:
|
||||
self.live_preview_toggle.checked = enabled
|
||||
self.image.image(device=CAMERA[0], signal=CAMERA[1])
|
||||
# Reconnecting the monitor schedules a one-shot view autorange on
|
||||
# the next incoming frame (bec_widgets Image._autorange_on_next_update),
|
||||
# which would silently discard any manual zoom/pan every time live
|
||||
# view is re-enabled (e.g. once per step of an alignment routine).
|
||||
# Suppress it; an explicit reset is available via reset_zoom() /
|
||||
# the "fit to view" button. Private attribute - re-check this if
|
||||
# bec_widgets' Image implementation changes.
|
||||
if hasattr(self.image, "_autorange_on_next_update"):
|
||||
self.image._autorange_on_next_update = False
|
||||
self.live_preview_toggle.blockSignals(False)
|
||||
return
|
||||
|
||||
@@ -674,6 +869,7 @@ class XRayEye(BECWidget, QWidget):
|
||||
def cleanup(self):
|
||||
"""Cleanup connections on widget close -> disconnect slots and stop live mode of camera."""
|
||||
self._queue_idle_timer.stop()
|
||||
self.target_crosshair.cleanup()
|
||||
if self._connected_motor is not None:
|
||||
self.bec_dispatcher.disconnect_slot(
|
||||
self.on_tomo_angle_readback, MessageEndpoints.device_readback(self._connected_motor)
|
||||
@@ -707,4 +903,4 @@ if __name__ == "__main__":
|
||||
|
||||
win.resize(1000, 800)
|
||||
win.show()
|
||||
sys.exit(app.exec_())
|
||||
sys.exit(app.exec_())
|
||||
@@ -1,17 +1,6 @@
|
||||
# eiger_1_5:
|
||||
# description: Eiger 1.5M in-vacuum detector
|
||||
# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M
|
||||
# deviceConfig:
|
||||
# detector_distance: 100
|
||||
# beam_center: [0, 0]
|
||||
# onFailure: raise
|
||||
# enabled: True
|
||||
# readoutPriority: async
|
||||
# softwareTrigger: False
|
||||
|
||||
eiger_9:
|
||||
description: Eiger 9M detector
|
||||
deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M
|
||||
eiger_1_5:
|
||||
description: Eiger 1.5M in-vacuum detector
|
||||
deviceClass: csaxs_bec.devices.jungfraujoch.eiger_1_5m.Eiger1_5M
|
||||
deviceConfig:
|
||||
detector_distance: 2150
|
||||
beam_center: [860, 1219]
|
||||
@@ -20,6 +9,17 @@ eiger_9:
|
||||
readoutPriority: async
|
||||
softwareTrigger: False
|
||||
|
||||
# eiger_9:
|
||||
# description: Eiger 9M detector
|
||||
# deviceClass: csaxs_bec.devices.jungfraujoch.eiger_9m.Eiger9M
|
||||
# deviceConfig:
|
||||
# detector_distance: 2200
|
||||
# beam_center: [870, 1203]
|
||||
# onFailure: raise
|
||||
# enabled: True
|
||||
# readoutPriority: async
|
||||
# softwareTrigger: False
|
||||
|
||||
# ids_cam:
|
||||
# description: IDS camera for live image acquisition
|
||||
# deviceClass: csaxs_bec.devices.ids_cameras.IDSCamera
|
||||
|
||||
@@ -855,6 +855,9 @@ ebsupport:
|
||||
enabled: true
|
||||
readoutPriority: baseline
|
||||
softwareTrigger: false
|
||||
userParameter:
|
||||
one_reflection: -93.0
|
||||
two_reflections: 10.9787
|
||||
|
||||
fttrx1:
|
||||
description: FTS1 translation X
|
||||
@@ -1039,7 +1042,7 @@ gain_bpm_xbox2:
|
||||
gain_lsb: galilrioesxbox.digital_out.ch0 # Pin 10 -> Galil ch0
|
||||
gain_mid: galilrioesxbox.digital_out.ch1 # Pin 11 -> Galil ch1
|
||||
gain_msb: galilrioesxbox.digital_out.ch2 # Pin 12 -> Galil ch2
|
||||
coupling: galilrioesxbox.digital_out.ch3 # Pin 13 -> Galil ch3
|
||||
coupling_ref: galilrioesxbox.digital_out.ch3 # Pin 13 -> Galil ch3
|
||||
speed_mode: galilrioesxbox.digital_out.ch4 # Pin 14 -> Galil ch4
|
||||
enabled: true
|
||||
readoutPriority: baseline
|
||||
@@ -1051,10 +1054,10 @@ bpm_xbox2_slowrb:
|
||||
description: BPM Xbox 2 (First Xbox in ES hutch) readback
|
||||
deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM
|
||||
deviceConfig:
|
||||
left_top: galilrioesxbox.analog_in.ch0
|
||||
right_top: galilrioesxbox.analog_in.ch1
|
||||
right_bot: galilrioesxbox.analog_in.ch2
|
||||
left_bot: galilrioesxbox.analog_in.ch3
|
||||
left_top_ref: galilrioesxbox.analog_in.ch0
|
||||
right_top_ref: galilrioesxbox.analog_in.ch1
|
||||
right_bot_ref: galilrioesxbox.analog_in.ch2
|
||||
left_bot_ref: galilrioesxbox.analog_in.ch3
|
||||
enabled: true
|
||||
readoutPriority: monitored
|
||||
onFailure: retry
|
||||
@@ -1068,7 +1071,7 @@ gain_bim_xbox3:
|
||||
gain_lsb: galilrioesxbox.digital_out.ch6 # Pin 10 -> Galil ch0
|
||||
gain_mid: galilrioesxbox.digital_out.ch7 # Pin 11 -> Galil ch1
|
||||
gain_msb: galilrioesxbox.digital_out.ch8 # Pin 12 -> Galil ch2
|
||||
coupling: galilrioesxbox.digital_out.ch9 # Pin 13 -> Galil ch3
|
||||
coupling_ref: galilrioesxbox.digital_out.ch9 # Pin 13 -> Galil ch3
|
||||
speed_mode: galilrioesxbox.digital_out.ch10 # Pin 14 -> Galil ch4
|
||||
enabled: true
|
||||
readoutPriority: baseline
|
||||
@@ -1080,7 +1083,7 @@ bim_xbox3_slowrb:
|
||||
description: Beam intensity slow readback ES XBox3
|
||||
deviceClass: csaxs_bec.devices.pseudo_devices.signal_forwarder.SignalForwarder
|
||||
deviceConfig:
|
||||
signal: galilrioesxbox.analog_in.ch6
|
||||
signal_ref: galilrioesxbox.analog_in.ch6
|
||||
enabled: true
|
||||
readoutPriority: monitored
|
||||
onFailure: retry
|
||||
@@ -1112,7 +1115,7 @@ gain_beamstop_diode:
|
||||
gain_lsb: galilrioesft.digital_out.ch0 # Pin 10 -> Galil ch0
|
||||
gain_mid: galilrioesft.digital_out.ch1 # Pin 11 -> Galil ch1
|
||||
gain_msb: galilrioesft.digital_out.ch2 # Pin 12 -> Galil ch2
|
||||
coupling: galilrioesft.digital_out.ch3 # Pin 13 -> Galil ch3
|
||||
coupling_ref: galilrioesft.digital_out.ch3 # Pin 13 -> Galil ch3
|
||||
speed_mode: galilrioesft.digital_out.ch4 # Pin 14 -> Galil ch4
|
||||
enabled: true
|
||||
readoutPriority: baseline
|
||||
@@ -1124,7 +1127,7 @@ beamstop_intensity:
|
||||
description: Beamstop intensity from Galil analog input ch6
|
||||
deviceClass: csaxs_bec.devices.pseudo_devices.signal_forwarder.SignalForwarder
|
||||
deviceConfig:
|
||||
signal: galilrioesft.analog_in.ch0
|
||||
signal_ref: galilrioesft.analog_in.ch0
|
||||
enabled: true
|
||||
readoutPriority: monitored
|
||||
onFailure: retry
|
||||
|
||||
@@ -203,10 +203,10 @@ bpm1:
|
||||
description: 'XBPM1 (frontend)'
|
||||
deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM
|
||||
deviceConfig:
|
||||
left_top: xbpm1c1
|
||||
right_top: xbpm1c2
|
||||
right_bot: xbpm1c3
|
||||
left_bot: xbpm1c4
|
||||
left_top_ref: xbpm1c1
|
||||
right_top_ref: xbpm1c2
|
||||
right_bot_ref: xbpm1c3
|
||||
left_bot_ref: xbpm1c4
|
||||
onFailure: raise
|
||||
enabled: true
|
||||
readoutPriority: monitored
|
||||
|
||||
@@ -201,48 +201,48 @@ ccm_energy:
|
||||
######################## SMARACT STAGES ##################################
|
||||
##########################################################################
|
||||
|
||||
# xbpm2x:
|
||||
# description: X-ray beam position monitor 1 in OPbox
|
||||
# deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
# deviceConfig:
|
||||
# axis_Id: A
|
||||
# host: x12sa-eb-smaract-mcs-03.psi.ch
|
||||
# limits:
|
||||
# - -200
|
||||
# - 200
|
||||
# port: 5000
|
||||
# sign: 1
|
||||
# enabled: true
|
||||
# onFailure: retry
|
||||
# readOnly: false
|
||||
# readoutPriority: baseline
|
||||
# connectionTimeout: 20
|
||||
# userParameter:
|
||||
# init_position: 22.5
|
||||
# in_position: -1.5
|
||||
# # bl_smar_stage to use csaxs reference method. assign number according to axis channel
|
||||
# bl_smar_stage: 0
|
||||
xbpm2x:
|
||||
description: X-ray beam position monitor 1 in OPbox
|
||||
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
deviceConfig:
|
||||
axis_Id: A
|
||||
host: x12sa-eb-smaract-mcs-03.psi.ch
|
||||
limits:
|
||||
- -200
|
||||
- 200
|
||||
port: 5000
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: retry
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
init_position: 22.5
|
||||
in_position: -1.5
|
||||
# bl_smar_stage to use csaxs reference method. assign number according to axis channel
|
||||
bl_smar_stage: 0
|
||||
|
||||
# xbpm2y:
|
||||
# description: X-ray beam position monitor 1 in OPbox
|
||||
# deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
# deviceConfig:
|
||||
# axis_Id: B
|
||||
# host: x12sa-eb-smaract-mcs-03.psi.ch
|
||||
# limits:
|
||||
# - -200
|
||||
# - 200
|
||||
# port: 5000
|
||||
# sign: 1
|
||||
# enabled: true
|
||||
# onFailure: retry
|
||||
# readOnly: false
|
||||
# readoutPriority: baseline
|
||||
# connectionTimeout: 20
|
||||
# userParameter:
|
||||
# in_position: -1.0
|
||||
# # bl_smar_stage to use csaxs reference method. assign number according to axis channel
|
||||
# bl_smar_stage: 1
|
||||
xbpm2y:
|
||||
description: X-ray beam position monitor 1 in OPbox
|
||||
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
deviceConfig:
|
||||
axis_Id: B
|
||||
host: x12sa-eb-smaract-mcs-03.psi.ch
|
||||
limits:
|
||||
- -200
|
||||
- 200
|
||||
port: 5000
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: retry
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in_position: -1.0
|
||||
# bl_smar_stage to use csaxs reference method. assign number according to axis channel
|
||||
bl_smar_stage: 1
|
||||
|
||||
scinx:
|
||||
description: scintillator in OPbox
|
||||
@@ -266,47 +266,47 @@ scinx:
|
||||
bl_smar_stage: 2
|
||||
in_position: -12.5
|
||||
|
||||
poly:
|
||||
description: polarizer holder in OPbox
|
||||
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
deviceConfig:
|
||||
axis_Id: B
|
||||
host: x12sa-eb-smaract-mcs-03.psi.ch
|
||||
limits:
|
||||
- -200
|
||||
- 200
|
||||
port: 5000
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: retry
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
# bl_smar_stage to use csaxs reference method. assign number according to axis channel
|
||||
init_position: -23
|
||||
bl_smar_stage: 1
|
||||
# poly:
|
||||
# description: polarizer holder in OPbox
|
||||
# deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
# deviceConfig:
|
||||
# axis_Id: B
|
||||
# host: x12sa-eb-smaract-mcs-03.psi.ch
|
||||
# limits:
|
||||
# - -200
|
||||
# - 200
|
||||
# port: 5000
|
||||
# sign: 1
|
||||
# enabled: true
|
||||
# onFailure: retry
|
||||
# readOnly: false
|
||||
# readoutPriority: baseline
|
||||
# connectionTimeout: 20
|
||||
# userParameter:
|
||||
# # bl_smar_stage to use csaxs reference method. assign number according to axis channel
|
||||
# init_position: -23
|
||||
# bl_smar_stage: 1
|
||||
|
||||
polrot:
|
||||
description: rotation of crytal of the polarizer
|
||||
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
deviceConfig:
|
||||
axis_Id: A
|
||||
host: x12sa-eb-smaract-mcs-03.psi.ch
|
||||
limits:
|
||||
- -200
|
||||
- 200
|
||||
port: 5000
|
||||
sign: 1
|
||||
enabled: true
|
||||
onFailure: retry
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in_position: -1.0
|
||||
# bl_smar_stage to use csaxs reference method. assign number according to axis channel
|
||||
bl_smar_stage: 0
|
||||
# polrot:
|
||||
# description: rotation of crytal of the polarizer
|
||||
# deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
# deviceConfig:
|
||||
# axis_Id: A
|
||||
# host: x12sa-eb-smaract-mcs-03.psi.ch
|
||||
# limits:
|
||||
# - -200
|
||||
# - 200
|
||||
# port: 5000
|
||||
# sign: 1
|
||||
# enabled: true
|
||||
# onFailure: retry
|
||||
# readOnly: false
|
||||
# readoutPriority: baseline
|
||||
# connectionTimeout: 20
|
||||
# userParameter:
|
||||
# in_position: -1.0
|
||||
# # bl_smar_stage to use csaxs reference method. assign number according to axis channel
|
||||
# bl_smar_stage: 0
|
||||
|
||||
# dmm1_trx_readback_example: # This is the same template as for i.e. bpm4i
|
||||
# description: 'This is an example of a read-only Epics signal'
|
||||
@@ -356,7 +356,7 @@ galilrioop:
|
||||
# gain_lsb: galilrioop.digital_out.ch0 # Pin 10 -> Galil ch0
|
||||
# gain_mid: galilrioop.digital_out.ch1 # Pin 11 -> Galil ch1
|
||||
# gain_msb: galilrioop.digital_out.ch2 # Pin 12 -> Galil ch2
|
||||
# coupling: galilrioop.digital_out.ch3 # Pin 13 -> Galil ch3
|
||||
# coupling_ref: galilrioop.digital_out.ch3 # Pin 13 -> Galil ch3
|
||||
# speed_mode: galilrioop.digital_out.ch4 # Pin 14 -> Galil ch4
|
||||
# enabled: true
|
||||
# readoutPriority: monitored
|
||||
@@ -368,10 +368,10 @@ galilrioop:
|
||||
# description: BPM Xbox 1 (OP hutch) readback
|
||||
# deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM
|
||||
# deviceConfig:
|
||||
# left_top: galilrioop.analog_in.ch0
|
||||
# right_top: galilrioop.analog_in.ch1
|
||||
# right_bot: galilrioop.analog_in.ch2
|
||||
# left_bot: galilrioop.analog_in.ch3
|
||||
# left_top_ref: galilrioop.analog_in.ch0
|
||||
# right_top_ref: galilrioop.analog_in.ch1
|
||||
# right_bot_ref: galilrioop.analog_in.ch2
|
||||
# left_bot_ref: galilrioop.analog_in.ch3
|
||||
# enabled: true
|
||||
# readoutPriority: monitored
|
||||
# onFailure: retry
|
||||
@@ -385,7 +385,7 @@ gain_diodes_xbox1:
|
||||
gain_lsb: galilrioop.digital_out.ch6 # Pin 10 -> Galil ch0
|
||||
gain_mid: galilrioop.digital_out.ch7 # Pin 11 -> Galil ch1
|
||||
gain_msb: galilrioop.digital_out.ch8 # Pin 12 -> Galil ch2
|
||||
coupling: galilrioop.digital_out.ch9 # Pin 13 -> Galil ch3
|
||||
coupling_ref: galilrioop.digital_out.ch9 # Pin 13 -> Galil ch3
|
||||
speed_mode: galilrioop.digital_out.ch10 # Pin 14 -> Galil ch4
|
||||
enabled: true
|
||||
readoutPriority: baseline
|
||||
@@ -397,7 +397,7 @@ diode_horizontal_xbox1_slowrb:
|
||||
description: Slow readback diode horizontal XBox OP (polarization diagnostics)
|
||||
deviceClass: csaxs_bec.devices.pseudo_devices.signal_forwarder.SignalForwarder
|
||||
deviceConfig:
|
||||
signal: galilrioop.analog_in.ch6
|
||||
signal_ref: galilrioop.analog_in.ch6
|
||||
enabled: true
|
||||
readoutPriority: monitored
|
||||
onFailure: retry
|
||||
@@ -408,7 +408,7 @@ diode_vertical_xbox1_slowrb:
|
||||
description: Slow readback diode vertical XBox OP (polarization diagnostics)
|
||||
deviceClass: csaxs_bec.devices.pseudo_devices.signal_forwarder.SignalForwarder
|
||||
deviceConfig:
|
||||
signal: galilrioop.analog_in.ch7
|
||||
signal_ref: galilrioop.analog_in.ch7
|
||||
enabled: true
|
||||
readoutPriority: monitored
|
||||
onFailure: retry
|
||||
@@ -750,6 +750,9 @@ kbhtry:
|
||||
deviceTags:
|
||||
- cSAXS
|
||||
- optics
|
||||
userParameter:
|
||||
one_reflection: -5.0
|
||||
two_reflections: -0.95
|
||||
|
||||
kbhyaw:
|
||||
description: "KB Horizontal Focusing Mirror, yaw"
|
||||
|
||||
@@ -19,8 +19,8 @@ detectors:
|
||||
#sastt:
|
||||
# - !include ./sastt.yaml
|
||||
|
||||
# flomni:
|
||||
# - !include ./ptycho_flomni.yaml
|
||||
flomni:
|
||||
- !include ./ptycho_flomni.yaml
|
||||
|
||||
#omny:
|
||||
# - !include ./ptycho_omny.yaml
|
||||
@@ -28,7 +28,7 @@ detectors:
|
||||
#lamni:
|
||||
# - !include ./ptycho_lamni.yaml
|
||||
|
||||
user setup:
|
||||
- !include ./user_setup.yaml
|
||||
# user setup:
|
||||
# - !include ./user_setup.yaml
|
||||
|
||||
|
||||
|
||||
@@ -19,8 +19,11 @@ feyex:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: -16.267
|
||||
in: -16.453
|
||||
out: -1
|
||||
fttrx_in: 2.3
|
||||
fttrx_out: -24
|
||||
|
||||
feyey:
|
||||
description: Xray eye Y
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -38,7 +41,7 @@ feyey:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: -10.467
|
||||
in: -10.09
|
||||
fheater:
|
||||
description: Heater Y
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -72,7 +75,8 @@ foptx:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: -13.761
|
||||
#170 micron, 60 nm
|
||||
in: -13.831
|
||||
fopty:
|
||||
description: Optics Y
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -90,8 +94,9 @@ fopty:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: 0.552
|
||||
out: 0.752
|
||||
#170 micron, 60 nm
|
||||
in: 0.42
|
||||
out: 0.57
|
||||
foptz:
|
||||
description: Optics Z
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -152,7 +157,7 @@ fsamy:
|
||||
host: mpc2844.psi.ch
|
||||
limits:
|
||||
- 2
|
||||
- 3.1
|
||||
- 3.3
|
||||
port: 8081
|
||||
sign: 1
|
||||
enabled: true
|
||||
@@ -161,7 +166,7 @@ fsamy:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: 2.75
|
||||
in: 3
|
||||
ftracky:
|
||||
description: Laser Tracker coarse Y
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -227,7 +232,7 @@ ftransy:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
sensor_voltage: -1.1
|
||||
sensor_voltage: -1.6
|
||||
ftransz:
|
||||
description: Sample transer Z
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -296,8 +301,12 @@ fosax:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: 9.124
|
||||
out: 5.3
|
||||
#170 micron, 60 nm, 7.6 kev
|
||||
#in: 8.7568
|
||||
#out: 5.1
|
||||
#170 micron, 60 nm, 7.9 kev
|
||||
in: 8.731922
|
||||
out: 5.1
|
||||
fosay:
|
||||
description: OSA Y
|
||||
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
@@ -315,7 +324,11 @@ fosay:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: 0.367
|
||||
#170 micron, 60 nm, 7.6 kev
|
||||
#in: -0.0235
|
||||
#170 micron, 60 nm, 7.6 kev
|
||||
in: -0.0422
|
||||
|
||||
fosaz:
|
||||
description: OSA Z
|
||||
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
@@ -333,7 +346,11 @@ fosaz:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: 8.5
|
||||
#170 micron, 60 nm, 7.6 kev
|
||||
#in: 8.5
|
||||
#out: 6
|
||||
#170 micron, 60 nm, 7.9 kev, foptz 15.9
|
||||
in: 11.9
|
||||
out: 6
|
||||
|
||||
############################################################
|
||||
@@ -439,8 +456,8 @@ cam_xeye:
|
||||
deviceConfig:
|
||||
camera_id: 11
|
||||
bits_per_pixel: 24
|
||||
num_rotation_90: 3
|
||||
transpose: false
|
||||
num_rotation_90: 0
|
||||
transpose: true
|
||||
force_monochrome: true
|
||||
m_n_colormode: 1
|
||||
enabled: true
|
||||
@@ -511,33 +528,33 @@ calculated_signal:
|
||||
############################################################
|
||||
#################### OMNY Pandabox #########################
|
||||
############################################################
|
||||
# omny_panda:
|
||||
# readoutPriority: async
|
||||
# deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY
|
||||
# deviceConfig:
|
||||
# host: omny-panda.psi.ch
|
||||
# signal_alias:
|
||||
# FMC_IN.VAL1.Min: cap_voltage_fzp_y_min
|
||||
# FMC_IN.VAL1.Max: cap_voltage_fzp_y_max
|
||||
# FMC_IN.VAL1.Mean: cap_voltage_fzp_y_mean
|
||||
# FMC_IN.VAL2.Min: cap_voltage_fzp_x_min
|
||||
# FMC_IN.VAL2.Max: cap_voltage_fzp_x_max
|
||||
# FMC_IN.VAL2.Mean: cap_voltage_fzp_x_mean
|
||||
# INENC1.VAL.Max: interf_st_fzp_y_max
|
||||
# INENC1.VAL.Mean: interf_st_fzp_y_mean
|
||||
# INENC1.VAL.Min: interf_st_fzp_y_min
|
||||
# INENC2.VAL.Max: interf_st_fzp_x_max
|
||||
# INENC2.VAL.Mean: interf_st_fzp_x_mean
|
||||
# INENC2.VAL.Min: interf_st_fzp_x_min
|
||||
# INENC3.VAL.Max: interf_st_rotz_max
|
||||
# INENC3.VAL.Mean: interf_st_rotz_mean
|
||||
# INENC3.VAL.Min: interf_st_rotz_min
|
||||
# INENC4.VAL.Max: interf_st_rotx_max
|
||||
# INENC4.VAL.Mean: interf_st_rotx_mean
|
||||
# INENC4.VAL.Min: interf_st_rotx_min
|
||||
# PCAP.GATE_DURATION.Value: pcap_gate_duration_value
|
||||
# deviceTags:
|
||||
# - detector
|
||||
# enabled: true
|
||||
# readOnly: false
|
||||
# softwareTrigger: false
|
||||
omny_panda:
|
||||
readoutPriority: async
|
||||
deviceClass: csaxs_bec.devices.panda_box.panda_box_omny.PandaBoxOMNY
|
||||
deviceConfig:
|
||||
host: omny-panda.psi.ch
|
||||
signal_alias:
|
||||
FMC_IN.VAL1.Min: cap_voltage_fzp_y_min
|
||||
FMC_IN.VAL1.Max: cap_voltage_fzp_y_max
|
||||
FMC_IN.VAL1.Mean: cap_voltage_fzp_y_mean
|
||||
FMC_IN.VAL2.Min: cap_voltage_fzp_x_min
|
||||
FMC_IN.VAL2.Max: cap_voltage_fzp_x_max
|
||||
FMC_IN.VAL2.Mean: cap_voltage_fzp_x_mean
|
||||
INENC1.VAL.Max: interf_st_fzp_y_max
|
||||
INENC1.VAL.Mean: interf_st_fzp_y_mean
|
||||
INENC1.VAL.Min: interf_st_fzp_y_min
|
||||
INENC2.VAL.Max: interf_st_fzp_x_max
|
||||
INENC2.VAL.Mean: interf_st_fzp_x_mean
|
||||
INENC2.VAL.Min: interf_st_fzp_x_min
|
||||
INENC3.VAL.Max: interf_st_rotz_max
|
||||
INENC3.VAL.Mean: interf_st_rotz_mean
|
||||
INENC3.VAL.Min: interf_st_rotz_min
|
||||
INENC4.VAL.Max: interf_st_rotx_max
|
||||
INENC4.VAL.Mean: interf_st_rotx_mean
|
||||
INENC4.VAL.Min: interf_st_rotx_min
|
||||
PCAP.GATE_DURATION.Value: pcap_gate_duration_value
|
||||
deviceTags:
|
||||
- detector
|
||||
enabled: true
|
||||
readOnly: false
|
||||
softwareTrigger: false
|
||||
|
||||
@@ -12,10 +12,10 @@ bpm1:
|
||||
readoutPriority: baseline
|
||||
deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM
|
||||
deviceConfig:
|
||||
blade_t: galilrioesxbox.analog_in.ch0
|
||||
blade_r: galilrioesxbox.analog_in.ch1
|
||||
blade_b: galilrioesxbox.analog_in.ch2
|
||||
blade_l: galilrioesxbox.analog_in.ch3
|
||||
left_top_ref: galilrioesxbox.analog_in.ch0
|
||||
right_top_ref: galilrioesxbox.analog_in.ch1
|
||||
right_bot_ref: galilrioesxbox.analog_in.ch2
|
||||
left_bot_ref: galilrioesxbox.analog_in.ch3
|
||||
enabled: true
|
||||
readOnly: false
|
||||
softwareTrigger: true
|
||||
|
||||
@@ -454,33 +454,36 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard):
|
||||
NOTE! This logic currently works for any step scan, but has to be extended for fly scans.
|
||||
"""
|
||||
while not self._scan_done_thread_kill_event.is_set():
|
||||
while self._start_monitor_async_data_emission.wait():
|
||||
try:
|
||||
if (
|
||||
hasattr(self.scan_parameters, "num_points")
|
||||
and self.scan_parameters.num_points is not None
|
||||
):
|
||||
if self.scan_parameters.scan_type == "software_triggered":
|
||||
logger.info(
|
||||
f"Software triggered scan: {self._current_data_index}/{self.scan_parameters.num_points} points received."
|
||||
)
|
||||
if self._current_data_index == self.scan_parameters.num_points:
|
||||
for callback in self._scan_done_callbacks:
|
||||
callback(exception=None)
|
||||
else:
|
||||
if self._current_data_index >= 1:
|
||||
for callback in self._scan_done_callbacks:
|
||||
callback(exception=None)
|
||||
|
||||
time.sleep(0.02) # 20ms delay to avoid busy loop
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
content = traceback.format_exc()
|
||||
logger.error(
|
||||
f"Exception in monitoring thread of complete for {self.name}:\n{content}"
|
||||
"Running callbacks to avoid deadlock."
|
||||
)
|
||||
for callback in self._scan_done_callbacks:
|
||||
callback(exception=exc)
|
||||
# 20ms delay to avoid busy loop
|
||||
if not self._start_monitor_async_data_emission.wait(timeout=0.02):
|
||||
continue # Wait for the event to be set before checking data emission
|
||||
try:
|
||||
if (
|
||||
hasattr(self.scan_parameters, "num_points")
|
||||
and self.scan_parameters.num_points is not None
|
||||
):
|
||||
if self.scan_parameters.scan_type == "software_triggered":
|
||||
logger.info(
|
||||
f"Software triggered scan: {self._current_data_index}/{self.scan_parameters.num_points} points received."
|
||||
)
|
||||
if self._current_data_index == self.scan_parameters.num_points:
|
||||
for callback in self._scan_done_callbacks:
|
||||
callback(exception=None)
|
||||
else:
|
||||
if self._current_data_index >= 1:
|
||||
for callback in self._scan_done_callbacks:
|
||||
callback(exception=None)
|
||||
# 20ms delay to avoid busy loop
|
||||
if self._scan_done_thread_kill_event.wait(timeout=0.02):
|
||||
continue
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
content = traceback.format_exc()
|
||||
logger.error(
|
||||
f"Exception in monitoring thread of complete for {self.name}:\n{content}"
|
||||
"Running callbacks to avoid deadlock."
|
||||
)
|
||||
for callback in self._scan_done_callbacks:
|
||||
callback(exception=exc)
|
||||
|
||||
def _status_callback(self, status: StatusBase, exception=None) -> None:
|
||||
"""Callback for status completion."""
|
||||
@@ -580,6 +583,7 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard):
|
||||
|
||||
def on_stop(self) -> None:
|
||||
"""Hook called when the device is stopped. In addition, any status that is registered through cancel_on_stop will be cancelled here."""
|
||||
self._start_monitor_async_data_emission.clear()
|
||||
with suppress_mca_callbacks(self):
|
||||
self.stop_all.put(1)
|
||||
self.erase_all.put(1)
|
||||
|
||||
@@ -274,16 +274,14 @@ class Eiger(PSIDeviceBase):
|
||||
start_time = time.time()
|
||||
self.scan_parameters = fetch_scan_info(self.scan_info)
|
||||
|
||||
# TODO: Check mono energy from device in BEC
|
||||
# Setting incident energy in keV
|
||||
|
||||
# TODO Reactivate
|
||||
try:
|
||||
incident_energy = self._get_beam_energy(self.device_manager)
|
||||
if self._incident_energy is None:
|
||||
self._incident_energy = round(float(incident_energy), 3)
|
||||
elif not np.isclose(
|
||||
self._incident_energy, incident_energy, atol=0.01
|
||||
): # 10 keV tolerance
|
||||
self._incident_energy, incident_energy, atol=0.1
|
||||
): # 0.1 keV tolerance
|
||||
logger.warning(
|
||||
f"Incident energy changed from {self._incident_energy} keV to {incident_energy} keV for device {self.name}. "
|
||||
)
|
||||
@@ -328,7 +326,7 @@ class Eiger(PSIDeviceBase):
|
||||
beam_x_pxl=int(self._beam_center[0]),
|
||||
beam_y_pxl=int(self._beam_center[1]),
|
||||
detector_distance_mm=self.detector_distance,
|
||||
incident_energy_ke_v=incident_energy,
|
||||
incident_energy_ke_v=self._incident_energy,
|
||||
)
|
||||
logger.debug(f"Setting data_settings: {yaml.dump(data_settings.to_dict(), indent=4)}")
|
||||
prep_time = time.time()
|
||||
@@ -388,12 +386,12 @@ class Eiger(PSIDeviceBase):
|
||||
f"JungfrauJoch broker status: {yaml.dump(broker_status.to_dict(), indent=4)}"
|
||||
)
|
||||
if broker_status.message_severity == "error": # Raise on error
|
||||
# raise EigerError(
|
||||
# f"Device {self.name} acquisition completed with error status from JungfrauJoch broker: {yaml.dump(broker_status.to_dict(), indent=4)}"
|
||||
# )
|
||||
logger.warning(
|
||||
raise EigerError(
|
||||
f"Device {self.name} acquisition completed with error status from JungfrauJoch broker: {yaml.dump(broker_status.to_dict(), indent=4)}"
|
||||
)
|
||||
# logger.warning(
|
||||
# f"Device {self.name} acquisition completed with error status from JungfrauJoch broker: {yaml.dump(broker_status.to_dict(), indent=4)}"
|
||||
# )
|
||||
# Call API endpoint to get statistics
|
||||
statistics: MeasurementStatistics = (
|
||||
self.jfj_client.api.statistics_data_collection_get(_request_timeout=5)
|
||||
@@ -450,9 +448,6 @@ class Eiger(PSIDeviceBase):
|
||||
Returns:
|
||||
float: The beam energy in keV.
|
||||
"""
|
||||
if hasattr(device_manager, "devices") and hasattr(device_manager.devices, "ccm_energy"):
|
||||
energy = device_manager.devices.ccm_energy.read()[
|
||||
device_manager.devices.ccm_energy.name
|
||||
]["value"]
|
||||
|
||||
return energy
|
||||
return device_manager.devices.ccm_energy.read()[device_manager.devices.ccm_energy.name][
|
||||
"value"
|
||||
]
|
||||
|
||||
@@ -333,7 +333,11 @@ class GalilController(Controller):
|
||||
|
||||
def galil_show_all(self) -> None:
|
||||
for controller in self._controller_instances.values():
|
||||
if isinstance(controller, GalilController):
|
||||
if any(
|
||||
GalilController.__name__ == entry.__name__.split(".")[-1]
|
||||
for entry in controller.__class__.mro()
|
||||
):
|
||||
# if isinstance(controller, GalilController):
|
||||
controller.describe()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -72,10 +72,10 @@ class BPM(PSIPseudoDeviceBase):
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
left_top: str,
|
||||
right_top: str,
|
||||
right_bot: str,
|
||||
left_bot: str,
|
||||
left_top_ref: str,
|
||||
right_top_ref: str,
|
||||
right_bot_ref: str,
|
||||
left_bot_ref: str,
|
||||
device_manager=None,
|
||||
scan_info=None,
|
||||
**kwargs,
|
||||
@@ -83,16 +83,16 @@ class BPM(PSIPseudoDeviceBase):
|
||||
super().__init__(name=name, device_manager=device_manager, scan_info=scan_info, **kwargs)
|
||||
# Get all blade signal objects from utility method
|
||||
signal_t = self.left_top.get_device_object_from_bec(
|
||||
object_name=left_top, signal_name=self.name, device_manager=device_manager
|
||||
object_name=left_top_ref, signal_name=self.name, device_manager=device_manager
|
||||
)
|
||||
signal_r = self.right_top.get_device_object_from_bec(
|
||||
object_name=right_top, signal_name=self.name, device_manager=device_manager
|
||||
object_name=right_top_ref, signal_name=self.name, device_manager=device_manager
|
||||
)
|
||||
signal_b = self.right_bot.get_device_object_from_bec(
|
||||
object_name=right_bot, signal_name=self.name, device_manager=device_manager
|
||||
object_name=right_bot_ref, signal_name=self.name, device_manager=device_manager
|
||||
)
|
||||
signal_l = self.left_bot.get_device_object_from_bec(
|
||||
object_name=left_bot, signal_name=self.name, device_manager=device_manager
|
||||
object_name=left_bot_ref, signal_name=self.name, device_manager=device_manager
|
||||
)
|
||||
|
||||
# Set compute methods for blade signals and virtual signals
|
||||
|
||||
@@ -50,7 +50,7 @@ class BPMControl(PSIPseudoDeviceBase):
|
||||
"""
|
||||
BPM amplifier control pseudo device. It is responsible for controlling the
|
||||
gain and coupling for the BPM amplifier. It relies on signals from a device
|
||||
in BEC to be available. For cSAXS, these are most liikely to be from the
|
||||
in BEC to be available. For cSAXS, these are most likely to be from the
|
||||
GalilRIO device that controls the BPM amplifier.
|
||||
|
||||
Args:
|
||||
@@ -97,7 +97,7 @@ class BPMControl(PSIPseudoDeviceBase):
|
||||
gain_lsb: str,
|
||||
gain_mid: str,
|
||||
gain_msb: str,
|
||||
coupling: str,
|
||||
coupling_ref: str,
|
||||
speed_mode: str,
|
||||
device_manager: DeviceManagerDS | None = None,
|
||||
scan_info: ScanInfo | None = None,
|
||||
@@ -116,7 +116,7 @@ class BPMControl(PSIPseudoDeviceBase):
|
||||
object_name=gain_msb, signal_name=self.name, device_manager=device_manager
|
||||
)
|
||||
self._coupling = self.gain.get_device_object_from_bec(
|
||||
object_name=coupling, signal_name=self.name, device_manager=device_manager
|
||||
object_name=coupling_ref, signal_name=self.name, device_manager=device_manager
|
||||
)
|
||||
self._speed_mode = self.gain.get_device_object_from_bec(
|
||||
object_name=speed_mode, signal_name=self.name, device_manager=device_manager
|
||||
|
||||
@@ -20,11 +20,11 @@ class SignalForwarder(PSIPseudoDeviceBase):
|
||||
doc="Forwarded signal",
|
||||
)
|
||||
|
||||
def __init__(self, name, signal: str, device_manager=None, scan_info=None, **kwargs):
|
||||
def __init__(self, name, signal_ref: str, device_manager=None, scan_info=None, **kwargs):
|
||||
super().__init__(name=name, device_manager=device_manager, scan_info=scan_info, **kwargs)
|
||||
|
||||
src = self.signal.get_device_object_from_bec(
|
||||
object_name=signal, signal_name=self.name, device_manager=device_manager
|
||||
object_name=signal_ref, signal_name=self.name, device_manager=device_manager
|
||||
)
|
||||
|
||||
self.signal.set_compute_method(self._compute_signal, signal=src)
|
||||
|
||||
@@ -48,12 +48,13 @@ Manually move the gripper to a transfer position
|
||||
#### Coarse alignment
|
||||
|
||||
After the sample transfer the sample stage moved to the measurement position with your new sample. The Xray eye will automatically move in and the shutter will open. You may already see the sample in the omny xeye interface running on the windows computer.
|
||||
If you see your sample already at the approximately correct height, you can skip steps 1 to 3. Otherwise adjust the height:
|
||||
+Height is now centered automatically as part of step 3 below, so manual height adjustment is only needed if the sample is far off screen to begin with:
|
||||
|
||||
1. `flomni.feedback_disable()` disable the closed loop operation to allow movement of coarse stages
|
||||
1. `umvr(dev.fsamy, 0.01)`, attention: unit <mm>, move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen
|
||||
|
||||
+1. If the sample is far off screen, `flomni.feedback_disable()` then `flomni.umvr_fsamy_tracked(0.01)`, attention: unit <mm>, move the sample stage relative up (positive) or down (negative) until the sample is visible in the xray eye screen, then `flomni.feedback_enable_with_reset()`
|
||||
1. `flomni.xrayeye_update_frame()` will update the current image on the xray eye screen
|
||||
1. `flomni.xrayeye_alignment_start()` start the coarse alignment of the sample by measuring (clicking in the X-ray eye software) the sample position at 0, 45, 90, 135, 180 degrees. The GUI will present a fit of this data, which is automatically loaded to BEC for aligning the sample.
|
||||
+1. `flomni.xrayeye_alignment_start()` start the coarse alignment: mark the sample once to auto-center its height, then measure (clicking in the X-ray eye software) the sample position at 0, 45, 90, 135, 180 degrees. The GUI will present a fit of this data, which is automatically loaded to BEC for aligning the sample.
|
||||
|
||||
|
||||
#### Fine alignment
|
||||
|
||||
@@ -62,25 +63,37 @@ _To bypass the fine alignment: `flomni.feye_out`_
|
||||
|
||||
|
||||
1. `flomni.tomo_parameters()` Adjust the ptychographic scan parameters for performing an alignment scan. Typically FOVX = FOVX(Xrayeye)+20 mu, shell step = beamsize/2.5, number of projections and tomo mode are ignored in the alignment scans.
|
||||
1. `flomni.tomo_alignment_scan()` perform the alignment scan. When the first scan is running, switch to a matlab session and run `SPEC_ptycho_align` again. Click left and right. The third click can define the height of the scan, but is not needed and ignored by default. The widest horizontal field of view will be printed at the end of the matlab session.
|
||||
1. `flomni.tomo_alignment_scan()` perform the alignment scan. When the first scan is running, switch to a matlab session and run `BEC_ptycho_align` again. Click left and right. The third click can define the height of the scan, but is not needed and ignored by default. The widest horizontal field of view will be printed at the end of the matlab session.
|
||||
1. `flomni.read_alignment_offset()` Load alignment parameters calculated in matlab.
|
||||
|
||||
### Tomographic Measurement
|
||||
|
||||
Now that the sample is aligned, the tomographic measurement can be performed.
|
||||
1. `flomni.tomo_parameters()` adjust the scan parameters for the tomographic scan. This includes the parameters for ptychographic scans of projections plus the strategy for angular sampling. The vertical shift adjusts the field of view, up (positive) or down (negative). After adjusting the numbers, type again `flomni.tomo_parameters()` and verify that they are correct.
|
||||
1. `flomni.tomo_scan_projection(angle)` perform a ptychographic scan at the rotation angle <angle>. Launch the tomographic measurement by `flomni.tomo_scan()`.
|
||||
1. `flomni.tomo_parameters()` adjust the scan parameters for the tomographic scan. This includes the parameters for ptychographic scans of projections plus the strategy for angular sampling. The vertical shift adjusts the field of view, up (positive) or down (negative). After adjusting the numbers, type again `flomni.tomo_parameters()` and verify that they are correct. For the "8 sub-tomograms" mode, an optional 0-deg reference shot can be enabled at the start of every odd sub-tomogram and once more at the end, useful for tracking radiation damage over the course of a long measurement.
|
||||
1. `flomni.tomo_scan_projection(angle)` perform a ptychographic scan at the rotation angle <angle>. Launch the tomographic measurement by `flomni.tomo_scan()`. During the scan a live progress report is printed, including an estimated time of completion. Unusually long gaps (e.g. a beamline interruption) are detected automatically and excluded from the estimate, and the total time lost to such gaps is reported once the scan finishes.
|
||||
1. Before changing sample, verify that all subtomograms were completely acquired using the tomo_reconstruction matlab script.
|
||||
|
||||
#### Queueing multiple scans
|
||||
|
||||
Several parameter sets can be queued and run one after another automatically, e.g. a fast overview scan followed by a higher resolution scan on the same sample, without having to wait around to start the next one by hand.
|
||||
1. `flomni.tomo_parameters()` set up the first parameter set, then `flomni.tomo_queue_add("label")` to add it to the queue.
|
||||
1. Repeat for further parameter sets.
|
||||
1. `flomni.tomo_queue_show()` review the queue before starting.
|
||||
1. `flomni.tomo_queue_execute()` run all queued scans in sequence.
|
||||
|
||||
If a queued scan is interrupted, the next call to `flomni.tomo_queue_execute()` resumes it automatically rather than starting over, then continues with the remaining entries. See [Tomography](user.ptychography.flomni.tomography) below for the full command reference.
|
||||
|
||||
#### If something went wrong…
|
||||
|
||||
A __single projection__ is to be repeated use
|
||||
`flomni.tomo_scan_projection(<angle>)`. The target angle of scans can be found in the second column of the file in
|
||||
`~/data/raw/logs/tomography_scannumbers.txt`
|
||||
|
||||
To continue an __interrupted tomography scan__:
|
||||
To continue an __interrupted tomography scan__, run
|
||||
`flomni.tomo_scan_resume()`
|
||||
It picks up automatically from wherever the scan was interrupted, no need to look up the exact subtomogram/angle by hand.
|
||||
|
||||
Depending on the tomo mode following parameters can be given to the `flomni.tomo_scan()` command:
|
||||
Alternatively, the same kind of parameters used internally by `tomo_scan_resume()` can be given to `flomni.tomo_scan()` directly, e.g. to resume from a different point than where it actually stopped:
|
||||
| tomo type | parameters and their defaults |
|
||||
| --- | --- |
|
||||
| 8 sub-tomograms | subtomo_start=1, start_angle=None |
|
||||
@@ -245,7 +258,7 @@ To start the xray eye alignment (and clear any previous alignment)
|
||||
This command can also be called to keep the shutter open and live view active. Warning: The dose to the sample will be significantly higher.
|
||||
`flomni.xrayeye_update_frame(keep_shutter_open=True)`
|
||||
|
||||
To load the fit parameters from directory _dir_path_ computed by _SPEC_ptycho_align.m_ in Matlab run
|
||||
To load the fit parameters from directory _dir_path_ computed by _BEC_ptycho_align.m_ in Matlab run
|
||||
`flomni.read_alignment_offset(dir_path='')`
|
||||
The loading routine uses default values for the vertical alignment. This behavior can be changed (e.g. for getting new default values) by the parameter `use_vertical_default_values=False`.
|
||||
|
||||
@@ -263,7 +276,7 @@ The alginment obtained by the X-ray eye can be refinde by recording ptychography
|
||||
Next, run the alignment scan by
|
||||
`flomni.tomo_alignment_scan()`
|
||||
|
||||
Reconstruct the scan and use SPEC_ptycho_align.m to obtain improved fit parameters. The new parameters can be loaded by
|
||||
Reconstruct the scan and use BEC_ptycho_align.m to obtain improved fit parameters. The new parameters can be loaded by
|
||||
`flomni.read_alignment_offset()`
|
||||
|
||||
For a __very__ tight vertical field of view, a fine vertical alignment based on outputs generated from early tomography reconstructions can be used. A corresponding file can be generated by the tomography reconstruction script and can be loaded by the following two methods:
|
||||
@@ -288,6 +301,7 @@ Define the scan parameters by `flomni.tomo_parameters()`
|
||||
|
||||
Run a scan at _angle_ (in degrees) by `flomni.tomo_scan_projection(angle)`
|
||||
|
||||
(user.ptychography.flomni.tomography)=
|
||||
### Tomography
|
||||
|
||||
The tomo parameters have to be set by
|
||||
@@ -296,6 +310,8 @@ The tomo parameters have to be set by
|
||||
Once satisfied with the alignment, the tomography scan can be started by
|
||||
`flomni.tomo_scan()`
|
||||
|
||||
During the scan, a live progress report is printed (subtomogram/projection counters and an estimated time of completion). Gaps significantly longer than a normal acquisition cycle (e.g. a beamline-down interruption, or a crash followed by a restart) are detected automatically from the time between consecutive projections and excluded from the time estimate; the total time lost to such gaps is printed once the scan finishes.
|
||||
|
||||
Three modes for angular sampling are implemented and they have different optional parameters for the tomo_scan method:
|
||||
| tomography mode | parameters and defaults |
|
||||
| --- | --- |
|
||||
@@ -303,9 +319,38 @@ Three modes for angular sampling are implemented and they have different optiona
|
||||
| Golden ratio tomography (sorted in bunches) | projection_number=None |
|
||||
| Equally spaced with golden starting angle | projection_number=None |
|
||||
|
||||
The parameters can be used to __restart an interrupted acquisition__.
|
||||
For the "8 sub-tomograms" mode, `flomni.tomo_parameters()` also offers a `zero_deg_reference_at_each_subtomo` option: when enabled, an additional reference projection at exactly 0 degrees is acquired at the start of every odd (forward) sub-tomogram and once more after the final sub-tomogram, useful for tracking radiation damage across the full measurement.
|
||||
|
||||
The parameters above can be used to __restart an interrupted acquisition__ manually, or - more conveniently - by running
|
||||
`flomni.tomo_scan_resume()`
|
||||
which reads the last recorded progress and resumes automatically at the exact point (subtomogram/angle, or projection for the golden ratio modes) the scan was interrupted at, without needing to look up the values by hand.
|
||||
|
||||
In case of eight equally spaced sub-tomograms, an individual sub tomogram can be scanned by flomni.sub_tomo_scan(subtomo_number, start_angle). If the start angle is not specified, it will be computed depending on the subtomo_number, which is ranging from 1 to 8.
|
||||
|
||||
#### Queueing multiple scans
|
||||
|
||||
Several tomo parameter sets can be queued and run sequentially on the same sample, without having to start each one by hand.
|
||||
|
||||
| command | explanation |
|
||||
| --- | --- |
|
||||
| `flomni.tomo_queue_add(label=None)` | Snapshot the currently set tomo parameters and add them as a new job to the queue. Returns the job's index. |
|
||||
| `flomni.tomo_queue_show()` | Print and return the current queue, with status per job. |
|
||||
| `flomni.tomo_queue_delete(*indices)` | Delete one or more jobs by index. |
|
||||
| `flomni.tomo_queue_clear()` | Empty the queue. |
|
||||
| `flomni.tomo_queue_execute(start_index=0)` | Run all pending jobs in sequence, on the current sample. |
|
||||
|
||||
The queue is persisted (it survives a BEC client restart). Each job's status is one of `pending`, `running`, `incomplete`, or `done`. A job that did not run to completion (an exception was caught, or the BEC client itself crashed mid-scan) is automatically resumed - rather than restarted - the next time `flomni.tomo_queue_execute()` is called.
|
||||
|
||||
Example:
|
||||
```
|
||||
flomni.tomo_parameters() # set up parameter set #1
|
||||
flomni.tomo_queue_add("fast overview")
|
||||
flomni.tomo_parameters() # set up parameter set #2
|
||||
flomni.tomo_queue_add("hires scan")
|
||||
flomni.tomo_queue_show()
|
||||
flomni.tomo_queue_execute() # runs both, in order, on this sample
|
||||
```
|
||||
|
||||
### Sample storage and transfer
|
||||
|
||||
[See short version](user.ptychography.flomni.transfer)
|
||||
|
||||
@@ -43,7 +43,7 @@ def bpm_control(patched_dm):
|
||||
"gain_lsb": "gain_lsb",
|
||||
"gain_mid": "gain_mid",
|
||||
"gain_msb": "gain_msb",
|
||||
"coupling": "coupling",
|
||||
"coupling_ref": "coupling",
|
||||
"speed_mode": "speed_mode",
|
||||
},
|
||||
needs=["gain_lsb", "gain_mid", "gain_msb", "coupling", "speed_mode"],
|
||||
@@ -55,7 +55,7 @@ def bpm_control(patched_dm):
|
||||
gain_lsb="gain_lsb",
|
||||
gain_mid="gain_mid",
|
||||
gain_msb="gain_msb",
|
||||
coupling="coupling",
|
||||
coupling_ref="coupling",
|
||||
speed_mode="speed_mode",
|
||||
device_manager=patched_dm,
|
||||
)
|
||||
@@ -150,10 +150,10 @@ def bpm(patched_dm_bpm):
|
||||
enabled=True,
|
||||
readoutPriority="baseline",
|
||||
deviceConfig={
|
||||
"left_top": "left_top",
|
||||
"right_top": "right_top",
|
||||
"right_bot": "right_bot",
|
||||
"left_bot": "left_bot",
|
||||
"left_top_ref": "left_top",
|
||||
"right_top_ref": "right_top",
|
||||
"right_bot_ref": "right_bot",
|
||||
"left_bot_ref": "left_bot",
|
||||
},
|
||||
needs=["left_top", "right_top", "right_bot", "left_bot"],
|
||||
)
|
||||
@@ -161,10 +161,10 @@ def bpm(patched_dm_bpm):
|
||||
try:
|
||||
bpm = BPM(
|
||||
name=name,
|
||||
left_top="left_top",
|
||||
right_top="right_top",
|
||||
right_bot="right_bot",
|
||||
left_bot="left_bot",
|
||||
left_top_ref="left_top",
|
||||
right_top_ref="right_top",
|
||||
right_bot_ref="right_bot",
|
||||
left_bot_ref="left_bot",
|
||||
device_manager=patched_dm_bpm,
|
||||
)
|
||||
patched_dm_bpm.devices._add_device(bpm.name, bpm)
|
||||
|
||||
Reference in New Issue
Block a user