adjust gripper sensor voltage in config #232

Merged
holler merged 36 commits from flomni_commissioning into main 2026-06-30 14:31:37 +02:00
24 changed files with 1757 additions and 456 deletions
@@ -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 35
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("")
@@ -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">&middot;&nbsp;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">&times;</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> &mdash; a tomography scan is actively running (a heartbeat from the scan loop has been seen recently).</li>
<li><strong>Running</strong> &mdash; 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> &mdash; 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> &mdash; nothing is running or queued right now.</li>
<li><strong>Unknown</strong> &mdash; 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&rarr;S06770), for direct comparison with reconstruction filenames.</p>
<h3>Audio warnings</h3>
<ul>
<li><strong>System LED</strong> &mdash; on when audio is enabled on this device.</li>
<li><strong>Watch LED</strong> &mdash; 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> &mdash; pulses orange while the queue is locked. A chime fires automatically after 60 continuous minutes blocked, then repeats hourly until cleared. No confirmation needed &mdash; it clears itself once the block resolves.</li>
<li><strong>Live LED</strong> &mdash; 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 &mdash; 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 &mdash; 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 &#8942;&#8942; 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">&#8942;&#8942;</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">&#8942;&#8942;</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 &middot; projection '+(d.progress.projection||0)+' of '+(d.progress.total_projections||0)+' &middot; '+(d.progress.tomo_type||''),
running: d=>'Queue active &middot; 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 &middot; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}}
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> &middot; 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
? ' &middot; <strong>'+mismatched.length+'</strong> watched state'+(mismatched.length>1?'s':'')+' currently blocking'
: ' &middot; 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
+57
View File
@@ -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_())
+14 -14
View File
@@ -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
+12 -9
View File
@@ -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
+4 -4
View File
@@ -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
+92 -89
View File
@@ -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"
+4 -4
View File
@@ -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
+61 -44
View File
@@ -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
+4 -4
View File
@@ -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)
+11 -16
View File
@@ -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"
]
+5 -1
View File
@@ -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
+8 -8
View File
@@ -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)
+57 -12
View File
@@ -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)
+10 -10
View File
@@ -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)