Flomni commissioning 4 #250
@@ -49,6 +49,22 @@ class LamniWebpageGenerator(WebpageGeneratorBase):
|
||||
sample name, and measurement settings.
|
||||
"""
|
||||
|
||||
# LamNI has no persisted parameter-snapshot job queue (unlike flomni
|
||||
# and, later, omny) — the "Tomo queue" card and its global-var read
|
||||
# are skipped entirely for this setup.
|
||||
HAS_TOMO_QUEUE = False
|
||||
|
||||
# LamNI's tomo scans come in two flavours: a 2-subtomo and an
|
||||
# 8-subtomo variant, both using the same equally-spaced-grid formula
|
||||
# as flomni's type 1 (just with a different sub-tomo count).
|
||||
# TODO: replace the placeholder keys (1, 2) with whatever values
|
||||
# progress["tomo_type"] actually takes for LamNI once the producer
|
||||
# side (LamNI equivalent of flomni.py) defines them.
|
||||
TOMO_TYPES = {
|
||||
1: {"kind": "equally_spaced_grid", "n_subtomos": 2},
|
||||
2: {"kind": "equally_spaced_grid", "n_subtomos": 8},
|
||||
}
|
||||
|
||||
# TODO: fill in LamNI-specific device paths
|
||||
# label -> dotpath under device_manager.devices
|
||||
_TEMP_MAP = {
|
||||
@@ -86,4 +102,4 @@ class LamniWebpageGenerator(WebpageGeneratorBase):
|
||||
return {
|
||||
"type": "lamni",
|
||||
# LamNI-specific data here
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import builtins
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -463,7 +465,117 @@ class FlomniSampleTransferMixin:
|
||||
def laser_tracker_off(self):
|
||||
dev.rtx.controller.laser_tracker_off()
|
||||
|
||||
def umvr_fsamy_tracked(self, shift: float, step: float = 0.01):
|
||||
def laser_parameters_show_all(self):
|
||||
dev.rtx.controller.emitter_show_all()
|
||||
|
||||
def laser_parameters_set_targety(self, val: float):
|
||||
dev.rtx.controller.emitter_set_tracking_target_y(val)
|
||||
|
||||
def laser_parameters_set_targetz(self, val: float):
|
||||
dev.rtx.controller.emitter_set_tracking_target_z(val)
|
||||
|
||||
def laser_parameters_set_intensity_threshold_laser(self, val: float):
|
||||
dev.rtx.controller.emitter_set_intensity_threshold_laser(val)
|
||||
|
||||
def laser_parameters_set_psd_intensity_threshold_tracking_low(self, val: float):
|
||||
dev.rtx.controller.emitter_set_psd_intensity_threshold_tracking_low(val)
|
||||
|
||||
def laser_tweak(self):
|
||||
"""Interactive tweak of the two laser tracking target positions.
|
||||
|
||||
Arrow keys nudge the ConstEmitter tracking targets by a fixed step of
|
||||
0.01 (absolute set of current +/- step):
|
||||
left / right : target y -/+
|
||||
up / down : target z +/-
|
||||
Keys:
|
||||
s : return both targets to the positions they had at launch
|
||||
q : quit
|
||||
|
||||
After every change the current y/z targets and the fzp x interferometer
|
||||
signal strength (channel 1) are printed, so one can watch the signal
|
||||
respond while tweaking.
|
||||
"""
|
||||
import fcntl
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
|
||||
step = 0.01
|
||||
controller = dev.rtx.controller
|
||||
|
||||
def read_targets():
|
||||
values = controller.emitter_get()
|
||||
return (
|
||||
values["targetpos_tracking_y"],
|
||||
values["targetpos_tracking_z"],
|
||||
)
|
||||
|
||||
def signal_fzpx():
|
||||
# second channel (index 1) of show_signal_strength_interferometer,
|
||||
# i.e. the fzp x channel / tracker signal
|
||||
return controller.read_ssi_interferometer(1)
|
||||
|
||||
def print_status():
|
||||
target_y, target_z = read_targets()
|
||||
print(
|
||||
f"\rtarget y: {target_y:.4f} target z: {target_z:.4f} "
|
||||
f"fzp x signal: {signal_fzpx():.1f}\r"
|
||||
)
|
||||
|
||||
# capture launch positions for the 's' (return to start) command
|
||||
start_y, start_z = read_targets()
|
||||
|
||||
print(
|
||||
"Laser tweak. "
|
||||
+ self.OMNYTools.BOLD
|
||||
+ self.OMNYTools.OKBLUE
|
||||
+ "left/right: target y, up/down: target z, s: start pos, q: quit\r"
|
||||
+ self.OMNYTools.ENDC
|
||||
)
|
||||
print_status()
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_term = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
old_flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, old_flags | os.O_NONBLOCK)
|
||||
while True:
|
||||
try:
|
||||
key = sys.stdin.read(1)
|
||||
if key == "q":
|
||||
print("\n\rExiting tweak mode\r")
|
||||
break
|
||||
elif key == "s":
|
||||
controller.emitter_set_tracking_target_y(start_y)
|
||||
controller.emitter_set_tracking_target_z(start_z)
|
||||
print("\rReturned to start positions\r")
|
||||
print_status()
|
||||
elif key == "\x1b": # escape sequences for arrow keys
|
||||
next1, next2 = sys.stdin.read(2)
|
||||
if next1 == "[":
|
||||
target_y, target_z = read_targets()
|
||||
if next2 == "A": # up: target z +
|
||||
controller.emitter_set_tracking_target_z(target_z + step)
|
||||
print_status()
|
||||
elif next2 == "B": # down: target z -
|
||||
controller.emitter_set_tracking_target_z(target_z - step)
|
||||
print_status()
|
||||
elif next2 == "C": # right: target y +
|
||||
controller.emitter_set_tracking_target_y(target_y + step)
|
||||
print_status()
|
||||
elif next2 == "D": # left: target y -
|
||||
controller.emitter_set_tracking_target_y(target_y - step)
|
||||
print_status()
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
time.sleep(0.02)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_term)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, old_flags)
|
||||
|
||||
def umvr_fsamy_tracked(self, shift: float, step: float = 0.01, _internal: bool = False):
|
||||
"""
|
||||
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()
|
||||
@@ -485,10 +597,22 @@ class FlomniSampleTransferMixin:
|
||||
other direction).
|
||||
step: maximum step size in mm per tracker re-acquisition.
|
||||
Must be positive. Default 0.01 mm (10 um).
|
||||
_internal: set True by internal callers that already manage the
|
||||
feedback lifecycle themselves, to skip the feedback guard
|
||||
below. User/CLI calls leave this False.
|
||||
"""
|
||||
if step <= 0:
|
||||
raise ValueError("step must be a positive number of mm.")
|
||||
|
||||
if not _internal and dev.rtx.controller.feedback_is_running():
|
||||
print(
|
||||
"\x1b[91mThis move requires rt feedback OFF; any active alignment "
|
||||
"may be lost.\x1b[0m"
|
||||
)
|
||||
if not self.OMNYTools.yesno("Disable feedback and continue?", "y"):
|
||||
return
|
||||
self.feedback_disable()
|
||||
|
||||
direction = 1 if shift >= 0 else -1
|
||||
remaining = abs(shift)
|
||||
while remaining > 0:
|
||||
@@ -497,7 +621,7 @@ class FlomniSampleTransferMixin:
|
||||
dev.rtx.controller.laser_tracker_on()
|
||||
remaining -= this_step
|
||||
|
||||
def umv_fsamy_tracked(self, target: float, step: float = 0.01):
|
||||
def umv_fsamy_tracked(self, target: float, step: float = 0.01, _internal: bool = False):
|
||||
"""
|
||||
Absolute move of fsamy to `target` mm (same units as dev.fsamy's
|
||||
readback / dev.fsamy.user_parameter.get("in")), broken into steps of
|
||||
@@ -508,10 +632,12 @@ class FlomniSampleTransferMixin:
|
||||
step: maximum step size in mm per tracker re-acquisition.
|
||||
Must be positive (validated in umvr_fsamy_tracked).
|
||||
Default 0.01 mm (10 um).
|
||||
_internal: forwarded to umvr_fsamy_tracked() to skip the feedback
|
||||
guard for callers that manage feedback themselves.
|
||||
"""
|
||||
current = dev.fsamy.readback.get()
|
||||
shift_mm = target - current
|
||||
self.umvr_fsamy_tracked(shift_mm, step)
|
||||
self.umvr_fsamy_tracked(shift_mm, step, _internal=_internal)
|
||||
|
||||
def show_signal_strength_interferometer(self):
|
||||
dev.rtx.controller.show_signal_strength_interferometer()
|
||||
@@ -565,7 +691,7 @@ class FlomniSampleTransferMixin:
|
||||
"Could not find an 'IN' position for fsamy. Please check your config."
|
||||
)
|
||||
|
||||
self.umv_fsamy_tracked(fsamy_in)
|
||||
self.umv_fsamy_tracked(fsamy_in, _internal=True)
|
||||
time.sleep(0.05)
|
||||
self.laser_tracker_off()
|
||||
time.sleep(0.05)
|
||||
@@ -1597,6 +1723,25 @@ class Flomni(
|
||||
def manual_shift_y(self, val: float):
|
||||
self.client.set_global_var("manual_shift_y", val)
|
||||
|
||||
@property
|
||||
def single_point_random_shift_max(self):
|
||||
"""Maximum absolute random shift [um] applied independently in x and y
|
||||
at each single-point acquisition (tomo_acquire_at_angle). At each
|
||||
angle a fresh random offset drawn uniformly from
|
||||
[-single_point_random_shift_max, +single_point_random_shift_max] is
|
||||
added to both x and y. 0 disables the random shift. Only takes effect
|
||||
when single_point_instead_of_fermat_scan is active."""
|
||||
val = self.client.get_global_var("single_point_random_shift_max")
|
||||
if val is None:
|
||||
return 0.0
|
||||
return val
|
||||
|
||||
@single_point_random_shift_max.setter
|
||||
def single_point_random_shift_max(self, val: float):
|
||||
if not 0 <= val <= 10:
|
||||
raise ValueError("single_point_random_shift_max must be between 0 and 10 um.")
|
||||
self.client.set_global_var("single_point_random_shift_max", val)
|
||||
|
||||
@property
|
||||
def fovx(self):
|
||||
val = self.client.get_global_var("fovx")
|
||||
@@ -1847,7 +1992,7 @@ class Flomni(
|
||||
while not successful:
|
||||
try:
|
||||
start_scan_number = bec.queue.next_scan_number
|
||||
self.tomo_scan_projection(angle)
|
||||
self.tomo_scan_projection(angle, _internal=True)
|
||||
error_caught = False
|
||||
except AlarmBase as exc:
|
||||
if exc.alarm_type == "TimeoutError":
|
||||
@@ -2083,13 +2228,25 @@ class Flomni(
|
||||
else:
|
||||
num_repeats = 1
|
||||
start_scan_number = bec.queue.next_scan_number
|
||||
projection_start = time.perf_counter()
|
||||
for i in range(num_repeats):
|
||||
self._at_each_angle(angle)
|
||||
projection_duration = time.perf_counter() - projection_start
|
||||
|
||||
end_scan_number = bec.queue.next_scan_number
|
||||
for scan_nr in range(start_scan_number, end_scan_number):
|
||||
self._write_tomo_scan_number(scan_nr, angle, subtomo_number)
|
||||
|
||||
# Only reached when the projection completed without @scan_repeat
|
||||
# re-raising, so this is the final successful attempt's duration.
|
||||
self._log_projection_timing(
|
||||
angle=angle,
|
||||
subtomo_number=subtomo_number,
|
||||
duration_s=projection_duration,
|
||||
start_scan_number=start_scan_number,
|
||||
end_scan_number=end_scan_number,
|
||||
)
|
||||
|
||||
def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None):
|
||||
"""start a tomo scan"""
|
||||
|
||||
@@ -2264,6 +2421,7 @@ class Flomni(
|
||||
self.progress["projection"] = self.progress["total_projections"]
|
||||
self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"]
|
||||
self._print_progress()
|
||||
self._log_tomogram_timing()
|
||||
self.OMNYTools.printgreenbold("Tomoscan finished")
|
||||
print(
|
||||
f"Total measurement time lost to detected gaps: {self._format_duration(self.progress.get('accumulated_idle_time', 0.0))}"
|
||||
@@ -2389,7 +2547,7 @@ class Flomni(
|
||||
self.tomo_acquire_at_angle(angle)
|
||||
return
|
||||
|
||||
self.tomo_scan_projection(angle)
|
||||
self.tomo_scan_projection(angle, _internal=True)
|
||||
|
||||
def _golden(self, ii, howmany_sorted, maxangle, reverse=False):
|
||||
"""returns the iis golden ratio angle of sorted bunches of howmany_sorted and its subtomo number"""
|
||||
@@ -2443,6 +2601,8 @@ class Flomni(
|
||||
self,
|
||||
base_path="~/data/raw/logs/reconstruction_queue",
|
||||
probe_propagation: float | None = None,
|
||||
random_offset_x: float | None = None,
|
||||
random_offset_y: float | None = None,
|
||||
):
|
||||
"""write the tomo reconstruct file for the reconstruction queue"""
|
||||
bec = builtins.__dict__.get("bec")
|
||||
@@ -2451,17 +2611,152 @@ class Flomni(
|
||||
next_scan_number=bec.queue.next_scan_number,
|
||||
base_path=base_path,
|
||||
probe_file_propagation=probe_propagation,
|
||||
random_offset_x=random_offset_x,
|
||||
random_offset_y=random_offset_y,
|
||||
)
|
||||
|
||||
_TIMING_LOG_DIR = "~/data/raw/logs/timing_statistics"
|
||||
_TIMING_SETUP = "flomni"
|
||||
_PROJECTION_TIMING_LOG = "projection_timing_log.jsonl"
|
||||
_TOMOGRAM_TIMING_LOG = "tomogram_timing_log.jsonl"
|
||||
|
||||
def _append_timing_record(self, filename: str, record: dict) -> None:
|
||||
"""Append one JSON record as a line to a timing-statistics log file.
|
||||
|
||||
Deliberately best-effort: a failure to write a timing record must
|
||||
never abort or interfere with an in-progress measurement, so any
|
||||
exception here is caught and logged rather than propagated. The
|
||||
files live in a dedicated subfolder (self._TIMING_LOG_DIR), separate
|
||||
from tomography_scannumbers.txt and any other existing log, and are
|
||||
pure append-only JSONL (one JSON object per line) so they are
|
||||
trivial to load later with pandas.read_json(..., lines=True) for the
|
||||
eventual scan-time prediction model.
|
||||
"""
|
||||
try:
|
||||
log_dir = os.path.expanduser(self._TIMING_LOG_DIR)
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, filename)
|
||||
with open(log_file, "a+") as out_file:
|
||||
out_file.write(json.dumps(record) + "\n")
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"Failed to write timing record to {filename}: {exc}")
|
||||
|
||||
def _log_projection_timing(
|
||||
self,
|
||||
angle: float,
|
||||
subtomo_number: int,
|
||||
duration_s: float,
|
||||
start_scan_number: int,
|
||||
end_scan_number: int,
|
||||
) -> None:
|
||||
"""Write one per-projection timing record.
|
||||
|
||||
A "projection" here is one completed _at_each_angle() call (all the
|
||||
stitch tiles / the single-point acquisition for one angle). Only
|
||||
successfully completed projections reach this point: because
|
||||
_tomo_scan_at_angle is wrapped in @scan_repeat, a failed attempt
|
||||
re-invokes the whole method and never returns to the logging call,
|
||||
so retried attempts are naturally excluded and only the final
|
||||
successful duration is recorded.
|
||||
|
||||
The parameters logged are exactly the scan settings that plausibly
|
||||
drive the duration (FOV, step, counting time, burst frames, stitch
|
||||
tiling, corridor, single-point mode) -- the raw feature set for the
|
||||
prediction model. The actual fermat-scan point count is deliberately
|
||||
not resolved here; if it turns out to be needed it can be added
|
||||
later once real data shows the raw settings aren't enough.
|
||||
"""
|
||||
record = {
|
||||
"setup": self._TIMING_SETUP,
|
||||
"timestamp": datetime.datetime.now().isoformat(),
|
||||
"duration_s": duration_s,
|
||||
"angle": angle,
|
||||
"subtomo_number": subtomo_number,
|
||||
"start_scan_number": start_scan_number,
|
||||
"end_scan_number": end_scan_number,
|
||||
"n_scans": end_scan_number - start_scan_number,
|
||||
"tomo_type": self.tomo_type,
|
||||
"single_point_instead_of_fermat_scan": self.single_point_instead_of_fermat_scan,
|
||||
"fovx": self.fovx,
|
||||
"fovy": self.fovy,
|
||||
"tomo_shellstep": self.tomo_shellstep,
|
||||
"tomo_countingtime": self.tomo_countingtime,
|
||||
"frames_per_trigger": self.frames_per_trigger,
|
||||
"stitch_x": self.stitch_x,
|
||||
"stitch_y": self.stitch_y,
|
||||
"tomo_stitch_overlap": self.tomo_stitch_overlap,
|
||||
"corridor_size": self.corridor_size,
|
||||
}
|
||||
self._append_timing_record(self._PROJECTION_TIMING_LOG, record)
|
||||
|
||||
def _log_tomogram_timing(self) -> None:
|
||||
"""Write one per-tomogram timing record at the end of a tomo_scan().
|
||||
|
||||
Captures a full snapshot of the scan parameters (reusing
|
||||
_TOMO_QUEUE_PARAM_NAMES -- the single source of truth for "every
|
||||
parameter that affects how the scan runs") together with the
|
||||
tomogram-level wall-clock timing already tracked in self.progress:
|
||||
total elapsed, the accumulated idle time detected from inter-
|
||||
projection gaps, and the active (elapsed-minus-idle) measurement
|
||||
time. Standalone by design -- no tomo_id / sample-database cross-
|
||||
reference for now.
|
||||
"""
|
||||
start_str = self.progress.get("tomo_start_time")
|
||||
now = datetime.datetime.now()
|
||||
elapsed_s = None
|
||||
if start_str is not None:
|
||||
try:
|
||||
elapsed_s = (now - datetime.datetime.fromisoformat(start_str)).total_seconds()
|
||||
except (ValueError, TypeError):
|
||||
elapsed_s = None
|
||||
|
||||
idle_s = self.progress.get("accumulated_idle_time", 0.0)
|
||||
active_s = elapsed_s - idle_s if elapsed_s is not None else None
|
||||
|
||||
record = {
|
||||
"setup": self._TIMING_SETUP,
|
||||
"timestamp": now.isoformat(),
|
||||
"tomo_start_time": start_str,
|
||||
"elapsed_s": elapsed_s,
|
||||
"accumulated_idle_time_s": idle_s,
|
||||
"active_s": active_s,
|
||||
"total_projections": self.progress.get("total_projections"),
|
||||
"tomo_type_label": self.progress.get("tomo_type"),
|
||||
"params": {name: getattr(self, name) for name in self._TOMO_QUEUE_PARAM_NAMES},
|
||||
}
|
||||
self._append_timing_record(self._TOMOGRAM_TIMING_LOG, record)
|
||||
|
||||
def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None:
|
||||
tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt")
|
||||
with open(tomo_scan_numbers_file, "a+") as out_file:
|
||||
# pylint: disable=undefined-variable
|
||||
out_file.write(
|
||||
f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n"
|
||||
f"{scan_number} {angle} {dev.fsamroy.read()['fsamroy']['value']:.5f} {self.tomo_id} {subtomo_number} {0} {self.sample_name}\n"
|
||||
)
|
||||
|
||||
def tomo_scan_projection(self, angle: float):
|
||||
def tomo_scan_projection(self, angle: float, _internal: bool = False):
|
||||
"""Acquire one fermat-scan projection at `angle`.
|
||||
|
||||
Note: this ALWAYS runs a fermat scan, regardless of
|
||||
single_point_instead_of_fermat_scan. That flag is honored by the
|
||||
normal tomo flow (tomo_scan -> _at_each_angle) and by
|
||||
tomo_acquire_at_angle, not here -- a single named command does one
|
||||
kind of acquisition. If you call this directly at the CLI with the
|
||||
single-point flag set, you probably meant tomo_acquire_at_angle();
|
||||
the guard below points that out. Internal callers that legitimately
|
||||
want the fermat path regardless (the fermat branch of
|
||||
_at_each_angle, and tomo_alignment_scan, which needs real ptycho
|
||||
data) pass _internal=True to skip the prompt.
|
||||
"""
|
||||
if not _internal and self.single_point_instead_of_fermat_scan:
|
||||
print(
|
||||
"\x1b[93mWarning: single_point_instead_of_fermat_scan is set, but"
|
||||
" tomo_scan_projection always runs a FERMAT scan. For a single-point"
|
||||
" acquisition at this angle use tomo_acquire_at_angle(angle) instead.\x1b[0m"
|
||||
)
|
||||
if not self.OMNYTools.yesno("Run a fermat scan anyway?", "n"):
|
||||
print("Aborted. Use tomo_acquire_at_angle(angle) for a single-point acquisition.")
|
||||
return
|
||||
|
||||
dev.rtx.controller.laser_tracker_check_signalstrength()
|
||||
|
||||
@@ -2550,17 +2845,30 @@ class Flomni(
|
||||
else:
|
||||
print("No rotation required")
|
||||
|
||||
# --- optional random shift in x and y (single-point acquisitions) ---
|
||||
# A fresh offset is drawn per angle from a uniform distribution over
|
||||
# [-single_point_random_shift_max, +single_point_random_shift_max] and
|
||||
# added independently to x and y. 0 (the default) disables it.
|
||||
random_shift_max = self.single_point_random_shift_max
|
||||
if random_shift_max > 0:
|
||||
random_shift_x = random.uniform(-random_shift_max, random_shift_max)
|
||||
random_shift_y = random.uniform(-random_shift_max, random_shift_max)
|
||||
else:
|
||||
random_shift_x = 0.0
|
||||
random_shift_y = 0.0
|
||||
|
||||
# --- alignment offset (same as tomo_scan_projection, no stitching) ---
|
||||
offsets = self.get_alignment_offset(angle)
|
||||
sum_offset_x = offsets[0]
|
||||
sum_offset_x = offsets[0] + random_shift_x
|
||||
sum_offset_y = (
|
||||
offsets[1]
|
||||
- self.compute_additional_correction_y(angle)
|
||||
- self.compute_additional_correction_y_2(angle)
|
||||
+ self.manual_shift_y
|
||||
+ random_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
|
||||
@@ -2583,8 +2891,13 @@ class Flomni(
|
||||
|
||||
# --- acquire ---
|
||||
n_frames = frames_per_trigger if frames_per_trigger is not None else self.frames_per_trigger
|
||||
self._current_scan_list = [bec.queue.next_scan_number]
|
||||
scans.acquire(exp_time=self.tomo_countingtime, frames_per_trigger=n_frames)
|
||||
self.tomo_reconstruct(probe_propagation=probe_propagation)
|
||||
self.tomo_reconstruct(
|
||||
probe_propagation=probe_propagation,
|
||||
random_offset_x=random_shift_x,
|
||||
random_offset_y=random_shift_y,
|
||||
)
|
||||
|
||||
def _tomo_type1_actual_grid(self) -> tuple[int, float, int]:
|
||||
"""Compute the actual (achievable) tomo_type==1 grid from the
|
||||
@@ -2622,6 +2935,8 @@ class Flomni(
|
||||
print(f" _manual_shift_y <um> = {self.manual_shift_y}")
|
||||
print(f"Frames per trigger (burst) = {self.frames_per_trigger}")
|
||||
print(f"Single point instead of fermat = {self.single_point_instead_of_fermat_scan}")
|
||||
if self.single_point_instead_of_fermat_scan:
|
||||
print(f"Single point random shift max <um> = {self.single_point_random_shift_max}")
|
||||
print("")
|
||||
|
||||
if self.tomo_type == 1:
|
||||
@@ -2709,6 +3024,12 @@ class Flomni(
|
||||
)
|
||||
self.stitch_x = 0
|
||||
self.stitch_y = 0
|
||||
if self.single_point_instead_of_fermat_scan:
|
||||
self.single_point_random_shift_max = self._get_val(
|
||||
"<single point random shift max> um (0 = off)",
|
||||
self.single_point_random_shift_max,
|
||||
float,
|
||||
)
|
||||
|
||||
print("Tomography type:")
|
||||
print(" 1: 8 equally spaced sub-tomograms")
|
||||
@@ -2821,6 +3142,7 @@ class Flomni(
|
||||
"manual_shift_y",
|
||||
"frames_per_trigger",
|
||||
"single_point_instead_of_fermat_scan",
|
||||
"single_point_random_shift_max",
|
||||
"tomo_type",
|
||||
"tomo_angle_range",
|
||||
"tomo_angle_stepsize",
|
||||
|
||||
@@ -261,10 +261,34 @@ class FlomniOpticsMixin:
|
||||
console.print(table)
|
||||
|
||||
fosaz_val = dev.fosaz.readback.get()
|
||||
foptz_val = dev.foptz.readback.get()
|
||||
fosaz_in = self._get_user_param_safe("fosaz", "in")
|
||||
|
||||
tol_osa = 0.010 # mm, 10 microns
|
||||
|
||||
remaining_current = -fosaz_val + (33 - foptz_val)
|
||||
remaining_at_in = -fosaz_in + (33 - foptz_val)
|
||||
|
||||
print("\nOSA Information:")
|
||||
print(f" Current fosaz {fosaz_val:.1f}")
|
||||
print(
|
||||
f" The OSA will collide with a normal OMNY pin at fosaz \033[1m{(33-fosaz_val):.1f}\033[0m"
|
||||
)
|
||||
print(f" Remaining space: \033[1m{-fosaz_val+(33-foptz_val):.1f}\033[0m")
|
||||
print(f" The OSA will collide with a normal OMNY pin at fosaz \033[1m{(33-fosaz_val):.1f}\033[0m")
|
||||
print(f" Remaining space (right now): \033[1m{remaining_current:.1f}\033[0m")
|
||||
|
||||
diff = fosaz_val - fosaz_in # >0: OSA is currently more "in" than nominal -> worse
|
||||
|
||||
if abs(diff) > tol_osa:
|
||||
if diff > 0:
|
||||
# current position is already more collision-prone than the defined IN position
|
||||
print(
|
||||
f" \033[91mWarning: current fosaz ({fosaz_val:.4f}) is {diff*1000:.1f} um "
|
||||
f"further IN than the defined IN position ({fosaz_in:.4f}) -- closer to collision "
|
||||
f"than normal operation.\033[0m"
|
||||
)
|
||||
else:
|
||||
# OSA is out (parked further away than its nominal in-position) -- not urgent now,
|
||||
# but flag what clearance will be once it's moved to IN
|
||||
print(
|
||||
f" Note: OSA is {(-diff)*1000:.1f} um away from its IN position (likely parked OUT)."
|
||||
)
|
||||
print(f" Remaining space if OSA is moved to its IN position: \033[1m{remaining_at_in:.1f}\033[0m")
|
||||
|
||||
@@ -103,10 +103,8 @@ VERBOSITY_VERBOSE = 2
|
||||
VERBOSITY_DEBUG = 3
|
||||
|
||||
_PHONE_NUMBERS = [
|
||||
("Beamline", "+41 56 310 5845"),
|
||||
("Beamline mobile", "+41 56 310 5844"),
|
||||
("Local contact", "+41 79 343 9230"),
|
||||
("Control room", "+41 56 310 5503"),
|
||||
("Beamline", "+41 56 310 5845"),
|
||||
("Control room", "+41 56 310 5503"),
|
||||
]
|
||||
|
||||
|
||||
@@ -286,21 +284,27 @@ def _derive_status(
|
||||
last_active_time,
|
||||
had_activity: bool,
|
||||
queue_locks: list | None = None,
|
||||
beamline_blocking: bool = False,
|
||||
) -> 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)
|
||||
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.
|
||||
blocked -- the experiment is being held up externally: EITHER a
|
||||
watched beamline state is out of its accepted range while
|
||||
the scan interlock would act on it (beamline_blocking),
|
||||
OR the primary queue has at least one explicit lock
|
||||
applied (queue_locks). Reported regardless of whether a
|
||||
scan is currently executing: when a block hits, the
|
||||
running scan is interrupted and its repeat waits for the
|
||||
block to clear, so active_request_block (and hence
|
||||
queue_has_active_scan) may already be cleared even though
|
||||
the beamline is still blocking. Only 'scanning' (fresh
|
||||
heartbeat = data still flowing) takes precedence.
|
||||
running -- queue has active scan but heartbeat has never been seen,
|
||||
and the queue is not locked
|
||||
idle -- not scanning, last_active_time known
|
||||
and nothing is blocking
|
||||
idle -- not scanning, not blocked, last_active_time known
|
||||
unknown -- no activity ever seen since generator started
|
||||
|
||||
'unknown' is ONLY returned before any scan activity has been observed.
|
||||
@@ -311,14 +315,19 @@ def _derive_status(
|
||||
hb_age = _heartbeat_age_s(progress.get("heartbeat"))
|
||||
if hb_age < _TOMO_HEARTBEAT_STALE_S:
|
||||
return "scanning"
|
||||
# External block takes precedence over the remaining scan/idle states.
|
||||
# A blocked scan is interrupted (active_request_block cleared) while its
|
||||
# repeat waits for the block to clear, so we must NOT require an active
|
||||
# scan here — that was the previous behaviour and it made a blocked-but-
|
||||
# interrupted experiment fall through to 'idle'.
|
||||
if beamline_blocking or queue_locks:
|
||||
return "blocked"
|
||||
# Heartbeat stale but queue still active and heartbeat was seen recently
|
||||
# enough (within 10× the stale window) — likely a long projection, not idle.
|
||||
# Report as 'scanning' so audio system does not trigger a false alarm.
|
||||
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"
|
||||
@@ -576,8 +585,32 @@ class WebpageGeneratorBase:
|
||||
Common webpage generator. Subclass and override:
|
||||
_collect_setup_data() -- return dict of instrument-specific data
|
||||
_logo_path() -- return Path to logo PNG, or None for text fallback
|
||||
|
||||
Setup capability flags (override on subclass as needed):
|
||||
HAS_TOMO_QUEUE -- whether this instrument exposes the persisted
|
||||
parameter-snapshot job queue (global var
|
||||
"tomo_queue" + the "Tomo queue" UI card).
|
||||
NOT to be confused with BEC's own primary scan
|
||||
queue (queue_status / queue_locks in _cycle()),
|
||||
which every setup has and which stays common.
|
||||
TOMO_TYPES -- dict describing this instrument's tomography
|
||||
scan types, keyed by the value progress["tomo_type"]
|
||||
takes. Used by the UI to compute total-projection
|
||||
counts for queued jobs without hardcoding a
|
||||
per-instrument formula in JS. Recognised "kind"s:
|
||||
"equally_spaced_grid" (params: n_subtomos)
|
||||
total = floor(angle_range / angle_stepsize) * n_subtomos
|
||||
"golden_capped"
|
||||
total = golden_max_number_of_projections
|
||||
"""
|
||||
|
||||
HAS_TOMO_QUEUE = True
|
||||
TOMO_TYPES = {
|
||||
1: {"kind": "equally_spaced_grid", "n_subtomos": 8},
|
||||
2: {"kind": "golden_capped"},
|
||||
3: {"kind": "golden_capped"},
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bec_client,
|
||||
@@ -649,7 +682,9 @@ class WebpageGeneratorBase:
|
||||
# Copy logo once at startup — HTML is also written once here,
|
||||
# not every cycle, since it is a static shell that only loads status.json.
|
||||
self._copy_logo()
|
||||
(self._output_dir / "status.html").write_text(_render_html(_PHONE_NUMBERS))
|
||||
(self._output_dir / "status.html").write_text(
|
||||
_render_html(_PHONE_NUMBERS, self.HAS_TOMO_QUEUE, self.TOMO_TYPES)
|
||||
)
|
||||
|
||||
# Check whether the active BEC account matches the session user on the
|
||||
# remote server. If not, clear session.htpasswd so the old user loses
|
||||
@@ -979,8 +1014,13 @@ class WebpageGeneratorBase:
|
||||
queue_has_active_scan = False
|
||||
queue_locks = []
|
||||
|
||||
# ── Beamline states (diagnostic display only; not used for the
|
||||
# 'blocked' experiment_status decision — see _read_queue_locks) ──
|
||||
# ── Beamline states ──────────────────────────────────────────
|
||||
# Diagnostic display AND (via beamline_blocking below) one of the two
|
||||
# signals that drive the 'blocked' experiment_status; the other is
|
||||
# queue_locks. A watched state that is out of its accepted range
|
||||
# (mismatched) means the scan interlock is / would be holding the
|
||||
# queue, even if the running scan has already been interrupted and
|
||||
# active_request_block cleared.
|
||||
try:
|
||||
beamline_states_info = _read_beamline_states_info(self._bec)
|
||||
except Exception as exc:
|
||||
@@ -988,6 +1028,18 @@ class WebpageGeneratorBase:
|
||||
f"beamline_states read error: {exc}", level="warning")
|
||||
beamline_states_info = {"enabled": None, "states": []}
|
||||
|
||||
# True if the scan interlock is enabled AND at least one watched state
|
||||
# is out of its accepted range. Gated on `enabled`: a mismatched state
|
||||
# while the interlock is disabled does not actually block a scan, so it
|
||||
# must not raise the 'blocked' status. Kept in lock-step with the
|
||||
# beamline-states card summary (renderBeamlineStates), which likewise
|
||||
# only calls mismatched states "blocking" when the interlock is enabled,
|
||||
# so the top status pill and that card can never disagree.
|
||||
beamline_blocking = bool(
|
||||
beamline_states_info.get("enabled") is True
|
||||
and any(s.get("mismatched") for s in beamline_states_info.get("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.
|
||||
@@ -1016,20 +1068,32 @@ class WebpageGeneratorBase:
|
||||
)
|
||||
self._last_queue_id = latest_queue_id
|
||||
|
||||
if tomo_active or queue_has_active_scan or history_changed:
|
||||
if (tomo_active or queue_has_active_scan or history_changed
|
||||
or beamline_blocking):
|
||||
self._last_active_time = _epoch()
|
||||
self._had_activity = True
|
||||
|
||||
exp_status = _derive_status(
|
||||
progress, queue_has_active_scan,
|
||||
self._last_active_time, self._had_activity,
|
||||
queue_locks,
|
||||
queue_locks, beamline_blocking,
|
||||
)
|
||||
idle_for_s = (
|
||||
None if self._last_active_time is None
|
||||
else max(0.0, _epoch() - self._last_active_time)
|
||||
)
|
||||
|
||||
# ── Tomo queue (setup-specific; see HAS_TOMO_QUEUE) ─────────────
|
||||
# Persisted list of parameter-snapshot jobs (global var "tomo_queue").
|
||||
# Each job: {"label": str, "params": {...},
|
||||
# "status": pending|running|incomplete|done, "added_at": ISO str}
|
||||
# Instruments without this feature (e.g. LamNI) never touch the
|
||||
# global var and always report an empty queue.
|
||||
if self.HAS_TOMO_QUEUE:
|
||||
tomo_queue = self._bec.get_global_var("tomo_queue") or []
|
||||
else:
|
||||
tomo_queue = []
|
||||
|
||||
# ── Reconstruction queue ──────────────────────────────────────
|
||||
recon = self._collect_recon_data()
|
||||
|
||||
@@ -1061,6 +1125,7 @@ class WebpageGeneratorBase:
|
||||
"idle_for_human": _format_duration(idle_for_s),
|
||||
"queue_locks": queue_locks,
|
||||
"beamline_states": beamline_states_info,
|
||||
"tomo_queue": tomo_queue,
|
||||
"progress": {
|
||||
"tomo_type": progress.get("tomo_type", "N/A"),
|
||||
"projection": progress.get("projection", 0),
|
||||
@@ -1086,6 +1151,7 @@ class WebpageGeneratorBase:
|
||||
"generator": {
|
||||
"owner_id": self._owner_id,
|
||||
"cycle_interval_s": self._cycle_interval,
|
||||
"active_account": getattr(self._bec, "active_account", None) or "",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1307,10 +1373,21 @@ class WebpageGeneratorBase:
|
||||
by the HTML page as the 'Instrument details' section.
|
||||
|
||||
Expected keys (all optional):
|
||||
type (str) -- setup identifier, e.g. "flomni"
|
||||
sample_name (str) -- current sample name
|
||||
temperatures (dict) -- label -> float (degrees C) or None
|
||||
settings (dict) -- label -> formatted string
|
||||
type (str) -- setup identifier, e.g. "flomni"
|
||||
sample_name (str) -- current sample name
|
||||
temperatures (dict) -- label -> float (degrees C) or None
|
||||
current_params (dict) -- raw tomo parameters read live (keyed by
|
||||
the same global-var names the tomo queue
|
||||
stores in job.params, e.g. tomo_countingtime,
|
||||
fovx, tomo_angle_stepsize, ...). Rendered by
|
||||
the page through the same param-table path
|
||||
as a queue job (buildParamRows), so the
|
||||
'Current measurement' section always matches
|
||||
the queue's level of detail -- and shows the
|
||||
details even for a scan started outside the
|
||||
queue. Values are left raw; the page formats
|
||||
them (fmtTqParam) and computes the projection
|
||||
count (calcProjections).
|
||||
"""
|
||||
return {}
|
||||
|
||||
@@ -1336,10 +1413,31 @@ class WebpageGeneratorBase:
|
||||
class FlomniWebpageGenerator(WebpageGeneratorBase):
|
||||
"""
|
||||
flOMNI-specific webpage generator.
|
||||
Adds: temperatures, sample name, measurement settings from global vars.
|
||||
Adds: temperatures, sample name, and live current-measurement parameters
|
||||
from global vars.
|
||||
Logo: flOMNI.png from the same directory as this file.
|
||||
"""
|
||||
|
||||
# Global-var names of the tomo parameters to read live for the
|
||||
# 'Current measurement' section. Kept in the same order as, and matching,
|
||||
# the tomo queue's TQ_PARAM_DISPLAY keys so the two render identically.
|
||||
# (Any name that is not actually a global var is skipped silently and just
|
||||
# does not appear as a row.)
|
||||
_CURRENT_PARAM_KEYS = [
|
||||
"tomo_type",
|
||||
"tomo_countingtime",
|
||||
"fovx",
|
||||
"fovy",
|
||||
"tomo_angle_stepsize",
|
||||
"tomo_angle_range",
|
||||
"tomo_shellstep",
|
||||
"stitch_x",
|
||||
"stitch_y",
|
||||
"frames_per_trigger",
|
||||
"single_point_instead_of_fermat_scan",
|
||||
"ptycho_reconstruct_foldername",
|
||||
]
|
||||
|
||||
# label -> dotpath under device_manager.devices
|
||||
_TEMP_MAP = {
|
||||
"Heater": "flomni_temphum.temperature_heater",
|
||||
@@ -1363,40 +1461,28 @@ class FlomniWebpageGenerator(WebpageGeneratorBase):
|
||||
for label, path in self._TEMP_MAP.items()
|
||||
}
|
||||
|
||||
# ── Settings from global vars ─────────────────────────────────
|
||||
# ── Current measurement parameters (read live) ────────────────
|
||||
# Same keys the tomo queue stores in job.params, so the 'Current
|
||||
# measurement' section on the page renders through the identical
|
||||
# param-table path (buildParamRows -> fmtTqParam / calcProjections)
|
||||
# and always matches the queue's level of detail. Reading these live
|
||||
# means a tomo started OUTSIDE the queue still shows full parameters.
|
||||
# Values are left raw; the page does the formatting.
|
||||
g = self._bec
|
||||
|
||||
def _fmt2(v):
|
||||
current_params = {}
|
||||
for key in self._CURRENT_PARAM_KEYS:
|
||||
try:
|
||||
return f"{float(v):.2f}"
|
||||
val = g.get_global_var(key)
|
||||
except Exception:
|
||||
return "N/A"
|
||||
|
||||
fovx = g.get_global_var("fovx")
|
||||
fovy = g.get_global_var("fovy")
|
||||
stx = g.get_global_var("stitch_x")
|
||||
sty = g.get_global_var("stitch_y")
|
||||
|
||||
def _fmt_int(v):
|
||||
try:
|
||||
return str(int(v))
|
||||
except (TypeError, ValueError):
|
||||
return "N/A"
|
||||
|
||||
settings = {
|
||||
"Sample name": sample_name,
|
||||
"FOV x / y": f"{_fmt2(fovx)} / {_fmt2(fovy)} \u00b5m",
|
||||
"Step size": _gvar(g, "tomo_shellstep", ".2f", " \u00b5m"),
|
||||
"Exposure time": _gvar(g, "tomo_countingtime", ".3f", " s"),
|
||||
"Angle step": _gvar(g, "tomo_angle_stepsize", ".2f", "\u00b0"),
|
||||
"Stitch x / y": f"{_fmt_int(stx)} / {_fmt_int(sty)}",
|
||||
}
|
||||
val = None
|
||||
if val is not None:
|
||||
current_params[key] = val
|
||||
|
||||
return {
|
||||
"type": "flomni",
|
||||
"sample_name": sample_name,
|
||||
"temperatures": temperatures,
|
||||
"settings": settings,
|
||||
"type": "flomni",
|
||||
"sample_name": sample_name,
|
||||
"temperatures": temperatures,
|
||||
"current_params": current_params,
|
||||
}
|
||||
|
||||
|
||||
@@ -1447,7 +1533,7 @@ def make_webpage_generator(bec_client, **kwargs):
|
||||
# Theme is stored in localStorage and applied via data-theme on <html>.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _render_html(phone_numbers: list) -> str:
|
||||
def _render_html(phone_numbers: list, has_tomo_queue: bool = True, tomo_types: dict = None) -> str:
|
||||
phones_html = "\n".join(
|
||||
f' <div class="phone-row">'
|
||||
f'<span class="phone-label">{label}</span>'
|
||||
@@ -1455,6 +1541,30 @@ def _render_html(phone_numbers: list) -> str:
|
||||
for label, num in phone_numbers
|
||||
)
|
||||
|
||||
# Tomo queue card + its slot in the default card order are only emitted
|
||||
# for setups that actually have the feature (see HAS_TOMO_QUEUE).
|
||||
if has_tomo_queue:
|
||||
tomo_queue_card_html = """
|
||||
<!-- 5. Tomo queue -->
|
||||
<div class="card draggable-card" data-card-id="tomo-queue">
|
||||
<div class="drag-handle" title="Drag to reorder">⋮⋮</div>
|
||||
<div class="card-title">Tomo queue</div>
|
||||
<div id="tomo-queue-content"></div>
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
tomo_queue_card_html = ""
|
||||
|
||||
default_order = ['audio', 'recon-queue', 'ptycho', 'instrument', 'blstates', 'contacts']
|
||||
if has_tomo_queue:
|
||||
default_order.insert(default_order.index('contacts'), 'tomo-queue')
|
||||
default_order_json = json.dumps(default_order)
|
||||
|
||||
# Declarative tomo-type -> projection-count formula, consumed by the
|
||||
# generic calcProjections() in JS instead of a hardcoded per-instrument
|
||||
# branch. See WebpageGeneratorBase.TOMO_TYPES for the schema.
|
||||
tomo_types_json = json.dumps(tomo_types or {})
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en" data-theme="auto">
|
||||
<head>
|
||||
@@ -1523,7 +1633,11 @@ def _render_html(phone_numbers: list) -> str:
|
||||
body {{
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: var(--sans); font-weight: 300;
|
||||
min-height: 100vh; padding: 1.5rem;
|
||||
min-height: 100vh; /* fallback for browsers without dvh */
|
||||
min-height: 100dvh; /* dynamic viewport: avoids extra scroll room
|
||||
below content on mobile (iOS 100vh counts the
|
||||
retracted-toolbar height and overshoots) */
|
||||
padding: 1.5rem;
|
||||
transition: background 0.4s, color 0.4s;
|
||||
}}
|
||||
body::before {{
|
||||
@@ -1568,6 +1682,10 @@ def _render_html(phone_numbers: list) -> str:
|
||||
font-family: var(--mono); font-size: 0.7rem; font-weight: 700;
|
||||
letter-spacing: 0.15em; color: var(--text-dim);
|
||||
}}
|
||||
.logo-account {{
|
||||
font-family: var(--mono); font-size: 0.7rem; font-weight: 400;
|
||||
letter-spacing: 0.06em; color: var(--text-dim);
|
||||
}}
|
||||
|
||||
/* header right: update time + theme switcher */
|
||||
.header-right {{ display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }}
|
||||
@@ -1806,6 +1924,52 @@ def _render_html(phone_numbers: list) -> str:
|
||||
.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; }}
|
||||
|
||||
/* ── Tomo queue card ── */
|
||||
.tq-empty {{
|
||||
font-family: var(--mono); font-size: 0.78rem; color: var(--text-dim);
|
||||
padding: 0.25rem 0;
|
||||
}}
|
||||
.tq-job {{ border-bottom: 1px solid var(--border); }}
|
||||
.tq-job:last-child {{ border-bottom: none; }}
|
||||
.tq-summary {{
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.55rem 0; cursor: pointer; list-style: none;
|
||||
font-size: 0.85rem;
|
||||
}}
|
||||
.tq-summary::-webkit-details-marker {{ display: none; }}
|
||||
.tq-summary::before {{
|
||||
content: '▶'; font-family: var(--mono); font-size: 0.55rem;
|
||||
color: var(--text-dim); transition: transform 0.2s; flex-shrink: 0;
|
||||
}}
|
||||
details.tq-job[open] > .tq-summary::before {{ transform: rotate(90deg); }}
|
||||
.tq-num {{
|
||||
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
||||
min-width: 1.8rem; flex-shrink: 0;
|
||||
}}
|
||||
.tq-label {{ flex: 1; font-weight: 600; color: var(--text); }}
|
||||
.tq-badge {{
|
||||
font-family: var(--mono); font-size: 0.58rem; font-weight: 700;
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
padding: 0.12rem 0.45rem; border-radius: 100px; white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}}
|
||||
.tq-badge.tq-pending {{ background: var(--surface2); color: var(--text-dim); }}
|
||||
.tq-badge.tq-running {{ background: color-mix(in srgb, var(--c-scanning) 20%, transparent); color: var(--c-scanning); }}
|
||||
.tq-badge.tq-incomplete {{ background: color-mix(in srgb, var(--c-blocked) 20%, transparent); color: var(--c-blocked); }}
|
||||
.tq-badge.tq-done {{ background: color-mix(in srgb, var(--c-running) 20%, transparent); color: var(--c-running); }}
|
||||
.tq-details {{ padding: 0.4rem 0 0.7rem 2.4rem; }}
|
||||
.tq-params {{ width: 100%; border-collapse: collapse; }}
|
||||
.tq-params td {{ padding: 0.18rem 0; font-size: 0.78rem; vertical-align: top; }}
|
||||
.tq-params td:first-child {{
|
||||
font-family: var(--mono); font-size: 0.65rem; color: var(--text-dim);
|
||||
padding-right: 1rem; white-space: nowrap; width: 40%;
|
||||
}}
|
||||
.tq-params td:last-child {{ color: var(--text); font-weight: 600; }}
|
||||
.tq-added {{
|
||||
font-family: var(--mono); font-size: 0.6rem; color: var(--text-dim);
|
||||
margin-top: 0.4rem;
|
||||
}}
|
||||
|
||||
/* ── Audio card ── */
|
||||
.audio-card {{
|
||||
display: flex; align-items: center;
|
||||
@@ -1921,6 +2085,7 @@ def _render_html(phone_numbers: list) -> str:
|
||||
onerror="document.getElementById('logo-img').hidden=true; document.getElementById('logo-text').hidden=false;">
|
||||
<span id="logo-text" class="logo-text" hidden>STATUS</span>
|
||||
<span class="logo-suffix">· STATUS</span>
|
||||
<span class="logo-account" id="active-account"></span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="theme-btn" id="help-btn" onclick="openHelp()" title="What does this page show?">?</button>
|
||||
@@ -1964,6 +2129,7 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<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 class="info-item" id="pi-queue-item" style="display:none"><span class="label">Queue job</span><span class="value" id="pi-queue-label">-</span></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1986,7 +2152,7 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<ul>
|
||||
<li><strong>Scanning</strong> — a tomography scan is actively running (a heartbeat from the scan loop has been seen recently).</li>
|
||||
<li><strong>Running</strong> — the scan queue has an active item, but it is not a tomo scan with a heartbeat (e.g. an alignment scan).</li>
|
||||
<li><strong>Blocked</strong> — a scan is queued or active, but the scan queue itself has been locked (for example by BEC's scan interlock when a watched beamline condition, such as the shutter or ring current, is out of spec). This is usually external and self-resolves once the condition clears.</li>
|
||||
<li><strong>Blocked</strong> — the experiment is being held up by a watched beamline condition that is out of spec (for example the shutter or ring current, see the Beamline states card), or by an explicit lock on the scan queue. A running scan is interrupted when this happens and its repeat waits for the condition to clear, so this shows even when no scan is momentarily executing. Usually external and self-resolves once the condition clears.</li>
|
||||
<li><strong>Idle</strong> — nothing is running or queued right now.</li>
|
||||
<li><strong>Unknown</strong> — shown only before any activity has been observed since the generator started.</li>
|
||||
</ul>
|
||||
@@ -1995,7 +2161,7 @@ def _render_html(phone_numbers: list) -> str:
|
||||
<h3>Audio warnings</h3>
|
||||
<ul>
|
||||
<li><strong>System LED</strong> — on when audio is enabled on this device.</li>
|
||||
<li><strong>Watch LED</strong> — armed (green) while a scan is running; pulses orange if a scan stops unexpectedly, with a chime every 30s until confirmed.</li>
|
||||
<li><strong>Watch LED</strong> — armed (green) while a scan is running; pulses orange when a scan stops, with a chime every 30s until confirmed. A normal finish (scan → idle) plays a rising success tone; a stop to any other state plays the falling warning tone, so you can tell them apart by ear.</li>
|
||||
<li><strong>Blocked LED</strong> — pulses orange while the queue is locked. A chime fires automatically after 60 continuous minutes blocked, then repeats hourly until cleared. No confirmation needed — it clears itself once the block resolves.</li>
|
||||
<li><strong>Live LED</strong> — green while this page's data feed is fresh; pulses orange if the feed goes stale or a fetch fails.</li>
|
||||
</ul>
|
||||
@@ -2081,7 +2247,8 @@ def _render_html(phone_numbers: list) -> str:
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 5. Contacts -->
|
||||
{tomo_queue_card_html}
|
||||
<!-- 6. Contacts -->
|
||||
<div class="card draggable-card" data-card-id="contacts">
|
||||
<div class="drag-handle" title="Drag to reorder">⋮⋮</div>
|
||||
<div class="card-title">Contacts</div>
|
||||
@@ -2119,7 +2286,7 @@ function setTheme(t) {{
|
||||
|
||||
// ── Drag-and-drop card ordering ──────────────────────────────────────────
|
||||
const CARD_ORDER_KEY = 'cardOrder';
|
||||
const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','blstates','contacts'];
|
||||
const DEFAULT_ORDER = {default_order_json};
|
||||
let _dragSrc = null;
|
||||
|
||||
function savedOrder() {{
|
||||
@@ -2190,6 +2357,7 @@ initDrag();
|
||||
|
||||
let audioCtx=null, audioEnabled=false;
|
||||
let audioArmed=false, warningActive=false, warningTimer=null, lastStatus=null;
|
||||
let warningWasSuccess=false; // true when the active warning is a normal finish (scanning -> idle)
|
||||
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
|
||||
@@ -2224,6 +2392,13 @@ function beep(freq,dur,vol){{
|
||||
function warningChime(){{
|
||||
beep(660,0.3,0.4); setTimeout(()=>beep(440,0.5,0.4),350);
|
||||
}}
|
||||
function successChime(){{
|
||||
// Reverse of warningChime: a rising major arpeggio (C5-E5-G5) so a normal
|
||||
// finish (scanning -> idle) is audibly a happy "done" rather than an alert.
|
||||
beep(523,0.22,0.4);
|
||||
setTimeout(()=>beep(659,0.22,0.4),240);
|
||||
setTimeout(()=>beep(784,0.45,0.4),480);
|
||||
}}
|
||||
function staleChime(){{
|
||||
beep(1200,0.12,0.35);
|
||||
setTimeout(()=>beep(1200,0.12,0.35),180);
|
||||
@@ -2269,12 +2444,14 @@ function confirmWarning(){{
|
||||
updateAudioUI();
|
||||
}}
|
||||
|
||||
function startWarning(){{
|
||||
function startWarning(finished){{
|
||||
// Not a gesture handler — context already unlocked by Enable button click.
|
||||
if(warningActive) return;
|
||||
warningActive=true;
|
||||
if(audioEnabled) warningChime();
|
||||
warningTimer=setInterval(()=>{{ if(audioEnabled) warningChime(); }},30000);
|
||||
warningWasSuccess=!!finished; // scanning -> idle == successful finish
|
||||
const chime = warningWasSuccess ? successChime : warningChime;
|
||||
if(audioEnabled) chime();
|
||||
warningTimer=setInterval(()=>{{ if(audioEnabled) chime(); }},30000);
|
||||
document.getElementById('btn-confirm').style.display='inline-block';
|
||||
updateAudioUI();
|
||||
}}
|
||||
@@ -2374,10 +2551,10 @@ function updateAudioUI(){{
|
||||
txt.textContent='Audio disabled \u2014 enable to receive warnings';
|
||||
}}else if(warningActive && staleActive){{
|
||||
ledWatch.className='led led-warning';
|
||||
txt.textContent='Measurement stopped & live feed lost \u2014 confirm each';
|
||||
txt.textContent=(warningWasSuccess?'Measurement finished':'Measurement stopped')+' & live feed lost \u2014 confirm each';
|
||||
}}else if(warningActive){{
|
||||
ledWatch.className='led led-warning';
|
||||
txt.textContent='Measurement stopped \u2014 confirm to silence';
|
||||
txt.textContent=(warningWasSuccess?'Measurement finished':'Measurement stopped')+' \u2014 confirm to silence';
|
||||
}}else if(staleActive){{
|
||||
ledWatch.className='led';
|
||||
txt.textContent='Live feed lost \u2014 confirm to silence';
|
||||
@@ -2410,7 +2587,7 @@ function handleAudioForStatus(status, prevStatus){{
|
||||
}}
|
||||
|
||||
if(audioArmed && wasScanning && !isScanning){{
|
||||
startWarning();
|
||||
startWarning(status==='idle'); // idle == finished successfully -> success chime
|
||||
}}
|
||||
|
||||
if(isScanning && warningActive){{
|
||||
@@ -2485,8 +2662,17 @@ const DETAILS={{
|
||||
scanning: d=>'Tomo scan in progress · projection '+(d.progress.projection||0)+' of '+(d.progress.total_projections||0)+' · '+(d.progress.tomo_type||''),
|
||||
running: d=>'Queue active · outside tomo heartbeat window',
|
||||
idle: d=>'Idle for <strong>'+d.idle_for_human+'</strong>',
|
||||
blocked: d=>'Queue locked'+((d.queue_locks&&d.queue_locks.length>1)?' by multiple locks':'')+': <strong>'
|
||||
+(d.queue_locks||[]).map(l=>esc(l.reason||l.identifier)).join('; ')+'</strong>',
|
||||
blocked: d=>{{
|
||||
const locks=d.queue_locks||[];
|
||||
if(locks.length>0)
|
||||
return 'Queue locked'+(locks.length>1?' by multiple locks':'')+': <strong>'
|
||||
+locks.map(l=>esc(l.reason||l.identifier)).join('; ')+'</strong>';
|
||||
const bad=(((d.beamline_states||{{}}).states)||[]).filter(s=>s.mismatched)
|
||||
.map(s=>esc(s.label||s.name));
|
||||
if(bad.length>0)
|
||||
return 'Beamline interlock blocking: <strong>'+bad.join('; ')+'</strong>';
|
||||
return 'Blocked';
|
||||
}},
|
||||
error: d=>'Queue stopped unexpectedly · idle for <strong>'+(d.idle_for_human||'?')+'</strong>',
|
||||
unknown: d=>'Waiting for first data\u2026',
|
||||
}};
|
||||
@@ -2504,12 +2690,18 @@ function esc(s){{return String(s==null?'N/A':s).replace(/&/g,'&').replace(/<
|
||||
|
||||
function renderInstrument(setup){{
|
||||
const card=document.getElementById('instrument-card'),grid=document.getElementById('instrument-grid');
|
||||
if(!setup||(!setup.temperatures&&!setup.settings)){{card.style.display='none';return;}}
|
||||
const cp=(setup&&setup.current_params)||null;
|
||||
const hasParams=cp&&Object.keys(cp).length>0;
|
||||
if(!setup||(!setup.temperatures&&!hasParams)){{card.style.display='none';return;}}
|
||||
card.style.display='';
|
||||
let html='';
|
||||
if(setup.settings){{
|
||||
html+='<div class="instrument-section"><h3>Measurement settings</h3><table class="kv-table">';
|
||||
for(const[k,v] of Object.entries(setup.settings)) html+='<tr><td>'+esc(k)+'</td><td>'+esc(v)+'</td></tr>';
|
||||
if(hasParams){{
|
||||
// Same param-table path as a tomo queue job (buildParamRows), so the live
|
||||
// 'Current measurement' matches the queue's level of detail — and shows
|
||||
// full parameters even for a scan started outside the queue.
|
||||
html+='<div class="instrument-section"><h3>Current measurement</h3><table class="kv-table tq-params">';
|
||||
if(setup.sample_name) html+='<tr><td>Sample name</td><td>'+esc(setup.sample_name)+'</td></tr>';
|
||||
html+=buildParamRows(cp);
|
||||
html+='</table></div>';
|
||||
}}
|
||||
if(setup.temperatures){{
|
||||
@@ -2536,10 +2728,19 @@ function renderBeamlineStates(bl){{
|
||||
return;
|
||||
}}
|
||||
|
||||
summary.innerHTML = 'Scan interlock <strong>'+enabledTxt+'</strong>'
|
||||
+ (mismatched.length>0
|
||||
? ' · <strong>'+mismatched.length+'</strong> watched state'+(mismatched.length>1?'s':'')+' currently blocking'
|
||||
: ' · all watched states OK');
|
||||
// "Blocking" only applies when the interlock is enabled — a mismatched
|
||||
// state while disabled/unknown is out of spec but does not block a scan.
|
||||
// Kept in lock-step with the Python beamline_blocking gate so this card and
|
||||
// the top status pill never disagree.
|
||||
let blockNote;
|
||||
if(mismatched.length===0){{
|
||||
blockNote=' · all watched states OK';
|
||||
}} else if(bl&&bl.enabled===true){{
|
||||
blockNote=' · <strong>'+mismatched.length+'</strong> watched state'+(mismatched.length>1?'s':'')+' currently blocking';
|
||||
}} else {{
|
||||
blockNote=' · <strong>'+mismatched.length+'</strong> watched state'+(mismatched.length>1?'s':'')+' out of spec (interlock '+enabledTxt+', not blocking)';
|
||||
}}
|
||||
summary.innerHTML = 'Scan interlock <strong>'+enabledTxt+'</strong>'+blockNote;
|
||||
|
||||
let html='';
|
||||
states.forEach(s=>{{
|
||||
@@ -2555,6 +2756,127 @@ function renderBeamlineStates(bl){{
|
||||
tbody.innerHTML=html;
|
||||
}}
|
||||
|
||||
// Key params to show in expanded job detail, in display order.
|
||||
const TQ_PARAM_DISPLAY = [
|
||||
['tomo_type', 'Type'],
|
||||
['tomo_countingtime', 'Exposure (s)'],
|
||||
['fovx', 'FOV x (\u00b5m)'],
|
||||
['fovy', 'FOV y (\u00b5m)'],
|
||||
['tomo_angle_stepsize', 'Angle step (\u00b0)'],
|
||||
['tomo_angle_range', 'Angle range (\u00b0)'],
|
||||
['tomo_shellstep', 'Shell step (\u00b5m)'],
|
||||
['stitch_x', 'Stitch x'],
|
||||
['stitch_y', 'Stitch y'],
|
||||
['frames_per_trigger', 'Frames/trigger'],
|
||||
['single_point_instead_of_fermat_scan','Single point'],
|
||||
['ptycho_reconstruct_foldername', 'Recon folder'],
|
||||
];
|
||||
|
||||
function fmtTqParam(key, val){{
|
||||
if(val==null) return '-';
|
||||
if(typeof val==='number') return Number.isInteger(val)?String(val):val.toFixed(3).replace(/\\.?0+$/,'');
|
||||
return String(val);
|
||||
}}
|
||||
|
||||
// Declarative per-instrument tomo-type definitions (see
|
||||
// WebpageGeneratorBase.TOMO_TYPES on the Python side for the schema).
|
||||
// Adding or changing a tomo type on any setup is a config change here,
|
||||
// not a new branch of formula code.
|
||||
const TOMO_TYPES = {tomo_types_json};
|
||||
|
||||
function calcProjections(params){{
|
||||
const def=TOMO_TYPES[params.tomo_type];
|
||||
if(!def) return null;
|
||||
if(def.kind==='golden_capped'){{
|
||||
const gmax=params.golden_max_number_of_projections;
|
||||
return (gmax!=null&&gmax>0)?gmax:null;
|
||||
}}
|
||||
if(def.kind==='equally_spaced_grid'){{
|
||||
const range=params.tomo_angle_range, step=params.tomo_angle_stepsize;
|
||||
if(range>0&&step>0){{
|
||||
const N=Math.floor(range/step);
|
||||
return N*(def.n_subtomos||1);
|
||||
}}
|
||||
}}
|
||||
return null;
|
||||
}}
|
||||
|
||||
// Build the tomo-parameter table rows for a params object, in TQ_PARAM_DISPLAY
|
||||
// order, injecting the computed Projections row after the angle step. Shared by
|
||||
// the tomo queue (per job) and the instrument 'Current measurement' section, so
|
||||
// both always show the same parameters, formatted the same way.
|
||||
function buildParamRows(params){{
|
||||
let rows='';
|
||||
TQ_PARAM_DISPLAY.forEach(([key,dispLabel])=>{{
|
||||
if(key in params){{
|
||||
rows+='<tr><td>'+dispLabel+'</td><td>'+esc(fmtTqParam(key,params[key]))+'</td></tr>';
|
||||
}}
|
||||
if(key==='tomo_angle_stepsize'){{
|
||||
const nproj=calcProjections(params);
|
||||
if(nproj!=null) rows+='<tr><td>Projections</td><td>'+nproj+'</td></tr>';
|
||||
}}
|
||||
}});
|
||||
return rows;
|
||||
}}
|
||||
|
||||
function renderTomoQueue(jobs){{
|
||||
const el=document.getElementById('tomo-queue-content');
|
||||
if(!el) return; // setup has no tomo-queue card (HAS_TOMO_QUEUE=False)
|
||||
// Snapshot which jobs are currently open, keyed by a stable identity
|
||||
// (added_at|label) rather than positional index, so add/remove/reorder
|
||||
// does not carry the open flag onto the wrong job. This reflects the
|
||||
// user's manual expand/collapse since the last render (the DOM persists
|
||||
// across refreshes; only innerHTML is replaced).
|
||||
const openKeys=new Set();
|
||||
el.querySelectorAll('details.tq-job').forEach(det=>{{
|
||||
if(det.open && det.dataset.key) openKeys.add(det.dataset.key);
|
||||
}});
|
||||
if(!jobs||jobs.length===0){{
|
||||
el.innerHTML='<div class="tq-empty">No jobs queued</div>';
|
||||
return;
|
||||
}}
|
||||
// Running jobs are auto-opened once, on first appearance. We remember which
|
||||
// we have already auto-opened so a manual collapse is not undone on the next
|
||||
// refresh (previously, collapsing the only open job made the snapshot empty
|
||||
// and re-triggered the auto-open).
|
||||
const autoOpened=window.__tqAutoOpened||(window.__tqAutoOpened=new Set());
|
||||
const presentKeys=new Set();
|
||||
let html='';
|
||||
jobs.forEach((job,i)=>{{
|
||||
const status=job.status||'pending';
|
||||
const label=job.label||('Job '+(i+1));
|
||||
const params=job.params||{{}};
|
||||
const key=(job.added_at||'')+'|'+label;
|
||||
presentKeys.add(key);
|
||||
// Open if the user currently has it open, or it is a running job we have
|
||||
// not auto-opened before (its first appearance). Auto-open fires once.
|
||||
let shouldOpen=openKeys.has(key);
|
||||
if(status==='running' && !autoOpened.has(key)){{
|
||||
shouldOpen=true;
|
||||
autoOpened.add(key);
|
||||
}}
|
||||
const paramRows=buildParamRows(params);
|
||||
const addedAt=job.added_at
|
||||
?'Added '+new Date(job.added_at).toLocaleString([],{{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}})
|
||||
:'';
|
||||
html+='<details class="tq-job" data-key="'+esc(key)+'"'+(shouldOpen?' open':'')+'>'
|
||||
+'<summary class="tq-summary">'
|
||||
+'<span class="tq-num">#'+(i+1)+'</span>'
|
||||
+'<span class="tq-label">'+esc(label)+'</span>'
|
||||
+'<span class="tq-badge tq-'+esc(status)+'">'+esc(status)+'</span>'
|
||||
+'</summary>'
|
||||
+'<div class="tq-details">'
|
||||
+(paramRows?'<table class="tq-params">'+paramRows+'</table>':'')
|
||||
+(addedAt?'<div class="tq-added">'+esc(addedAt)+'</div>':'')
|
||||
+'</div>'
|
||||
+'</details>';
|
||||
}});
|
||||
// Drop remembered auto-open keys for jobs that are gone, so the set stays
|
||||
// small and a job that later reappears is treated as new.
|
||||
autoOpened.forEach(k=>{{ if(!presentKeys.has(k)) autoOpened.delete(k); }});
|
||||
el.innerHTML=html;
|
||||
}}
|
||||
|
||||
function render(d){{
|
||||
const s=d.experiment_status||'unknown',p=d.progress||{{}};
|
||||
document.body.className=s;
|
||||
@@ -2576,6 +2898,14 @@ function render(d){{
|
||||
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);
|
||||
const runningJob=(d.tomo_queue||[]).find(j=>j.status==='running');
|
||||
const queueItem=document.getElementById('pi-queue-item');
|
||||
if(runningJob){{
|
||||
document.getElementById('pi-queue-label').textContent=runningJob.label||'-';
|
||||
queueItem.style.display='';
|
||||
}}else{{
|
||||
queueItem.style.display='none';
|
||||
}}
|
||||
|
||||
if(d.recon){{
|
||||
document.getElementById('recon-waiting').textContent=d.recon.waiting;
|
||||
@@ -2586,6 +2916,7 @@ function render(d){{
|
||||
}}
|
||||
renderInstrument(d.setup);
|
||||
renderBeamlineStates(d.beamline_states);
|
||||
renderTomoQueue(d.tomo_queue);
|
||||
renderPtycho(d.ptycho);
|
||||
document.getElementById('last-update').textContent='updated '+new Date(d.generated_at).toLocaleTimeString();
|
||||
const ageS=(Date.now()/1000)-d.generated_at_epoch;
|
||||
@@ -2594,6 +2925,8 @@ function render(d){{
|
||||
handleStale(isStale);
|
||||
handleBlocked(s==='blocked');
|
||||
document.getElementById('footer-gen').textContent='generator: '+((d.generator||{{}}).owner_id||'-');
|
||||
const acct=(d.generator||{{}}).active_account||'';
|
||||
document.getElementById('active-account').textContent=acct?'\u00b7\u00a0'+acct:'';
|
||||
document.getElementById('footer-hb').textContent='tomo_heartbeat: '+(p.tomo_heartbeat_age_s!=null?p.tomo_heartbeat_age_s+'s ago':'none');
|
||||
|
||||
const prevStatus=lastStatus;
|
||||
|
||||
@@ -90,9 +90,9 @@ class flomniGuiTools:
|
||||
|
||||
def flomnigui_show_cameras(self):
|
||||
self.flomnigui_show_gui()
|
||||
if self._flomnigui_check_attribute_not_exists(
|
||||
"cam_flomni_gripper"
|
||||
) or self._flomnigui_check_attribute_not_exists("cam_flomni_overview"):
|
||||
if self._flomnigui_is_missing("camera_gripper_image") or self._flomnigui_is_missing(
|
||||
"camera_overview_image"
|
||||
):
|
||||
self.flomnigui_remove_all_docks()
|
||||
self.camera_gripper_image = self.gui.flomni.new("Image")
|
||||
if self._flomnicam_check_device_exists(dev.cam_flomni_gripper):
|
||||
|
||||
@@ -278,12 +278,7 @@ class XrayEyeAlign:
|
||||
self.gui.show_crosshair()
|
||||
|
||||
self.send_message(
|
||||
<<<<<<< Updated upstream
|
||||
"Submit height. Use arrows if far off."
|
||||
=======
|
||||
"Adjust sample height with the arrows if needed, then mark "
|
||||
"the sample and submit - height will be centered automatically"
|
||||
>>>>>>> Stashed changes
|
||||
)
|
||||
self.gui.enable_submit_button(True)
|
||||
self.movement_buttons_enabled(True, True)
|
||||
@@ -312,7 +307,7 @@ class XrayEyeAlign:
|
||||
|
||||
self.flomni.feedback_disable()
|
||||
if not self.test_wo_movements and delta_y_mm != 0:
|
||||
self.flomni.umvr_fsamy_tracked(delta_y_mm)
|
||||
self.flomni.umvr_fsamy_tracked(delta_y_mm, _internal=True)
|
||||
time.sleep(2)
|
||||
self.flomni.feedback_enable_with_reset()
|
||||
|
||||
@@ -364,7 +359,7 @@ class XrayEyeAlign:
|
||||
if _xrayeyalignmvy != 0:
|
||||
self.flomni.feedback_disable()
|
||||
if not self.test_wo_movements:
|
||||
self.flomni.umvr_fsamy_tracked(_xrayeyalignmvy / 1000)
|
||||
self.flomni.umvr_fsamy_tracked(_xrayeyalignmvy / 1000, _internal=True)
|
||||
time.sleep(2)
|
||||
dev.omny_xray_gui.mvy.set(0)
|
||||
self.flomni.feedback_enable_with_reset()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import builtins
|
||||
import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -28,9 +29,12 @@ if builtins.__dict__.get("bec") is not None:
|
||||
|
||||
def umv(*args):
|
||||
return scans.umv(*args, relative=False)
|
||||
|
||||
|
||||
def umvr(*args):
|
||||
return scans.umv(*args, relative=True)
|
||||
|
||||
|
||||
class OMNYToolsError(Exception):
|
||||
pass
|
||||
|
||||
@@ -157,8 +161,10 @@ class OMNYTools:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_term)
|
||||
fcntl.fcntl(fd, fcntl.F_SETFL, old_flags)
|
||||
|
||||
|
||||
import socket
|
||||
|
||||
|
||||
class PtychoReconstructor:
|
||||
"""Writes ptychography reconstruction queue files after each scan projection.
|
||||
|
||||
@@ -195,6 +201,8 @@ class PtychoReconstructor:
|
||||
next_scan_number: int,
|
||||
base_path: str = "~/data/raw/analysis/",
|
||||
probe_file_propagation: float | None = None,
|
||||
random_offset_x: float | None = None,
|
||||
random_offset_y: float | None = None,
|
||||
):
|
||||
"""Write a reconstruction queue file for the given scan list.
|
||||
|
||||
@@ -211,19 +219,25 @@ class PtychoReconstructor:
|
||||
omitted entirely otherwise, matching the old reconstruction.mac
|
||||
behavior of only writing this parameter when
|
||||
progagateprobeinsteadofsample==1.
|
||||
random_offset_x (float, optional): Random x shift [um] applied for
|
||||
this single-point acquisition. Written to the JSON sidecar as
|
||||
``random_offset_x`` when given; omitted otherwise.
|
||||
random_offset_y (float, optional): Random y shift [um] applied for
|
||||
this single-point acquisition. Written to the JSON sidecar as
|
||||
``random_offset_y`` when given; omitted otherwise.
|
||||
"""
|
||||
if not self._accounts_match():
|
||||
logger.warning("Active BEC account does not match system user — skipping queue file write.")
|
||||
logger.warning(
|
||||
"Active BEC account does not match system user — skipping queue file write."
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
base_path = os.path.expanduser(base_path)
|
||||
queue_path = Path(os.path.join(base_path, self.folder_name))
|
||||
queue_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
last_scan_number = next_scan_number - 1
|
||||
queue_file = os.path.abspath(
|
||||
os.path.join(queue_path, f"scan_{last_scan_number:05d}.dat")
|
||||
)
|
||||
queue_file = os.path.abspath(os.path.join(queue_path, f"scan_{last_scan_number:05d}.dat"))
|
||||
with open(queue_file, "w") as f:
|
||||
scans = " ".join(str(s) for s in scan_list)
|
||||
f.write(f"p.scan_number {scans}\n")
|
||||
@@ -231,6 +245,17 @@ class PtychoReconstructor:
|
||||
f.write(f"p.probe_file_propagation {probe_file_propagation:.6f}\n")
|
||||
f.write("p.check_nextscan_started 1\n")
|
||||
|
||||
json_file = os.path.abspath(os.path.join(queue_path, f"{last_scan_number:06d}.json"))
|
||||
json_content = {"scan_id": last_scan_number}
|
||||
if probe_file_propagation is not None:
|
||||
json_content["probe_file_propagation"] = round(probe_file_propagation, 6)
|
||||
if random_offset_x is not None:
|
||||
json_content["random_offset_x"] = round(random_offset_x, 6)
|
||||
if random_offset_y is not None:
|
||||
json_content["random_offset_y"] = round(random_offset_y, 6)
|
||||
with open(json_file, "w") as f:
|
||||
json.dump(json_content, f, indent=4)
|
||||
|
||||
|
||||
class TomoIDManager:
|
||||
"""Registers a tomography measurement in the OMNY sample database
|
||||
@@ -249,7 +274,7 @@ class TomoIDManager:
|
||||
)
|
||||
"""
|
||||
|
||||
#OMNY_URL = "https://omny.web.psi.ch/samples/newmeasurement.php"
|
||||
# OMNY_URL = "https://omny.web.psi.ch/samples/newmeasurement.php"
|
||||
OMNY_URL = "https://v1p0zyg2w9n2k9c1.myfritz.net/samples/newmeasurement.php"
|
||||
OMNY_USER = ""
|
||||
OMNY_PASSWORD = ""
|
||||
@@ -294,12 +319,9 @@ class TomoIDManager:
|
||||
# f" -q -O {self.TMP_FILE} '{url}'",
|
||||
# shell=True,
|
||||
# )
|
||||
#print(url)
|
||||
# print(url)
|
||||
tmp_file = os.path.expanduser(self.TMP_FILE)
|
||||
result = subprocess.run(
|
||||
f"wget -q -O {tmp_file} '{url}'",
|
||||
shell=True,
|
||||
)
|
||||
result = subprocess.run(f"wget -q -O {tmp_file} '{url}'", shell=True)
|
||||
if result.returncode != 0:
|
||||
raise OMNYToolsError(
|
||||
f"wget failed (exit code {result.returncode}) fetching tomo ID from {self.OMNY_URL}"
|
||||
@@ -313,4 +335,4 @@ class TomoIDManager:
|
||||
except ValueError as exc:
|
||||
raise OMNYToolsError(
|
||||
f"Unexpected response from tomo ID server, got: {content!r}"
|
||||
) from exc
|
||||
) from exc
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.rpc_decorator import rpc_timeout
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtGui import QKeySequence
|
||||
from qtpy.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
@@ -92,6 +97,9 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
|
||||
# carries fetched slit data from the background thread to the main thread
|
||||
_slit_data_ready = Signal(object)
|
||||
|
||||
USER_ACCESS = [
|
||||
"set_slit",
|
||||
"move_center",
|
||||
@@ -122,8 +130,14 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
self.step = self._STEP_DEFAULT
|
||||
self._ov_items: dict[tuple[int, int], QTableWidgetItem] = {}
|
||||
self._shortcuts: list[QShortcut] = []
|
||||
# rotating index through non-selected slits for slow background polling
|
||||
self._slow_poll_idx: int = 0
|
||||
self._fetch_thread: Optional[threading.Thread] = None
|
||||
self._slit_data_ready.connect(self._apply_slit_data)
|
||||
self._locked: bool = False
|
||||
self._move_btns: list[QPushButton] = [] # populated by _build_* methods
|
||||
self._lockout_timer = QTimer(self)
|
||||
self._lockout_timer.setSingleShot(True)
|
||||
self._lockout_timer.timeout.connect(self._release_lockout)
|
||||
self._build_ui()
|
||||
self._build_shortcuts() # created once, enabled/disabled by toggle
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -162,7 +176,28 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
|
||||
root.addWidget(_separator())
|
||||
|
||||
# 5. keyboard toggle + legend
|
||||
# 5. absolute target entry + stop button
|
||||
target_row = QHBoxLayout()
|
||||
target_row.addWidget(self._build_target_panel(), stretch=1)
|
||||
self._btn_stop = QPushButton("■ Stop")
|
||||
self._btn_stop.setToolTip("Stop all axes of the selected slit")
|
||||
self._btn_stop.clicked.connect(self._stop_slit)
|
||||
self._btn_stop.setMinimumWidth(70)
|
||||
target_row.addWidget(self._btn_stop, stretch=0)
|
||||
root.addLayout(target_row)
|
||||
|
||||
root.addWidget(_separator())
|
||||
|
||||
# 6. status label (move errors, limit hits)
|
||||
self._status_lbl = QLabel("")
|
||||
self._status_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._status_lbl.setMinimumHeight(18)
|
||||
root.addWidget(self._status_lbl)
|
||||
self._status_timer = QTimer(self)
|
||||
self._status_timer.setSingleShot(True)
|
||||
self._status_timer.timeout.connect(lambda: self._status_lbl.setText(""))
|
||||
|
||||
# 7. keyboard toggle + legend
|
||||
self._btn_kb = QPushButton("\u2328 Keyboard tweaking")
|
||||
self._btn_kb.setCheckable(True)
|
||||
self._btn_kb.toggled.connect(self._on_kb_toggled)
|
||||
@@ -172,12 +207,58 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
self._kb_legend.setVisible(False)
|
||||
root.addWidget(self._kb_legend)
|
||||
|
||||
def _build_target_panel(self) -> QGroupBox:
|
||||
"""Four spinboxes to move the selected slit to an absolute position."""
|
||||
box = QGroupBox("Move to absolute position (mm)")
|
||||
form = QFormLayout(box)
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
self._target_spins: dict[str, QDoubleSpinBox] = {}
|
||||
labels = {"xc": "x center", "xs": "x size", "yc": "y center", "ys": "y size"}
|
||||
|
||||
for suffix, label in labels.items():
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setDecimals(4)
|
||||
spin.setRange(-500.0, 500.0)
|
||||
spin.setSingleStep(0.001)
|
||||
self._target_spins[suffix] = spin
|
||||
|
||||
go_btn = QPushButton("→")
|
||||
go_btn.setFixedWidth(28)
|
||||
go_btn.setToolTip(f"Move {label} to entered value")
|
||||
# capture suffix in closure
|
||||
go_btn.clicked.connect(lambda checked=False, s=suffix: self._move_absolute(s))
|
||||
spin.returnPressed = None # QDoubleSpinBox has no returnPressed
|
||||
|
||||
row_widget = QWidget()
|
||||
row_layout = QHBoxLayout(row_widget)
|
||||
row_layout.setContentsMargins(0, 0, 0, 0)
|
||||
row_layout.addWidget(spin)
|
||||
row_layout.addWidget(go_btn)
|
||||
form.addRow(f"{label}:", row_widget)
|
||||
|
||||
return box
|
||||
|
||||
def _build_overview(self) -> QGroupBox:
|
||||
box = QGroupBox("All slits (mm) – select to tweak")
|
||||
vbox = QVBoxLayout(box)
|
||||
|
||||
tbl = QTableWidget(len(self._SLIT_LABELS), 6)
|
||||
tbl.setHorizontalHeaderLabels(["", "Slit", "x center", "x size", "y center", "y size"])
|
||||
_R = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||||
_L = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
for col, (text, align) in enumerate(
|
||||
[
|
||||
("", _L),
|
||||
("Slit", _L),
|
||||
("x center", _R),
|
||||
("x size", _R),
|
||||
("y center", _R),
|
||||
("y size", _R),
|
||||
]
|
||||
):
|
||||
hdr_item = QTableWidgetItem(text)
|
||||
hdr_item.setTextAlignment(align)
|
||||
tbl.setHorizontalHeaderItem(col, hdr_item)
|
||||
# col 0 (radio) and col 1 (name) sized to content;
|
||||
# value cols 2-5 share remaining width equally → evenly distributed
|
||||
tbl.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
@@ -243,6 +324,7 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
btn_shrink_y.clicked.connect(lambda: self.move_size("down"))
|
||||
btn_shrink_x.clicked.connect(lambda: self.move_size("left"))
|
||||
btn_grow_x.clicked.connect(lambda: self.move_size("right"))
|
||||
self._move_btns += [btn_grow_y, btn_shrink_y, btn_shrink_x, btn_grow_x]
|
||||
|
||||
grid.addWidget(btn_grow_y, 0, 1)
|
||||
grid.addWidget(btn_shrink_x, 1, 0)
|
||||
@@ -275,6 +357,7 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
btn_down.clicked.connect(lambda: self.move_center("down"))
|
||||
btn_left.clicked.connect(lambda: self.move_center("left"))
|
||||
btn_right.clicked.connect(lambda: self.move_center("right"))
|
||||
self._move_btns += [btn_up, btn_down, btn_left, btn_right]
|
||||
|
||||
grid.addWidget(btn_up, 0, 1)
|
||||
grid.addWidget(btn_left, 1, 0)
|
||||
@@ -374,40 +457,131 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
else:
|
||||
self._btn_kb.setText("\u2328 Keyboard tweaking")
|
||||
|
||||
def _engage_lockout(self, ms: int = 2000) -> None:
|
||||
"""Disable move buttons and keyboard move shortcuts for ``ms`` ms."""
|
||||
self._locked = True
|
||||
for btn in self._move_btns:
|
||||
btn.setEnabled(False)
|
||||
# disable only the movement shortcuts (indices 6+); keep slit-select (0-5)
|
||||
for sc in self._shortcuts[6:]:
|
||||
sc.setEnabled(False)
|
||||
self._lockout_timer.start(ms)
|
||||
|
||||
def _release_lockout(self) -> None:
|
||||
"""Re-enable move buttons and shortcuts (called by the lockout timer)."""
|
||||
self._locked = False
|
||||
for btn in self._move_btns:
|
||||
btn.setEnabled(True)
|
||||
# only re-enable movement shortcuts if keyboard mode is active
|
||||
if self._btn_kb.isChecked():
|
||||
for sc in self._shortcuts[6:]:
|
||||
sc.setEnabled(True)
|
||||
|
||||
def _populate_target_spins(self) -> None:
|
||||
"""Pre-fill target spinboxes with current device values."""
|
||||
for suffix, spin in self._target_spins.items():
|
||||
try:
|
||||
device = getattr(self.dev, self._dev_name(suffix))
|
||||
reading = device.read()
|
||||
value = float(reading[self._dev_name(suffix)]["value"])
|
||||
spin.setValue(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _move_absolute(self, axis_suffix: str) -> None:
|
||||
"""Move one axis to the value currently entered in its target spinbox."""
|
||||
if self._locked:
|
||||
return
|
||||
self._engage_lockout(3000) # absolute moves may cover more distance
|
||||
dev_name = self._dev_name(axis_suffix)
|
||||
target = self._target_spins[axis_suffix].value()
|
||||
try:
|
||||
device = getattr(self.dev, dev_name)
|
||||
device.move(target)
|
||||
except Exception as exc:
|
||||
msg = str(exc) or type(exc).__name__
|
||||
logger.warning(f"SlitControlWidget: absolute move {dev_name} failed: {msg}")
|
||||
self._show_status(f"{dev_name}: {msg}")
|
||||
|
||||
def _stop_slit(self) -> None:
|
||||
"""Stop all four axes of the currently selected slit."""
|
||||
for suffix in ("xc", "xs", "yc", "ys"):
|
||||
try:
|
||||
device = getattr(self.dev, self._dev_name(suffix))
|
||||
device.stop()
|
||||
except Exception as exc:
|
||||
msg = str(exc) or type(exc).__name__
|
||||
logger.warning(f"SlitControlWidget: stop {self._dev_name(suffix)} failed: {msg}")
|
||||
self._show_status(f"Stop failed: {msg}")
|
||||
|
||||
def _show_status(self, msg: str, error: bool = True) -> None:
|
||||
"""Show a brief status message below the controls (auto-clears after 4 s)."""
|
||||
color = "#e53935" if error else "#43a047"
|
||||
self._status_lbl.setText(f'<span style="color:{color}">{msg}</span>')
|
||||
self._status_timer.start(4000)
|
||||
|
||||
# ── overview polling ──────────────────────────────────────────────────────
|
||||
|
||||
def _refresh_slit_row(self, slit_num: int) -> None:
|
||||
"""Read all four axis values for one slit and update its table row."""
|
||||
row = list(self._SLIT_LABELS).index(slit_num)
|
||||
for col_off, suffix in enumerate(("xc", "xs", "yc", "ys")):
|
||||
dev_name = f"sl{slit_num}{suffix}"
|
||||
item = self._ov_items.get((row, col_off + 2))
|
||||
if item is None:
|
||||
continue
|
||||
try:
|
||||
device = getattr(self.dev, dev_name)
|
||||
reading = device.read()
|
||||
value = float(reading[dev_name]["value"])
|
||||
item.setText(f"{value:.4f}")
|
||||
except Exception:
|
||||
item.setText("---")
|
||||
# ── background polling ────────────────────────────────────────────────────
|
||||
|
||||
def _refresh_overview(self) -> None:
|
||||
"""
|
||||
Per-tick (2 s) refresh strategy:
|
||||
- Selected slit: every tick → updated every 2 s.
|
||||
- Other slits: one per tick in rotation → each updated every ~10 s
|
||||
(5 other slits × 2 s). At most 8 device.read() calls per tick.
|
||||
"""
|
||||
# always refresh the selected slit
|
||||
self._refresh_slit_row(self.slit)
|
||||
Kick off a background thread to fetch slit values without blocking the
|
||||
Qt main thread. device.read() on virtual slit devices triggers RPC
|
||||
calls to the device server; doing those in the main thread stalls the UI.
|
||||
The thread emits _slit_data_ready when done; _apply_slit_data updates
|
||||
the table and target spinboxes safely from the main thread.
|
||||
|
||||
# advance through the other slits one per tick
|
||||
Per-tick strategy (2 s timer):
|
||||
- Selected slit: every tick.
|
||||
- Other slits: one per tick in rotation (~10 s per non-selected slit).
|
||||
"""
|
||||
if self._fetch_thread is not None and self._fetch_thread.is_alive():
|
||||
return # previous fetch still running – skip this tick
|
||||
self._fetch_thread = threading.Thread(target=self._fetch_slit_data, daemon=True)
|
||||
self._fetch_thread.start()
|
||||
|
||||
def _fetch_slit_data(self) -> None:
|
||||
"""Runs in background thread – NO Qt widget access here."""
|
||||
data: dict[tuple[int, str], Optional[float]] = {}
|
||||
self._fetch_one_slit(self.slit, data)
|
||||
others = [n for n in self._SLIT_LABELS if n != self.slit]
|
||||
if others:
|
||||
self._slow_poll_idx = self._slow_poll_idx % len(others)
|
||||
self._refresh_slit_row(others[self._slow_poll_idx])
|
||||
self._fetch_one_slit(others[self._slow_poll_idx], data)
|
||||
self._slow_poll_idx += 1
|
||||
self._slit_data_ready.emit(data) # thread-safe: Qt queues it to main thread
|
||||
|
||||
def _fetch_one_slit(self, slit_num: int, data: dict[tuple[int, str], Optional[float]]) -> None:
|
||||
"""Fetch four axis values for one slit into data (background thread)."""
|
||||
for suffix in ("xc", "xs", "yc", "ys"):
|
||||
dev_name = f"sl{slit_num}{suffix}"
|
||||
try:
|
||||
device = getattr(self.dev, dev_name)
|
||||
reading = device.read()
|
||||
data[(slit_num, suffix)] = float(reading[dev_name]["value"])
|
||||
except Exception:
|
||||
data[(slit_num, suffix)] = None
|
||||
|
||||
def _apply_slit_data(self, data: dict) -> None:
|
||||
"""Update table and target spinboxes in the main thread (slot for signal)."""
|
||||
suffix_order = ("xc", "xs", "yc", "ys")
|
||||
for (slit_num, suffix), value in data.items():
|
||||
row = list(self._SLIT_LABELS).index(slit_num)
|
||||
col_off = suffix_order.index(suffix)
|
||||
item = self._ov_items.get((row, col_off + 2))
|
||||
if item is None:
|
||||
continue
|
||||
if value is not None:
|
||||
item.setText(f"{value:.3f}")
|
||||
if slit_num == self.slit and hasattr(self, "_target_spins"):
|
||||
spin = self._target_spins.get(suffix)
|
||||
if spin is not None and not spin.hasFocus():
|
||||
spin.blockSignals(True)
|
||||
spin.setValue(value)
|
||||
spin.blockSignals(False)
|
||||
else:
|
||||
item.setText("---")
|
||||
|
||||
def _update_row_bold(self):
|
||||
for row, (slit_num, _) in enumerate(self._SLIT_LABELS.items()):
|
||||
@@ -436,6 +610,7 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
btn.setChecked(True)
|
||||
self._tweaking_lbl.setText(f"Tweaking: {self._SLIT_LABELS[slit]}")
|
||||
self._update_row_bold()
|
||||
self._populate_target_spins()
|
||||
|
||||
def move_center(self, direction: str):
|
||||
"""Move the slit center. direction: up / down / left / right."""
|
||||
@@ -464,6 +639,9 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
self._move_relative(suffix, sign * self.step)
|
||||
|
||||
def _move_relative(self, axis_suffix: str, delta: float):
|
||||
if self._locked:
|
||||
return
|
||||
self._engage_lockout(2000)
|
||||
dev_name = self._dev_name(axis_suffix)
|
||||
try:
|
||||
device = getattr(self.dev, dev_name)
|
||||
@@ -471,7 +649,9 @@ class SlitControlWidget(BECWidget, QWidget):
|
||||
current = float(reading[dev_name]["value"])
|
||||
device.move(current + delta)
|
||||
except Exception as exc:
|
||||
logger.warning(f"SlitControlWidget: failed to move {dev_name}: {exc}")
|
||||
msg = str(exc) or type(exc).__name__
|
||||
logger.warning(f"SlitControlWidget: failed to move {dev_name}: {msg}")
|
||||
self._show_status(f"{dev_name}: {msg}")
|
||||
|
||||
def set_step(self, value: float):
|
||||
"""Set step size in mm (clamped to 0.005–2.0 mm)."""
|
||||
|
||||
@@ -14,6 +14,22 @@ sync with changes made in a parallel CLI session. The parameter panel is
|
||||
refreshed on demand (when the user opens it or submits changes) rather than
|
||||
on every poll, to avoid clobbering in-progress edits.
|
||||
|
||||
Beamline-busy detection
|
||||
------------------------
|
||||
Two independent "something is running" signals gate parameter submission and
|
||||
drive an advisory banner:
|
||||
|
||||
* ``tomo_progress["heartbeat"]`` — set by ``_tomo_scan_at_angle()`` at every
|
||||
projection, whether the scan was queue- or CLI-started.
|
||||
* the BEC scan queue — catches a single ptycho projection (or any other scan)
|
||||
run manually outside a tomo run, which writes no heartbeat. The busy logic
|
||||
mirrors bec_widgets' ``BECQueue`` widget so it stays consistent with the
|
||||
queue display.
|
||||
|
||||
The banner is advisory (soft-warn): editing while busy is still allowed —
|
||||
property-backed params such as counting time take effect on the next
|
||||
projection — but Submit asks for confirmation.
|
||||
|
||||
Queue execution
|
||||
---------------
|
||||
``tomo_queue_execute()`` is a blocking Flomni CLI method that cannot be
|
||||
@@ -28,6 +44,7 @@ import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from bec_lib import bec_logger
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_widgets import BECWidget, SafeSlot
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
@@ -70,6 +87,10 @@ STATUS_COLORS = {
|
||||
"done": "#4CAF50",
|
||||
}
|
||||
|
||||
# Scan-queue item statuses that mean "not occupying hardware".
|
||||
# Mirrors bec_widgets' BECQueue.update_queue busy logic verbatim.
|
||||
_QUEUE_IDLE_STATUSES = ("STOPPED", "COMPLETED", "IDLE")
|
||||
|
||||
# Exact tuple from Flomni._TOMO_QUEUE_PARAM_NAMES (source of truth in flomni.py)
|
||||
QUEUE_PARAM_NAMES = (
|
||||
"tomo_countingtime",
|
||||
@@ -83,6 +104,7 @@ QUEUE_PARAM_NAMES = (
|
||||
"manual_shift_y",
|
||||
"frames_per_trigger",
|
||||
"single_point_instead_of_fermat_scan",
|
||||
"single_point_random_shift_max",
|
||||
"tomo_type",
|
||||
"tomo_angle_range",
|
||||
"tomo_angle_stepsize",
|
||||
@@ -105,6 +127,7 @@ DEFAULTS: dict[str, Any] = {
|
||||
"manual_shift_y": 0.0,
|
||||
"frames_per_trigger": 1,
|
||||
"single_point_instead_of_fermat_scan": False,
|
||||
"single_point_random_shift_max": 0.0,
|
||||
"tomo_type": 1,
|
||||
"corridor_size": -1.0,
|
||||
"tomo_angle_range": 180,
|
||||
@@ -132,6 +155,8 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
|
||||
Layout
|
||||
------
|
||||
Top:
|
||||
- Advisory beamline-busy banner (hidden unless a scan/queue is active)
|
||||
Top half (scrollable):
|
||||
- Read-only sample-name header
|
||||
- Tomo-type dropdown (always visible)
|
||||
@@ -148,13 +173,7 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
|
||||
PLUGIN = True
|
||||
|
||||
USER_ACCESS = [
|
||||
"refresh",
|
||||
"enter_edit_mode",
|
||||
"cancel_edit",
|
||||
"submit_params",
|
||||
"add_to_queue",
|
||||
]
|
||||
USER_ACCESS = ["refresh", "enter_edit_mode", "cancel_edit", "submit_params", "add_to_queue"]
|
||||
|
||||
_POLL_INTERVAL_MS = 2000
|
||||
|
||||
@@ -162,16 +181,40 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.get_bec_shortcuts()
|
||||
self._edit_mode = False
|
||||
# widget refs populated by _build_params_panel
|
||||
self._pw: dict[str, QWidget] = {} # key -> input widget
|
||||
self._queue_dlg: Optional["TomoQueueDialog"] = None
|
||||
self._busy_banner: Optional[QLabel] = None
|
||||
# check whether the FlOMNI setup is active before building the UI
|
||||
self._flomni_available = self._check_flomni_available()
|
||||
self._build_ui()
|
||||
# polling timer – does NOT refresh params (avoid overwriting edits)
|
||||
self._poll_timer = QTimer(self)
|
||||
self._poll_timer.setInterval(self._POLL_INTERVAL_MS)
|
||||
self._poll_timer.timeout.connect(self._on_poll)
|
||||
self._poll_timer.start()
|
||||
# initial load
|
||||
self.refresh()
|
||||
if self._flomni_available:
|
||||
self._poll_timer.start()
|
||||
self.refresh()
|
||||
# live beamline-busy banner: react to scan-queue changes as they
|
||||
# happen (the tomo heartbeat has no event and is covered by _on_poll)
|
||||
try:
|
||||
self.bec_dispatcher.connect_slot(
|
||||
self._on_queue_status, MessageEndpoints.scan_queue_status()
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"TomoParamsWidget: could not subscribe to scan queue: {exc}")
|
||||
self._refresh_busy_banner()
|
||||
|
||||
# ── setup detection ──────────────────────────────────────────────────────
|
||||
|
||||
def _check_flomni_available(self) -> bool:
|
||||
"""
|
||||
Return True if the FlOMNI setup is active in this BEC session.
|
||||
Uses ``fsamroy`` (the FlOMNI-specific rotation stage) as the
|
||||
discriminator — it is absent in OMNY and LamNI sessions.
|
||||
"""
|
||||
try:
|
||||
return getattr(self.dev, "fsamroy", None) is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# ── global-var I/O ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -212,6 +255,36 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(6)
|
||||
|
||||
# advisory busy banner sits at the very top, present regardless of
|
||||
# setup so it can warn before the user starts editing (hidden default)
|
||||
self._busy_banner = QLabel("")
|
||||
self._busy_banner.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._busy_banner.setWordWrap(True)
|
||||
self._busy_banner.setStyleSheet(
|
||||
"QLabel {"
|
||||
" background-color: #FF9800;"
|
||||
" color: black;"
|
||||
" font-weight: bold;"
|
||||
" border-radius: 4px;"
|
||||
" padding: 6px;"
|
||||
"}"
|
||||
)
|
||||
self._busy_banner.setVisible(False)
|
||||
root.addWidget(self._busy_banner)
|
||||
|
||||
if not self._flomni_available:
|
||||
lbl = QLabel(
|
||||
"⚠ FlOMNI setup not detected in this BEC session.\n\n"
|
||||
"This widget is only meaningful when the FlOMNI\n"
|
||||
"BEC session is active (fsamroy device present)."
|
||||
)
|
||||
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
lbl.setWordWrap(True)
|
||||
root.addStretch()
|
||||
root.addWidget(lbl)
|
||||
root.addStretch()
|
||||
return
|
||||
|
||||
# scrollable params panel
|
||||
params_scroll = QScrollArea()
|
||||
params_scroll.setWidgetResizable(True)
|
||||
@@ -230,7 +303,6 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
self._btn_queue.setToolTip("Open the tomo queue manager")
|
||||
self._btn_queue.clicked.connect(self._show_queue_dialog)
|
||||
root.addWidget(self._btn_queue)
|
||||
self._queue_dlg: Optional["TomoQueueDialog"] = None
|
||||
|
||||
def _build_header(self) -> QGroupBox:
|
||||
box = QGroupBox("Sample")
|
||||
@@ -292,16 +364,16 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
decimals=3,
|
||||
)
|
||||
self._add_int(common_form, "frames_per_trigger", "Frames / trigger", min_=1, max_=100)
|
||||
self._add_double(
|
||||
common_form,
|
||||
"corridor_size",
|
||||
"Corridor size (µm, −1=off)",
|
||||
min_=-1.0,
|
||||
max_=200.0,
|
||||
decimals=2,
|
||||
)
|
||||
sp = self._add_bool(common_form, "single_point_instead_of_fermat_scan", "Single-point scan")
|
||||
sp.stateChanged.connect(self._on_single_point_changed)
|
||||
self._add_double(
|
||||
common_form,
|
||||
"single_point_random_shift_max",
|
||||
"Random shift max (µm)",
|
||||
min_=0.0,
|
||||
max_=10.0,
|
||||
decimals=3,
|
||||
)
|
||||
vbox.addLayout(common_form)
|
||||
|
||||
# type-1 section
|
||||
@@ -348,6 +420,9 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
form = QFormLayout(box)
|
||||
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
|
||||
self._add_int(
|
||||
form, "_numprj_type3", "Projections per sub-tomo (type 3)", min_=1, max_=10000
|
||||
)
|
||||
self._add_int(
|
||||
form, "golden_ratio_bunch_size", "Bunch size (type 2 only)", min_=1, max_=10000
|
||||
)
|
||||
@@ -456,6 +531,15 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
self._pw["_requested_total"].blockSignals(False)
|
||||
|
||||
self._update_projection_preview()
|
||||
|
||||
# type-3: derive projections-per-subtomo from stored tomo_angle_stepsize
|
||||
# (flomni uses 180 / tomo_angle_stepsize for type 3, hardcoded to 180)
|
||||
stepsize = float(params.get("tomo_angle_stepsize", 10.0))
|
||||
numprj_t3 = int(180.0 / stepsize) if stepsize > 0 else 18
|
||||
self._pw["_numprj_type3"].blockSignals(True)
|
||||
self._pw["_numprj_type3"].setValue(numprj_t3)
|
||||
self._pw["_numprj_type3"].blockSignals(False)
|
||||
|
||||
self._update_type_visibility(type_val)
|
||||
|
||||
def _read_fields(self) -> dict[str, Any]:
|
||||
@@ -478,12 +562,19 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
elif isinstance(widget, QComboBox):
|
||||
params[key] = widget.currentData()
|
||||
|
||||
# derive tomo_angle_stepsize from requested_total + angle_range
|
||||
# derive tomo_angle_stepsize from requested_total + angle_range (type 1)
|
||||
angle_range = int(params.get("tomo_angle_range", 180))
|
||||
requested = self._pw["_requested_total"].value()
|
||||
stepsize = _requested_to_stepsize(angle_range, requested)
|
||||
params["tomo_angle_stepsize"] = stepsize
|
||||
|
||||
# type 3: override tomo_angle_stepsize from projections-per-subtomo
|
||||
# (flomni uses 180 / numprj for type 3)
|
||||
if params.get("tomo_type") == 3:
|
||||
numprj = self._pw["_numprj_type3"].value()
|
||||
if numprj > 0:
|
||||
params["tomo_angle_stepsize"] = 180.0 / numprj
|
||||
|
||||
# single_point forces stitch to 0
|
||||
if params.get("single_point_instead_of_fermat_scan"):
|
||||
params["stitch_x"] = 0
|
||||
@@ -509,6 +600,9 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
return "tomo_type must be 1, 2, or 3"
|
||||
if params.get("tomo_angle_range") not in (180, 360):
|
||||
return "tomo_angle_range must be 180 or 360"
|
||||
rshift = params.get("single_point_random_shift_max", 0.0)
|
||||
if not 0 <= rshift <= 10:
|
||||
return "single_point_random_shift_max must be between 0 and 10 µm"
|
||||
return None
|
||||
|
||||
# ── type visibility ───────────────────────────────────────────────────────
|
||||
@@ -516,19 +610,30 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
def _update_type_visibility(self, tomo_type: int) -> None:
|
||||
self._sec_type1.setVisible(tomo_type == 1)
|
||||
self._sec_type23.setVisible(tomo_type in (2, 3))
|
||||
# bunch_size is type-2 only; find its row in the form layout
|
||||
bunch_widget = self._pw.get("golden_ratio_bunch_size")
|
||||
if bunch_widget is not None:
|
||||
bunch_widget.setVisible(tomo_type == 2)
|
||||
# also hide the label in the form layout
|
||||
form = self._sec_type23.layout()
|
||||
if isinstance(form, QFormLayout):
|
||||
idx = form.indexOf(bunch_widget)
|
||||
if idx >= 0:
|
||||
row, role = form.getItemPosition(idx)
|
||||
label_item = form.itemAt(row, QFormLayout.ItemRole.LabelRole)
|
||||
if label_item and label_item.widget():
|
||||
label_item.widget().setVisible(tomo_type == 2)
|
||||
# _numprj_type3: only visible for type 3
|
||||
_w__numprj_type3 = self._pw.get("_numprj_type3")
|
||||
if _w__numprj_type3 is not None:
|
||||
_w__numprj_type3.setVisible(tomo_type == 3)
|
||||
_form = self._sec_type23.layout()
|
||||
if isinstance(_form, QFormLayout):
|
||||
_idx = _form.indexOf(_w__numprj_type3)
|
||||
if _idx >= 0:
|
||||
_row, _role = _form.getItemPosition(_idx)
|
||||
_lbl = _form.itemAt(_row, QFormLayout.ItemRole.LabelRole)
|
||||
if _lbl and _lbl.widget():
|
||||
_lbl.widget().setVisible(tomo_type == 3)
|
||||
# golden_ratio_bunch_size: only visible for type 2
|
||||
_w_golden_ratio_bunch_size = self._pw.get("golden_ratio_bunch_size")
|
||||
if _w_golden_ratio_bunch_size is not None:
|
||||
_w_golden_ratio_bunch_size.setVisible(tomo_type == 2)
|
||||
_form = self._sec_type23.layout()
|
||||
if isinstance(_form, QFormLayout):
|
||||
_idx = _form.indexOf(_w_golden_ratio_bunch_size)
|
||||
if _idx >= 0:
|
||||
_row, _role = _form.getItemPosition(_idx)
|
||||
_lbl = _form.itemAt(_row, QFormLayout.ItemRole.LabelRole)
|
||||
if _lbl and _lbl.widget():
|
||||
_lbl.widget().setVisible(tomo_type == 2)
|
||||
|
||||
# ── type-1 projection preview ─────────────────────────────────────────────
|
||||
|
||||
@@ -567,11 +672,69 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
self._pw["stitch_y"].setValue(0)
|
||||
self._pw["stitch_x"].setEnabled(self._edit_mode and not checked)
|
||||
self._pw["stitch_y"].setEnabled(self._edit_mode and not checked)
|
||||
# random shift only applies to single-point acquisitions
|
||||
self._pw["single_point_random_shift_max"].setEnabled(self._edit_mode and checked)
|
||||
|
||||
def _on_poll(self) -> None:
|
||||
"""Periodic refresh: params (guarded against edit mode) + sample name."""
|
||||
self._refresh_params()
|
||||
self._refresh_sample_name()
|
||||
self._refresh_busy_banner()
|
||||
|
||||
# ── beamline-busy detection ───────────────────────────────────────────────
|
||||
|
||||
def _is_scan_queue_busy(self) -> bool:
|
||||
"""
|
||||
Return True if BEC's primary scan queue is actively running or paused
|
||||
mid-scan.
|
||||
|
||||
Catches a single ptycho projection run manually (outside a tomo scan):
|
||||
it writes no ``tomo_progress`` heartbeat but still occupies the
|
||||
beamline. Mirrors the busy logic in bec_widgets' BECQueue widget so
|
||||
it stays consistent with what the queue display shows.
|
||||
"""
|
||||
try:
|
||||
msg = self.client.connector.get(MessageEndpoints.scan_queue_status())
|
||||
if msg is None:
|
||||
return False # no scan has run yet this session
|
||||
primary = msg.content.get("queue", {}).get("primary")
|
||||
if not primary:
|
||||
return False
|
||||
queue_info = getattr(primary, "info", None)
|
||||
if not queue_info:
|
||||
return False
|
||||
return not all(item.status in _QUEUE_IDLE_STATUSES for item in queue_info)
|
||||
except Exception as exc:
|
||||
logger.warning(f"TomoParamsWidget: scan-queue busy check failed: {exc}")
|
||||
return False
|
||||
|
||||
def _beamline_busy_reason(self) -> Optional[str]:
|
||||
"""Return a short human reason if the beamline is busy, else None."""
|
||||
if self._is_tomo_running():
|
||||
return "tomo scan running"
|
||||
if self._is_scan_queue_busy():
|
||||
return "scan queue active (manual projection or other scan)"
|
||||
return None
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def _on_queue_status(self, _content: dict, _meta: dict) -> None:
|
||||
"""Dispatcher slot: scan-queue status changed -> refresh the banner."""
|
||||
self._refresh_busy_banner()
|
||||
|
||||
def _refresh_busy_banner(self) -> None:
|
||||
"""Show/hide the advisory busy banner based on current beamline state."""
|
||||
banner = getattr(self, "_busy_banner", None)
|
||||
if banner is None:
|
||||
return
|
||||
reason = self._beamline_busy_reason()
|
||||
if reason:
|
||||
banner.setText(
|
||||
f"\u26a0 Beamline busy — {reason}.\n"
|
||||
"Editing is allowed, but submitting changes will ask for confirmation."
|
||||
)
|
||||
banner.setVisible(True)
|
||||
else:
|
||||
banner.setVisible(False)
|
||||
|
||||
# ── public refresh API ────────────────────────────────────────────────────
|
||||
|
||||
@@ -610,11 +773,14 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
job.get("label", f"job_{row}"),
|
||||
status,
|
||||
str(params.get("tomo_type", "?")),
|
||||
f"{params.get('fovx', '?')} × {params.get('fovy', '?')}",
|
||||
_format_projections(params),
|
||||
_fmt_num(params.get("tomo_countingtime")),
|
||||
_fmt_num(params.get("tomo_shellstep")),
|
||||
job.get("added_at", ""),
|
||||
]
|
||||
for col, text in enumerate(cells):
|
||||
item = QTableWidgetItem(text)
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
item.setForeground(table.palette().text() if col != 2 else _color_from_hex(color))
|
||||
table.setItem(row, col, item)
|
||||
|
||||
@@ -642,8 +808,50 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
params = self._load_params()
|
||||
self._populate_fields(params)
|
||||
|
||||
def _is_tomo_running(self) -> bool:
|
||||
"""
|
||||
Return True if a tomo scan is likely active.
|
||||
|
||||
Uses the ``tomo_progress["heartbeat"]`` timestamp written by
|
||||
``_tomo_scan_at_angle()`` at the start of every projection — this is
|
||||
updated whether the scan was started via the queue or directly via
|
||||
``flomni.tomo_scan()``, so it's more reliable than queue job status.
|
||||
A heartbeat fresher than 2 minutes indicates a scan in progress;
|
||||
older than that it's either done, or stuck long enough that editing
|
||||
parameters is intentional.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
try:
|
||||
prog = self._gv_get("tomo_progress")
|
||||
if not isinstance(prog, dict):
|
||||
return False
|
||||
heartbeat_str = prog.get("heartbeat")
|
||||
if heartbeat_str is None:
|
||||
return False
|
||||
heartbeat = datetime.datetime.fromisoformat(heartbeat_str)
|
||||
age_s = (datetime.datetime.now() - heartbeat).total_seconds()
|
||||
return age_s < 120 # 2-minute window
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def submit_params(self) -> None:
|
||||
"""Validate, write to global vars, and re-lock fields."""
|
||||
busy_reason = self._beamline_busy_reason()
|
||||
if busy_reason:
|
||||
reply = QMessageBox.warning(
|
||||
self,
|
||||
"Scan in progress",
|
||||
f"A scan appears to be running ({busy_reason}).\n\n"
|
||||
"Parameters read via properties (e.g. counting time) take effect "
|
||||
"immediately on the next projection. Parameters used to build the "
|
||||
"scan trajectory (FOV, angle step) are already fixed for the "
|
||||
"current run but will affect any subsequent queue job.\n\n"
|
||||
"Apply changes now?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
params = self._read_fields()
|
||||
error = self._validate(params)
|
||||
if error:
|
||||
@@ -689,10 +897,13 @@ class TomoParamsWidget(BECWidget, QWidget):
|
||||
if key in skip:
|
||||
continue
|
||||
widget.setEnabled(enabled)
|
||||
# stitch locked when single_point is active
|
||||
if enabled and self._pw["single_point_instead_of_fermat_scan"].isChecked():
|
||||
# stitch locked when single_point is active; random shift is the
|
||||
# inverse -- only editable while single_point is active
|
||||
single_point = self._pw["single_point_instead_of_fermat_scan"].isChecked()
|
||||
if enabled and single_point:
|
||||
self._pw["stitch_x"].setEnabled(False)
|
||||
self._pw["stitch_y"].setEnabled(False)
|
||||
self._pw["single_point_random_shift_max"].setEnabled(enabled and single_point)
|
||||
# projection requested total only meaningful in edit mode
|
||||
self._pw["_requested_total"].setEnabled(enabled)
|
||||
|
||||
@@ -786,9 +997,9 @@ class TomoQueueDialog(QDialog):
|
||||
vbox = QVBoxLayout(self)
|
||||
vbox.setSpacing(6)
|
||||
|
||||
self._table = QTableWidget(0, 6)
|
||||
self._table = QTableWidget(0, 8)
|
||||
self._table.setHorizontalHeaderLabels(
|
||||
["#", "Label", "Status", "Type", "FOV x×y (µm)", "Added at"]
|
||||
["#", "Label", "Status", "Type", "Projections", "Exp (s)", "Step (µm)", "Added at"]
|
||||
)
|
||||
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
||||
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
@@ -825,11 +1036,14 @@ class TomoQueueDialog(QDialog):
|
||||
job.get("label", f"job_{row}"),
|
||||
status,
|
||||
str(params.get("tomo_type", "?")),
|
||||
f"{params.get('fovx', '?')} × {params.get('fovy', '?')}",
|
||||
_format_projections(params),
|
||||
_fmt_num(params.get("tomo_countingtime")),
|
||||
_fmt_num(params.get("tomo_shellstep")),
|
||||
job.get("added_at", ""),
|
||||
]
|
||||
for col, text in enumerate(cells):
|
||||
item = QTableWidgetItem(text)
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if col == 2:
|
||||
item.setForeground(_color_from_hex(color))
|
||||
self._table.setItem(row, col, item)
|
||||
@@ -936,6 +1150,53 @@ def _compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float
|
||||
return actual_total, achievable_step, stepsize
|
||||
|
||||
|
||||
def _format_projections(params: dict) -> str:
|
||||
"""
|
||||
Human-readable projection count for a queued job, mirroring the CLI's
|
||||
per-type logic:
|
||||
- type 1: int(angle_range / stepsize) * 8 (8 equally spaced sub-tomos)
|
||||
- type 3: int(180 / stepsize) * 8 (equally spaced, golden start)
|
||||
- type 2: golden ratio has no fixed count -> configured max, or ∞ if unset
|
||||
"""
|
||||
try:
|
||||
tomo_type = int(params.get("tomo_type", 1))
|
||||
stepsize = float(params.get("tomo_angle_stepsize", 0) or 0)
|
||||
|
||||
if tomo_type == 1:
|
||||
angle_range = int(params.get("tomo_angle_range", 180))
|
||||
actual_total, _, _ = _compute_type1(angle_range, stepsize)
|
||||
return str(actual_total)
|
||||
|
||||
if tomo_type == 3:
|
||||
if stepsize <= 0:
|
||||
return "?"
|
||||
return str(int(180.0 / stepsize) * 8)
|
||||
|
||||
if tomo_type == 2:
|
||||
max_prj = params.get("golden_max_number_of_projections", 0) or 0
|
||||
try:
|
||||
max_prj = int(float(max_prj))
|
||||
except (TypeError, ValueError):
|
||||
return "∞"
|
||||
return str(max_prj) if max_prj > 0 else "∞"
|
||||
|
||||
return "?"
|
||||
except Exception:
|
||||
return "?"
|
||||
|
||||
|
||||
def _fmt_num(val) -> str:
|
||||
"""Compact numeric formatting for table cells; blank for None."""
|
||||
if val is None:
|
||||
return ""
|
||||
try:
|
||||
f = float(val)
|
||||
except (TypeError, ValueError):
|
||||
return str(val)
|
||||
# trim trailing zeros: 0.100 -> 0.1, 1.0 -> 1
|
||||
return f"{f:g}"
|
||||
|
||||
|
||||
def _color_from_hex(hex_color: str):
|
||||
from qtpy.QtGui import QColor
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ fheater:
|
||||
readOnly: false
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
|
||||
foptx:
|
||||
description: Optics X
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -80,6 +81,10 @@ foptx:
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: -13.8809375
|
||||
out: -14.1809
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
# in: -14.5490625
|
||||
# out: -14.1809
|
||||
|
||||
fopty:
|
||||
description: Optics Y
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -103,6 +108,10 @@ fopty:
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: 2.8299
|
||||
out: 2.8299
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
# in: 2.8419921875
|
||||
# out: 2.8419921875
|
||||
|
||||
foptz:
|
||||
description: Optics Z
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -121,6 +130,7 @@ foptz:
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: 23
|
||||
|
||||
fsamroy:
|
||||
description: Sample rotation
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fupr_ophyd.FuprGalilMotor
|
||||
@@ -154,7 +164,7 @@ fsamx:
|
||||
readoutPriority: baseline
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
in: -1.1
|
||||
in: -1.14
|
||||
fsamy:
|
||||
description: Sample coarse Y
|
||||
deviceClass: csaxs_bec.devices.omny.galil.fgalil_ophyd.FlomniGalilMotor
|
||||
@@ -314,8 +324,12 @@ fosax:
|
||||
# in: 8.731922
|
||||
# out: 5.1
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: 8.755141
|
||||
in: 8.7392
|
||||
out: 5.1
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
# in: 9.420798
|
||||
# out: 5.1
|
||||
|
||||
fosay:
|
||||
description: OSA Y
|
||||
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
@@ -338,7 +352,9 @@ fosay:
|
||||
#170 micron, 60 nm, 7.6 kev
|
||||
#in: -0.0422
|
||||
#250 micron, 30 nm, Abe structures
|
||||
in: -2.357436
|
||||
in: -2.3684
|
||||
#250 micron, 30 nm, Tomas structures
|
||||
# in: -2.383993
|
||||
fosaz:
|
||||
description: OSA Z
|
||||
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
|
||||
@@ -362,9 +378,9 @@ fosaz:
|
||||
#170 micron, 60 nm, 7.9 kev, foptz 15.9
|
||||
# in: 11.9
|
||||
# out: 6
|
||||
# micron, 30 nm, 7.9 kev, foptz 32 //abe's fzp's
|
||||
in: -2
|
||||
out: -5
|
||||
# micron, 30 nm, 7.9 kev, very close to the sample. make sure foptz is 32.02 or smaller //abe's fzp's
|
||||
in: 0.5
|
||||
out: -5
|
||||
|
||||
############################################################
|
||||
#################### flOMNI RT motors ######################
|
||||
@@ -387,8 +403,8 @@ rtx:
|
||||
readoutPriority: on_request
|
||||
connectionTimeout: 20
|
||||
userParameter:
|
||||
low_signal: 10000
|
||||
min_signal: 9000
|
||||
low_signal: 8500
|
||||
min_signal: 8000
|
||||
rt_pid_voltage: -0.06219
|
||||
rty:
|
||||
description: flomni rt
|
||||
|
||||
@@ -383,14 +383,14 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
|
||||
|
||||
# NOTE First we wait that the MCS card is not acquiring. We add here a timeout of 5s to avoid
|
||||
# a deadlock in case the MCS card is stuck for some reason. This should not happen normally.
|
||||
status = CompareStatus(mcs.acquiring, ACQUIRING.DONE)
|
||||
status = CompareStatus(mcs.acquiring, ACQUIRING.DONE, timeout=5)
|
||||
self.cancel_on_stop(status)
|
||||
status.wait(timeout=5)
|
||||
status.wait()
|
||||
|
||||
# NOTE Clear the '_omit_mca_callbacks' flag. This makes sure that data received from the mca1...mca3
|
||||
# counters are forwarded to BEC. Once the flag is set, we create a TransitionStatus DONE->ACQUIRING
|
||||
# and start the acquisition through erase_start.put(1). Finally, we wait for the card to go to ACQUIRING state.
|
||||
status_acquiring = CompareStatus(mcs.acquiring, ACQUIRING.ACQUIRING)
|
||||
status_acquiring = CompareStatus(mcs.acquiring, ACQUIRING.ACQUIRING, timeout=5)
|
||||
with suppress_mca_callbacks(mcs):
|
||||
mcs.erase_start.put(1)
|
||||
mcs._omit_mca_callbacks.clear() # pylint: disable=protected-access
|
||||
@@ -614,7 +614,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
|
||||
# )
|
||||
if not status_mcs.done:
|
||||
mcs.acquiring.get(use_monitor=False)
|
||||
status_mcs.wait(timeout=3)
|
||||
status_mcs.wait()
|
||||
except Exception as exc:
|
||||
if (
|
||||
mcs.acquiring.get(use_monitor=False) != ACQUIRING.ACQUIRING
|
||||
@@ -625,7 +625,8 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
|
||||
raise exc
|
||||
# mcs.erase_start.put(1)
|
||||
# status_mcs.wait(timeout=3)
|
||||
status = CompareStatus(mcs.acquiring, ACQUIRING.DONE)
|
||||
timeout = 5 + self.scan_parameters.exp_time * self.scan_parameters.frames_per_trigger
|
||||
status = CompareStatus(mcs.acquiring, ACQUIRING.DONE, timeout=timeout)
|
||||
logger.info(f"Finished preparing mcs card {time.time()-start_time}")
|
||||
|
||||
# Send trigger
|
||||
|
||||
@@ -45,6 +45,12 @@ class RtFlomniController(Controller):
|
||||
"laser_tracker_check_signalstrength",
|
||||
"laser_tracker_check_enabled",
|
||||
"is_axis_moving",
|
||||
"emitter_show_all",
|
||||
"emitter_get",
|
||||
"emitter_set_tracking_target_y",
|
||||
"emitter_set_tracking_target_z",
|
||||
"emitter_set_intensity_threshold_laser",
|
||||
"emitter_set_psd_intensity_threshold_tracking_low",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
@@ -391,6 +397,63 @@ class RtFlomniController(Controller):
|
||||
return True
|
||||
return False
|
||||
|
||||
def emitter_get(self) -> dict:
|
||||
"""Read the runtime constants served by the ConstEmitter Orchestra module.
|
||||
|
||||
These are values that used to live in individual module parameter files
|
||||
(tracking targets and intensity thresholds) and are now emitted centrally
|
||||
so they can be tweaked live. The rotation-loop laser threshold
|
||||
(intensity_threshold_rot_laser) is a pure startup constant and is
|
||||
intentionally not reported here.
|
||||
|
||||
Returns:
|
||||
dict: current emitter values.
|
||||
"""
|
||||
ret = self.socket_put_and_receive("es")
|
||||
# remove trailing \n
|
||||
ret = ret.split("\n")[0]
|
||||
values = [float(val) for val in ret.split(",")]
|
||||
if len(values) != 4:
|
||||
raise RtCommunicationError(
|
||||
f"Expected to receive 4 emitter values. Instead received {values}"
|
||||
)
|
||||
return {
|
||||
"targetpos_tracking_y": values[0],
|
||||
"targetpos_tracking_z": values[1],
|
||||
"intensity_threshold_laser": values[2],
|
||||
"PSD_intensity_threshold_tracking_low": values[3],
|
||||
}
|
||||
|
||||
def emitter_show_all(self):
|
||||
"""Print all ConstEmitter runtime constants as a table."""
|
||||
t = PrettyTable()
|
||||
t.title = "ConstEmitter Runtime Constants"
|
||||
t.field_names = ["Name", "Value"]
|
||||
for key, val in self.emitter_get().items():
|
||||
t.add_row([key, val])
|
||||
print(t)
|
||||
|
||||
def emitter_set_tracking_target_y(self, val: float) -> None:
|
||||
"""Set the y tracking target position (PID_tracking_y)."""
|
||||
self.socket_put(f"ey{val:.4f}")
|
||||
|
||||
def emitter_set_tracking_target_z(self, val: float) -> None:
|
||||
"""Set the z tracking target position (PID_tracking_z)."""
|
||||
self.socket_put(f"ez{val:.4f}")
|
||||
|
||||
def emitter_set_intensity_threshold_laser(self, val: float) -> None:
|
||||
"""Set the shared laser intensity threshold.
|
||||
|
||||
Wired into the slew-rate limiters (x/y/z) and the PID loops
|
||||
(x/y/z and the fast FZP x/y loops). Does not affect the
|
||||
rotation loops, which use a separate fixed threshold.
|
||||
"""
|
||||
self.socket_put(f"et{val:.4f}")
|
||||
|
||||
def emitter_set_psd_intensity_threshold_tracking_low(self, val: float) -> None:
|
||||
"""Set the low PSD intensity threshold used by the tracking PIDs (y and z)."""
|
||||
self.socket_put(f"el{val:.4f}")
|
||||
|
||||
def pid_y(self) -> float:
|
||||
ret = float(self.socket_put_and_receive("G").strip())
|
||||
return ret
|
||||
@@ -929,4 +992,4 @@ if __name__ == "__main__":
|
||||
socket_cls=SocketIO, socket_host="mpc2844.psi.ch", socket_port=2222, device_manager=None
|
||||
)
|
||||
rtcontroller.on()
|
||||
rtcontroller.laser_tracker_on()
|
||||
rtcontroller.laser_tracker_on()
|
||||
@@ -47,13 +47,15 @@ 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:
|
||||
After the sample transfer the sample stage moved to the measurement position with your new sample. The Xray eye will automatically move in, the alignment GUI opens and shows a current snapshot.
|
||||
If you see your sample already at the approximately correct height:
|
||||
|
||||
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. `flomni.xrayeye_alignment_start()` start the coarse alignment of the sample by measuring (clicking in the X-ray eye software) the sample position at its height and then angles of 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.
|
||||
|
||||
Otherwise adjust the height manually:
|
||||
|
||||
1. `umvr_fsamy_tracked(0.01)`, attention: unit <mm>, move the sample stage relative up (positive) or down (negative) until the sample is approximately vertically centered in xray eye screen
|
||||
1. `flomni.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 its height and angles of 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
|
||||
|
||||
@@ -90,7 +92,7 @@ A __single projection__ is to be repeated use
|
||||
|
||||
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.
|
||||
It picks up automatically from wherever the scan was interrupted, no need to look up the exact subtomogram/angle by hand. Do not use this command when running from the tomo queuing system. Instead use `flomni.tomo_queue_execute()`.
|
||||
|
||||
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 |
|
||||
@@ -322,7 +324,7 @@ For the "8 sub-tomograms" mode, `flomni.tomo_parameters()` also offers a `zero_d
|
||||
|
||||
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.
|
||||
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. When running from the tomo scan queuing system use `flomni.tomo_queue_execute()` instead!
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user