Merge branch 'main' into fix/rename-mpc-flomni
CI for csaxs_bec / test (push) Successful in 3m21s
CI for csaxs_bec / test (pull_request) Successful in 3m25s

This commit is contained in:
holler committed 2026-11-03 09:38:12 +01:00
commit 2291fcbabb
67 files changed
+3763 -495

No files matched your search

+6
View File
@@ -22,6 +22,12 @@ the repo when I ask for one.
## TODO
- 2026-10-15: reduce flOMNI scan overhead. Log analysis of the Oct 7–12 beamtime: ~3.4 s dead
time between projections plus ~30–50 ms per point; quick wins are the 1 s `status.done`
polling sleep in `scan_core`/`at_each_point` (also in lamni/omny fermat scans) and the
duplicate laser-tracker check. Findings, method and ranked ideas:
`csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/FLOMNI_SCAN_OVERHEAD.md`.
- 2026-09-21: port "always define `tomo_alignment_fit`" from Flomni to OMNY. Flomni is done
(commit `5b688df`): `reset_tomo_alignment_fit()` writes zeros instead of deleting the global
var, and `ensure_tomo_alignment_fit()` (called from `Flomni.__init__`) creates the zero fit
+1 -1
View File
@@ -149,5 +149,5 @@ def transform(src_name: str, dst_dir: str, dst_name: str, setup: str) -> None:
if __name__ == "__main__":
transform("ptycho_lamni.yaml", "simulated_omny", "simulated_lamni.yaml", "LamNI")
transform("ptycho_lamni.yaml", "simulated_omny", "simulated_bl_simulated_lamni.yaml", "LamNI")
transform("ptycho_omny.yaml", "simulated_omny", "simulated_omny.yaml", "OMNY")
@@ -22,6 +22,8 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import
OMNYTools,
PtychoReconstructor,
TomoIDManager,
lamni_smallest_tile_position_count,
stitch_tile_offsets,
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import (
AngleFileError,
@@ -31,9 +33,15 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import (
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_angle_file_mixin import (
TomoAngleFileMixin,
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.idle_time import account_projection_gap
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import TomoQueueMixin
from csaxs_bec.scans.lamni_components import LamNIComponents
from .lamni_optics_mixin import LaMNIInitStages, LamNIOpticsMixin
from .lamni_optics_mixin import (
LaMNIInitStages,
LamNIOpticsMixin,
compute_osa_remaining_space,
)
from .x_ray_eye_align import XrayEyeAlign as XrayEyeAlignGUI
logger = bec_logger.logger
@@ -71,6 +79,7 @@ class _ProgressProxy:
"estimated_finish_time": None,
"heartbeat": None,
"accumulated_idle_time": 0.0,
"recent_projection_gaps": [],
}
def __init__(self, client):
@@ -190,12 +199,15 @@ class LamNI(
"idgap": {"label": "Undulator gap", "unit": "mm"},
}
# Named-action registry for tomo-queue command jobs -- identical to
# flomni's plain "move" action, since it's beamline-wide, not
# flomni-specific hardware. flomni also has a "move_foptz_with_shift"
# action for its own FZP optics-Z stage; lamni has no equivalent
# hardware, so it isn't mirrored here. See
# TomoQueueMixin._queue_action_move() for the implementation.
# Named-action registry for tomo-queue command jobs. "move" is identical
# to flomni's plain move action (beamline-wide devices).
# "move_loptz_with_shift" is lamni's counterpart of flomni's
# move_foptz_with_shift, but sets loptz_shift_x/y (not manual_shift_x/y,
# which every queued tomo job snapshots and restores, and would thus
# overwrite). loptz is deliberately NOT in _TOMO_QUEUE_MOVE_DEVICES, so
# this is the only way to queue a loptz move and the new shift can't be
# forgotten. See TomoQueueMixin._queue_action_move() for "move".
# "set_keithley_current" is not a "move" because it carries its own sleep.
_TOMO_QUEUE_ACTIONS = {
"move": {
"func": "_queue_action_move",
@@ -203,8 +215,165 @@ class LamNI(
"help": "Move device(s) to absolute position(s).",
"params": {"positions": {"type": "device_positions"}},
},
"move_loptz_with_shift": {
"func": "_queue_action_move_loptz_with_shift",
"idempotent": True, # absolute move + absolute param set, re-runs harmlessly
"help": (
"Move loptz (FZP optics-Z) to an absolute target and set new "
"loptz_shift_x/loptz_shift_y values (replacing the current "
"ones) in the same step -- changing loptz shifts the aligned "
"scan center, so this is the only way to queue a loptz move."
),
"params": {
"loptz_target": {
"type": "float",
"default": 77.0,
"min": 70.0,
"max": 83.0,
"unit": "mm",
"label": "loptz target (absolute)",
# Live ophyd limits are checked at add time and again at
# execution time; min/max above are only the static
# fallback if the live device can't be reached.
"device_limits_from": "loptz",
},
# mm, non-rotating (added to lamni_fermat_scan shift_x/y)
"loptz_shift_x": {
"type": "float",
"default": 0.0,
"min": -1.0,
"max": 1.0,
"unit": "mm",
"label": "New loptz_shift_x",
},
"loptz_shift_y": {
"type": "float",
"default": 0.0,
"min": -1.0,
"max": 1.0,
"unit": "mm",
"label": "New loptz_shift_y",
},
},
},
"set_keithley_current": {
"func": "_queue_action_set_keithley_current",
"idempotent": True, # absolute level, re-runs harmlessly
"help": (
"Set the Keithley source current, switching its output on if it is "
"off, then sleep."
),
"params": {
"current_mA": {
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 3.0,
"unit": "mA",
"label": "Current",
"device_limits_from": "keithley",
},
"sleep_s": {
"type": "float",
"default": 0.0,
"min": 0.0,
"max": 86400.0,
"unit": "s",
"label": "Sleep afterwards",
},
},
},
"sleep": {
"func": "_queue_action_sleep",
"idempotent": True, # re-running only waits again
"help": "Wait for a number of seconds.",
"params": {
"seconds": {
"type": "float",
"default": 60.0,
"min": 0.0,
"max": 86400.0,
"unit": "s",
"label": "Sleep",
},
},
},
}
def _queue_action_sleep(self, seconds: float) -> None:
"""Wait, the queue stays on this job."""
if seconds < 0:
raise ValueError(f"Queue sleep: seconds={seconds} is negative.")
print(f"Queue: sleeping {seconds} s.")
time.sleep(seconds)
def _queue_action_set_keithley_current(self, current_mA: float, sleep_s: float = 0.0) -> None:
"""Set the Keithley current and sleep.
The move fails if the current isn't reached (e.g. voltage compliance),
which pauses the queue before the next job. Re-checks the live limits
at execution time.
"""
if "keithley" not in dev:
raise ValueError("Queue set_keithley_current: device 'keithley' is not loaded.")
lo, hi = dev.keithley.limits
if not (lo <= current_mA <= hi):
raise ValueError(
f"Queue set_keithley_current: current_mA={current_mA} is outside "
f"keithley's current limits ({lo}, {hi})."
)
umv(dev.keithley, current_mA)
if sleep_s > 0:
self._queue_action_sleep(sleep_s)
def _queue_action_move_loptz_with_shift(
self, loptz_target: float, loptz_shift_x: float, loptz_shift_y: float
) -> None:
"""Move loptz and set new loptz_shift_x/y values together.
loptz_shift_x/loptz_shift_y (mm, non-rotating) are replaced outright
by the values given here, not adjusted relative to whatever they
were before. loptz changes the FZP-to-sample distance; the
interferometer mirrors aren't perfectly parallel to the stage
motion, so the aligned scan center shifts with it. See the
loptz_shift_x property for why this isn't manual_shift_x/y.
loptz is movable with interferometer feedback running (the RT
controller no longer locks it read-only), so feedback is left as is.
Re-checks loptz's live limits at execution time, and refuses the move
if the OSA at its *current* losaz position would collide with the
sample holder at the target (the OSA rides on loptz, so this is a
physical collision, not just a swap-in risk).
FUTURE: loptz_shift_x/y could be proposed from a calibration of
shift vs. (loptz - loptz_shift_reference) instead of typed in.
"""
lo, hi = dev.loptz.limits
if not (lo <= loptz_target <= hi):
raise ValueError(
f"Queue move_loptz_with_shift: loptz_target={loptz_target} is "
f"outside loptz's current limits ({lo}, {hi})."
)
collision_offset = self._get_user_param_optional("loptz", "collision_offset")
if collision_offset is None:
print(
"Warning: loptz has no 'collision_offset' userParameter -- "
"skipping the OSA collision check."
)
else:
losaz_val = dev.losaz.readback.get()
clearance = compute_osa_remaining_space(loptz_target, losaz_val, collision_offset)
if clearance <= 0:
raise ValueError(
f"Queue move_loptz_with_shift: loptz_target={loptz_target} with "
f"losaz at {losaz_val:.3f} leaves {clearance:.2f} mm OSA "
"clearance (<=0 = collision with the sample holder)."
)
umv(dev.loptz, loptz_target)
self.loptz_shift_x = loptz_shift_x
self.loptz_shift_y = loptz_shift_y
def _describe_tomo_job_line(self, job: dict) -> str:
"""lamni's tomo_queue_show() one-line summary: circfov instead of
fov, no range= (lamni is 360-only).
@@ -354,7 +523,7 @@ class LamNI(
self.tomo_stitch_overlap = 0.2
self.golden_projections_at_0_deg_for_damage_estimation = 0
self.zero_deg_reference_at_each_subtomo = False
self.golden_ratio_bunch_size = 20
self.golden_ratio_bunch_size = 100
self.frames_per_trigger = 1
self.lamni_piezo_range_x = 20
self.lamni_piezo_range_y = 20
@@ -544,7 +713,7 @@ class LamNI(
if not self._confirm_sequence_override(
"No rotation-center calibration has been recorded yet "
"(xrayeye_rotation_center_calibration_isolated/extended/"
"smear_experimental()). X-ray-eye alignment is normally done "
"smear()). X-ray-eye alignment is normally done "
"after finding the rotation centre.",
force,
):
@@ -586,6 +755,7 @@ class LamNI(
f"Manual shifts were reset to zero after X-ray eye alignment "
f"(previous values: x={mx:.3f}, y={my:.3f})."
)
self.reset_loptz_shift()
def xrayeye_update_frame(self, keep_shutter_open: bool = False):
"""Capture a single fresh X-ray eye frame without running full alignment.
@@ -702,14 +872,14 @@ class LamNI(
logger.warning(f"Failed to disable XRayEye DAP parameter forwarding: {gui_exc}")
raise exc
def xrayeye_rotation_center_calibration_smear_experimental(
def xrayeye_rotation_center_calibration_smear(
self,
keep_shutter_open: bool = False,
sweep_deg: float = 360.0,
projection: str = "min",
opaque_level: float = 0.25,
):
"""EXPERIMENTAL: extended-sample rotation-center calibration aided by
"""Extended-sample rotation-center calibration aided by
a continuous-rotation min- or max-projection ("star-trail") composite image,
instead of a single instant of live rotation.
@@ -741,7 +911,7 @@ class LamNI(
"""
aligner = XrayEyeAlignGUI(self.client, self)
try:
return aligner.find_rotation_center_smear_experimental(
return aligner.find_rotation_center_smear(
keep_shutter_open=keep_shutter_open,
sweep_deg=sweep_deg,
projection=projection,
@@ -904,6 +1074,59 @@ class LamNI(
def manual_shift_y(self, val: float):
self.client.set_global_var("manual_shift_y", val)
# loptz_shift_x/y (mm, non-rotating): compensates the scan-center drift
# caused by moving loptz after alignment (interferometer mirrors not
# perfectly parallel to the loptz motion). Kept separate from
# manual_shift_x/y on purpose: NOT in _TOMO_SCAN_PARAM_NAMES, so queued
# tomo jobs don't snapshot/restore it and a queued loptz move's shift
# survives the following scan jobs. Zeroed at X-ray eye alignment, where
# the current loptz is recorded as loptz_shift_reference.
@property
def loptz_shift_x(self):
val = self.client.get_global_var("loptz_shift_x")
if val is None:
return 0.0
return val
@loptz_shift_x.setter
def loptz_shift_x(self, val: float):
self.client.set_global_var("loptz_shift_x", val)
@property
def loptz_shift_y(self):
val = self.client.get_global_var("loptz_shift_y")
if val is None:
return 0.0
return val
@loptz_shift_y.setter
def loptz_shift_y(self, val: float):
self.client.set_global_var("loptz_shift_y", val)
@property
def loptz_shift_reference(self):
"""loptz position at which loptz_shift_x/y were last zeroed, or None."""
return self.client.get_global_var("loptz_shift_reference")
@loptz_shift_reference.setter
def loptz_shift_reference(self, val):
self.client.set_global_var("loptz_shift_reference", val)
def reset_loptz_shift(self):
"""Zero loptz_shift_x/y and record the current loptz as reference."""
lx, ly = self.loptz_shift_x, self.loptz_shift_y
self.loptz_shift_x = 0.0
self.loptz_shift_y = 0.0
try:
self.loptz_shift_reference = float(dev.loptz.readback.get())
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Could not read loptz for loptz_shift_reference: {exc}")
self.loptz_shift_reference = None
if lx != 0.0 or ly != 0.0:
self.OMNYTools.printgreen(
f"loptz shifts were reset to zero (previous values: x={lx} mm, y={ly} mm)."
)
@property
def lamni_piezo_range_x(self):
val = self.client.get_global_var("lamni_piezo_range_x")
@@ -1037,13 +1260,13 @@ class LamNI(
def golden_ratio_bunch_size(self):
val = self.client.get_global_var("golden_ratio_bunch_size")
if val is None:
return 20
return 100
return val
@golden_ratio_bunch_size.setter
def golden_ratio_bunch_size(self, val: int):
if val < 20:
raise ValueError("golden_ratio_bunch_size must be at least 20.")
if not 30 <= val <= 200:
raise ValueError("golden_ratio_bunch_size must be between 30 and 200.")
self.client.set_global_var("golden_ratio_bunch_size", val)
@property
@@ -1410,14 +1633,24 @@ class LamNI(
additional_correction = self.compute_additional_correction(angle)
additional_correction_2 = self.compute_additional_correction_2(angle)
correction_xeye_mu = self.lamni_compute_additional_correction_xeye_mu(angle)
print(f"Constant shift: x={self.manual_shift_x}, y={self.manual_shift_y}")
# base shift (center finding / first X-ray eye click), rotated to this
# angle by lamni_fermat_scan via center_x/center_y
base_x, base_y = LamNIComponents.lamni_compute_scan_center(
self.tomo_fovx_offset, self.tomo_fovy_offset, angle
)
print(
f"Base shift: x={self.tomo_fovx_offset:.6f} mm, y={self.tomo_fovy_offset:.6f} mm"
f" at 0 deg -> x={base_x:.6f} mm, y={base_y:.6f} mm @ angle={angle}"
)
print(f"Constant shift: x={self.manual_shift_x} mm, y={self.manual_shift_y} mm")
print(f"loptz shift: x={self.loptz_shift_x} mm, y={self.loptz_shift_y} mm")
self._current_scan_list = []
start_scan_number = bec.queue.next_scan_number
projection_start = time.perf_counter()
for stitch_x in range(-self.lamni_stitch_x, self.lamni_stitch_x + 1):
for stitch_y in range(-self.lamni_stitch_y, self.lamni_stitch_y + 1):
for stitch_x in stitch_tile_offsets(self.lamni_stitch_x):
for stitch_y in stitch_tile_offsets(self.lamni_stitch_y):
self._current_scan_list.append(bec.queue.next_scan_number)
log_message = (
f"{str(datetime.datetime.now())}: LamNI scan projection at angle {angle},"
@@ -1435,12 +1668,14 @@ class LamNI(
center_y=self.tomo_fovy_offset,
shift_x=(
self.manual_shift_x
+ self.loptz_shift_x
+ correction_xeye_mu[0]
- additional_correction[0]
- additional_correction_2[0]
),
shift_y=(
self.manual_shift_y
+ self.loptz_shift_y
+ correction_xeye_mu[1]
- additional_correction[1]
- additional_correction_2[1]
@@ -1471,46 +1706,70 @@ class LamNI(
if _reconstruct:
self.tomo_reconstruct()
@staticmethod
def alignment_scan_angles(number_of_projections: int = 13) -> list[float]:
"""Angles of tomo_alignment_scan(): number_of_projections equally
spaced angles from 0 to 360 inclusive (default 13, i.e. every 30
degrees). 360 is the same orientation as 0; it is measured anyway as
a last data point confirming the sample did not drift during the run."""
return list(np.linspace(0, 360, num=number_of_projections))
def write_alignment_scan_numbers(
self, first_scan: int, scans: list[int] | None = None
self,
first_scan: int,
scans: list[int] | None = None,
angles: list[float] | None = None,
) -> None:
"""Write the scan-number/angle/offset log consumed by BEC_ptycho_align.
``scans`` (optional): the actual scan number of each alignment point,
used to rewrite the file after the scan when a retried angle consumed
extra scan numbers. Defaults to the contiguous ``first_scan + k``
prediction written before the scan starts.
One scan number per alignment angle: the first scan of that angle's
stitch series (with stitching, each projection is
(lamni_stitch_x+1)*(lamni_stitch_y+1) scans).
Mirrors Flomni.write_alignment_scan_numbers() exactly (same 4-line
format), adapted to lamni's own alignment-scan angle set (12 points
across the full 360 degrees, see tomo_alignment_scan()) and its own
x-ray-eye-fit offset source (lamni_compute_additional_correction_xeye_mu()
instead of get_alignment_offset()). Assumes exactly one scan per
alignment point (no stitching) -- same assumption flomni's own
version makes.
``scans`` (optional): the actual first scan number of the angles
acquired so far (possibly fewer than all angles), used to rewrite the
file during/after the scan when a retried angle consumed extra scan
numbers. Angles not yet acquired are predicted from the last known
one, stepping by the number of stitch tiles per projection. Without
``scans`` the whole list is predicted from ``first_scan``.
``angles`` (optional): the alignment angles; defaults to
alignment_scan_angles().
File format, as read by BEC_ptycho_align (MATLAB):
line 1 scans first scan number per angle
line 2 theta angle [deg]
line 3 scan_xpos X-ray-eye x correction at that angle [um]
line 4 scan_ypos X-ray-eye y correction at that angle [um]
Lines 3/4 are only used by its sinusoid branch (lamni_fit = false);
the LaMNI ellipse fit uses just the clicks, scans and theta.
lamni_compute_additional_correction_xeye_mu() returns mm, hence *1000.
"""
angles = list(np.linspace(0, 360, num=12, endpoint=False))
if scans is None:
scans = [first_scan + k for k in range(len(angles))]
if angles is None:
angles = self.alignment_scan_angles()
scans_per_angle = (self.lamni_stitch_x + 1) * (self.lamni_stitch_y + 1)
scans = list(scans) if scans else [first_scan]
while len(scans) < len(angles):
scans.append(scans[-1] + scans_per_angle)
x_vals = []
x_um, y_um = [], []
for angle in angles:
x, _y = self.lamni_compute_additional_correction_xeye_mu(angle, verbose=False)
x_vals.append(x)
zeros = [0] * len(angles)
x, y = self.lamni_compute_additional_correction_xeye_mu(angle, verbose=False)
x_um.append(x * 1000)
y_um.append(y * 1000)
file = os.path.expanduser("~/data/raw/logs/ptychotomoalign_scannum.txt")
os.makedirs(os.path.dirname(file), exist_ok=True)
with open(file, "w") as f:
f.write(" ".join(map(str, scans)) + "\n")
f.write(" ".join(map(str, angles)) + "\n")
f.write(" ".join(f"{x:.2f}" for x in x_vals) + "\n")
f.write(" ".join(map(str, x_vals)) + "\n")
f.write(" ".join(f"{x:.4f}" for x in x_um) + "\n")
f.write(" ".join(f"{y:.4f}" for y in y_um) + "\n")
def tomo_alignment_scan(self, force: bool = False) -> None:
def tomo_alignment_scan(self, number_of_projections: int = 13, force: bool = False) -> None:
"""Perform a laminogram alignment scan: a quick ptychography scan at
12 angles evenly spaced across the full 360 degrees, using whatever
number_of_projections equally spaced angles from 0 to 360 inclusive
(default 13, every 30 degrees; see alignment_scan_angles()), using whatever
tomo_parameters() are currently set (FOV/step/counting time --
tomo_type and requested projection count are ignored, same as
Flomni.tomo_alignment_scan()). Collects all scan numbers acquired
@@ -1521,8 +1780,16 @@ class LamNI(
scan numbers.
Args:
number_of_projections: number of angles, 0 and 360 included
(at least 2).
force: skip the X-ray-eye-alignment check below without prompting.
"""
if int(number_of_projections) != number_of_projections or number_of_projections < 2:
raise ValueError(
f"number_of_projections must be an integer >= 2, got {number_of_projections}."
)
number_of_projections = int(number_of_projections)
if self.client.get_global_var("tomo_fit_xray_eye") is None:
if not self._confirm_sequence_override(
"No X-ray-eye alignment fit is loaded (or it was invalidated "
@@ -1536,6 +1803,11 @@ class LamNI(
bec = builtins.__dict__.get("bec")
dev = builtins.__dict__.get("dev")
# leye_out() retracts the filters (csaxs.fil_trans(1)), so check only
# afterwards -- checking first warned about filters it was about to
# remove anyway.
self.leye_out()
all_out, offending = filters_out_of_beam(dev)
if not all_out:
warning = f"Not all filters are out of the beam: {', '.join(offending)}."
@@ -1543,11 +1815,9 @@ class LamNI(
print("Aborting alignment scan.")
return
self.leye_out()
angles = self.alignment_scan_angles(number_of_projections)
self.write_alignment_scan_numbers(bec.queue.next_scan_number, angles=angles)
self.write_alignment_scan_numbers(bec.queue.next_scan_number)
angles = list(np.linspace(0, 360, num=12, endpoint=False))
alignment_scan_numbers = []
self.alignment_scan_progress.reset()
@@ -1580,14 +1850,15 @@ class LamNI(
first_scan_per_angle.append(start_scan_number)
alignment_scan_numbers.extend(range(start_scan_number, end_scan_number))
# Keep the file current after every angle, so an interrupted run
# still leaves correct first-of-stitch-series numbers behind.
self.write_alignment_scan_numbers(
first_scan_per_angle[0], scans=first_scan_per_angle, angles=angles
)
self.alignment_scan_progress.update(angle_index=idx + 1, angle=angle)
self._lamnigui_update_alignment_progress()
# The file written up front assumes first_scan + k per angle; a retried
# angle consumes extra scan numbers, so rewrite it with what was
# actually acquired.
self.write_alignment_scan_numbers(first_scan_per_angle[0], scans=first_scan_per_angle)
umv(dev.lsamrot, 0)
self.OMNYTools.printgreenbold(
"\n\nAlignment scan finished. Please run BEC_ptycho_align and load the new fit"
@@ -1747,6 +2018,7 @@ class LamNI(
"estimated_finish_time": None,
"heartbeat": None,
"accumulated_idle_time": 0.0,
"recent_projection_gaps": [],
}
)
self._lamnigui_update_progress()
@@ -1916,36 +2188,14 @@ class LamNI(
):
if self._min_scan_angle() <= angle < self._max_scan_angle() + 0.05:
now = datetime.datetime.now()
prev_heartbeat_str = self.progress.get("heartbeat")
if prev_heartbeat_str is not None:
gap = (now - datetime.datetime.fromisoformat(prev_heartbeat_str)).total_seconds()
# Normal cadence between consecutive projections is roughly the
# acquisition time (times the number of stitch tiles per
# projection) plus motor/readout overhead. A gap well beyond
# that means something interrupted the scan in between
# (beamline-down interlock pause, a crash + manual restart,
# ...) -- attribute the excess to idle time so it doesn't drag
# down the apparent scan rate used for the ETA above. The
# 5x/60s margins are a heuristic, not a precise timing model --
# tune if it over/under-triggers in practice.
n_tiles = (2 * self.lamni_stitch_x + 1) * (2 * self.lamni_stitch_y + 1)
normal_cadence = max(
60.0,
5
* (self.tomo_countingtime if exp_time is None else exp_time)
* (self.frames_per_trigger if frames_per_trigger is None else frames_per_trigger)
* n_tiles,
# A gap well beyond this scan's measured typical gap (beamline-down
# pause, a crash + manual restart, ...) is excluded from the ETA.
idle = account_projection_gap(self.progress, now)
if idle > 0:
print(
"Detected a long gap since the last projection"
f" -- excluding {self._format_duration(idle)} from the ETA estimate."
)
if gap > normal_cadence:
idle = gap - normal_cadence
self.progress["accumulated_idle_time"] = (
self.progress.get("accumulated_idle_time", 0.0) + idle
)
print(
f"Detected a {self._format_duration(gap)} gap since the last projection"
f" -- excluding {self._format_duration(idle)} from the ETA estimate."
)
self.progress["heartbeat"] = now.isoformat()
print(f"Starting LamNI scan for angle {angle} in subtomo {subtomo_number}")
self._print_progress()
@@ -2383,49 +2633,32 @@ class LamNI(
# ------------------------------------------------------------------
def _expected_fermat_position_count(self) -> int:
"""Predict the number of Fermat-spiral scan positions the current
settings would produce per projection tile, using the exact same
algorithm LamniFermatScan runs at scan time
(LamniFermatScan.get_lamni_fermat_spiral_pos()) -- so a
too-few-points configuration (which the scan server would only
catch by aborting with ScanAbortion once the scan actually starts)
can be caught here instead, while just looking at tomo_parameters().
"""Predict the number of Fermat-spiral scan positions of the stitch
tile with the fewest points, using the exact same algorithm
LamniFermatScan runs at scan time (see
lamni_smallest_tile_position_count()) -- so a too-few-points
configuration (which the scan server would only catch by aborting
with ScanAbortion once the scan actually starts) can be caught here
instead. With a circular FOV the corner tiles can hold far fewer
points than the center tile; any tile below the minimum aborts the
whole projection.
center_x/center_y/shift_x/shift_y are deliberately left at their
defaults (0): they only shift the final absolute position, applied
after the FOV/circular-FOV keep-or-discard check, so they can never
affect the resulting count. Computed for the center stitch tile
(stitch_x=stitch_y=0) at angle=0, as a representative estimate --
the real per-projection count can vary a bit with angle/stitch
tile, since the circular FOV crop (tomo_circfov) is checked against
the rotated stage position.
The real scan also draws a fresh random sub-step offset on every
call (see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a
further small amount of per-projection count variation on top of
the angle/stitch dependence above. A fixed seed is used here only so
that this estimate itself is reproducible from one call to the next.
center_x/center_y/shift_x/shift_y don't enter: they only shift the
final absolute position, applied after the FOV/circular-FOV
keep-or-discard check. The real scan also draws a fresh random
sub-step offset on every call, so the actual count can differ by a
few points per angle.
"""
from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan
positions = LamniFermatScan.get_lamni_fermat_spiral_pos(
-abs(self.lamni_piezo_range_x / 2),
abs(self.lamni_piezo_range_x / 2),
-abs(self.lamni_piezo_range_y / 2),
abs(self.lamni_piezo_range_y / 2),
step=self.tomo_shellstep,
spiral_type=0,
center=False,
angle=0.0,
stitch_x=0,
stitch_y=0,
stitch_overlap=self.tomo_stitch_overlap,
fovx=self.lamni_piezo_range_x,
fovy=self.lamni_piezo_range_y,
fov_circular=self.tomo_circfov,
seed=0,
count, _tile = lamni_smallest_tile_position_count(
self.lamni_piezo_range_x,
self.lamni_piezo_range_y,
self.tomo_shellstep,
self.lamni_stitch_x,
self.lamni_stitch_y,
self.tomo_stitch_overlap,
self.tomo_circfov,
)
return len(positions)
return count
@staticmethod
def _fermat_min_positions() -> int:
@@ -2435,6 +2668,50 @@ class LamNI(
return LamniFermatScan.MIN_POSITIONS
def _print_fermat_position_estimate(self) -> bool:
"""Print the estimated Fermat point count (smallest stitch tile);
True if it is below the scan's minimum, i.e. the scan would abort."""
expected_positions = self._expected_fermat_position_count()
min_positions = self._fermat_min_positions()
label = "Estimated Fermat scan points"
if self.lamni_stitch_x or self.lamni_stitch_y:
label += " (smallest stitch tile)"
if expected_positions < min_positions:
print(
f"{label}: {expected_positions} (WARNING: below the minimum of"
f" {min_positions} -- the scan will abort when run)"
)
return True
print(f"{label}: {expected_positions}")
return False
def _prompt_scan_geometry(self):
"""tomo_parameters_cli() prompts that determine the Fermat point count."""
self.tomo_shellstep = self._get_val(
"<step size> um", self.tomo_shellstep, float, min_=0.025, max_=20.0
)
self.lamni_piezo_range_x = self._get_val(
"<piezo range X (max 80)> um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9
)
self.lamni_piezo_range_y = self._get_val(
"<piezo range Y (max 80)> um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9
)
# min 0: a negative stitch makes stitch_tile_offsets() empty, so a
# projection would silently run zero scans. stitch n gives n+1 tiles.
# max 50 = the GUI's range.
self.lamni_stitch_x = self._get_val(
"<stitch X> (n+1 tiles)", self.lamni_stitch_x, int, min_=0, max_=50
)
self.lamni_stitch_y = self._get_val(
"<stitch Y> (n+1 tiles)", self.lamni_stitch_y, int, min_=0, max_=50
)
self.tomo_stitch_overlap = self._get_val(
"<stitch overlap> um", self.tomo_stitch_overlap, float, min_=0.0, max_=10.0
)
self.tomo_circfov = self._get_val(
"<circular FOV> um", self.tomo_circfov, float, min_=0.0, max_=500.0
)
def tomo_parameters_cli(self):
"""Print and interactively update the tomo parameters on the command line.
tomo_parameters() opens the GUI version."""
@@ -2445,18 +2722,13 @@ class LamNI(
f"Piezo range (max 80) <microns> = {self.lamni_piezo_range_x},"
f" {self.lamni_piezo_range_y}"
)
print(f"Stitching number x,y = {self.lamni_stitch_x}, {self.lamni_stitch_y}")
print(
f"Stitching number x,y = {self.lamni_stitch_x}, {self.lamni_stitch_y}"
f" ({self.lamni_stitch_x + 1} x {self.lamni_stitch_y + 1} tiles)"
)
print(f"Stitching overlap = {self.tomo_stitch_overlap}")
print(f"Circular FOV diam <microns> = {self.tomo_circfov}")
expected_positions = self._expected_fermat_position_count()
min_positions = self._fermat_min_positions()
if expected_positions < min_positions:
print(
f"Estimated Fermat scan points per projection: {expected_positions} "
f"(WARNING: below the minimum of {min_positions} -- the scan will abort when run)"
)
else:
print(f"Estimated Fermat scan points per projection: {expected_positions}")
too_few_positions = self._print_fermat_position_estimate()
print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}")
print(f"Frames per trigger (burst) = {self.frames_per_trigger}")
print("FOV offset rotates to find the ROI; initial values determined in Xrayeye alignment.")
@@ -2465,6 +2737,8 @@ class LamNI(
print(f" _tomo_fovy_offset <mm> = {self.tomo_fovy_offset:.4f}")
print(f" _manual_shift_x <mm> = {self.manual_shift_x:.4f}")
print(f" _manual_shift_y <mm> = {self.manual_shift_y:.4f}")
print(f" _loptz_shift_x <mm> = {self.loptz_shift_x:.4f} (queue loptz moves)")
print(f" _loptz_shift_y <mm> = {self.loptz_shift_y:.4f}")
if self.at_each_angle_hook:
print(f"At-each-angle hook = {self._describe_active_hook()}")
print("")
@@ -2503,36 +2777,23 @@ class LamNI(
self._print_angle_file_summary()
print(f"\nSample name: {self.sample_name}\n")
if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"):
if too_few_positions:
print("Too few Fermat scan points -- the scan parameters have to be adjusted.")
elif self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"):
print("OK. continue.")
return
self.tomo_countingtime = self._get_val(
"<ctime per frame> s", self.tomo_countingtime, float, min_=0.001, max_=100.0
)
self.tomo_shellstep = self._get_val(
"<step size> um", self.tomo_shellstep, float, min_=0.025, max_=20.0
)
self.lamni_piezo_range_x = self._get_val(
"<piezo range X (max 80)> um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9
)
self.lamni_piezo_range_y = self._get_val(
"<piezo range Y (max 80)> um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9
)
# min 0: a negative stitch makes range(-n, n + 1) empty, so a
# projection would silently run zero scans. max 50 = the GUI's range.
self.lamni_stitch_x = self._get_val(
"<stitch X>", self.lamni_stitch_x, int, min_=0, max_=50
)
self.lamni_stitch_y = self._get_val(
"<stitch Y>", self.lamni_stitch_y, int, min_=0, max_=50
)
self.tomo_stitch_overlap = self._get_val(
"<stitch overlap> um", self.tomo_stitch_overlap, float, min_=0.0, max_=10.0
)
self.tomo_circfov = self._get_val(
"<circular FOV> um", self.tomo_circfov, float, min_=0.0, max_=500.0
)
while True:
self._prompt_scan_geometry()
if not self._print_fermat_position_estimate():
break
print(
"Too few Fermat scan points: reduce the step size or the piezo range,"
" or adjust stitching/circular FOV."
)
self.ptycho_reconstruct_foldername = self._get_val(
"Reconstruction queue", self.ptycho_reconstruct_foldername, str
)
@@ -2580,11 +2841,11 @@ class LamNI(
elif self.tomo_type == 2:
self.golden_ratio_bunch_size = self._get_val(
"Number of projections sorted per bunch (minimum 20)",
"Number of projections sorted per bunch (30 to 200)",
self.golden_ratio_bunch_size,
int,
min_=20,
max_=100,
min_=30,
max_=200,
)
self.golden_max_number_of_projections = self._get_val(
"Stop after number of projections (0 for endless)",
@@ -64,10 +64,12 @@ class LamNIAlignmentMixin:
# ------------------------------------------------------------------
def reset_correction(self):
"""Reset the look-up-table corrections to empty (iteration 1)."""
"""Reset the look-up-table corrections to empty (iterations 1 and 2),
same as FlOMNI's reset_correction()."""
self.corr_pos_x = []
self.corr_pos_y = []
self.corr_angle = []
self.reset_correction_2()
def reset_correction_2(self):
"""Reset the second iteration look-up-table correction to empty."""
@@ -90,6 +92,8 @@ class LamNIAlignmentMixin:
existing.append("X-ray eye alignment fit")
if self.corr_pos_x:
existing.append("fine-alignment correction")
if self.corr_pos_x_2:
existing.append("fine-alignment correction 2")
return existing
# ------------------------------------------------------------------
@@ -102,8 +106,8 @@ class LamNIAlignmentMixin:
# to be the same data, just the same persistence pattern.
#
# No default correction file exists for LamNI (unlike FlOMNI's
# default_correction_file): reset_correction()/reset_correction_2() just
# clear to empty, nothing is auto-loaded.
# default_correction_file): reset_correction() clears both iterations,
# reset_correction_2() only the second, nothing is auto-loaded.
# ------------------------------------------------------------------
@property
@@ -15,6 +15,19 @@ scans = builtins.__dict__.get("scans")
def umv(*args):
return scans.umv(*args, relative=False)
def compute_osa_remaining_space(
loptz_val: float, losaz_val: float, collision_offset: float
) -> float:
"""Clearance (mm) between the OSA and a normal LamNI sample holder for a
given loptz/losaz pair. <=0 means collision.
Module-level so the tomo-queue GUI (tomo_params.py, RPC device access
only) and the queue action can reuse the exact formula lfzp_info()
prints. collision_offset is loptz's "collision_offset" userParameter.
"""
return collision_offset - loptz_val - losaz_val
class LamNIInitError(Exception):
pass
@@ -665,8 +678,8 @@ class LamNIOpticsMixin:
tol_osa = 0.010 # mm, 10 microns
remaining_current = -losaz_val + (collision_offset - loptz_val)
remaining_at_in = -losaz_in + (collision_offset - loptz_val)
remaining_current = compute_osa_remaining_space(loptz_val, losaz_val, collision_offset)
remaining_at_in = compute_osa_remaining_space(loptz_val, losaz_in, collision_offset)
print(f" Current losaz {losaz_val:.1f}")
print(
@@ -404,6 +404,7 @@ class XrayEyeAlign:
self.lamni.reset_xray_eye_correction()
self.lamni.manual_shift_x = 0
self.lamni.manual_shift_y = 0
self.lamni.reset_loptz_shift()
self.gui.enable_submit_button(False)
@@ -903,11 +904,11 @@ class XrayEyeAlign:
print(f"[rotation-center] at {self.get_tomo_angle():.1f} deg")
print("[rotation-center] live sweep done")
# --- EXPERIMENTAL: rotation-center smear mode -------------------------
# --- rotation-center smear mode ---------------------------------------
# Min- or max-projection "star-trail" composite aid for the extended-sample
# rotation-center click, built while lsamrot rotates continuously.
# Operator-visual only -- no automatic circle fitting. See
# find_rotation_center_smear_experimental() below for the entry point.
# find_rotation_center_smear() below for the entry point.
def _push_smear_composite(self, composite: np.ndarray):
"""Publish the current composite to the GUI's live view.
@@ -979,7 +980,7 @@ class XrayEyeAlign:
"""
accumulator = SmearComposite(projection=projection, opaque_level=opaque_level)
print(
f"[rotation-center][smear] EXPERIMENTAL feature: {projection}-projection composite "
f"[rotation-center][smear] {projection}-projection composite "
"aid, operator-visual only -- no automatic circle fitting."
)
if not dev.cam_xeye.live_mode_enabled.get():
@@ -1054,10 +1055,10 @@ class XrayEyeAlign:
# Note: the GUI stays on the "smear_preview" channel (showing this
# composite) until the caller has collected the operator's click --
# switching back to "image" is the caller's responsibility (see
# find_rotation_center_smear_experimental).
# find_rotation_center_smear).
return composite
def find_rotation_center_smear_experimental(
def find_rotation_center_smear(
self,
keep_shutter_open: bool = False,
apply: bool = True,
@@ -1065,7 +1066,7 @@ class XrayEyeAlign:
projection: str = "min",
opaque_level: float = 0.25,
):
"""EXPERIMENTAL: extended-sample rotation-center calibration aided
"""Extended-sample rotation-center calibration aided
by a continuous-rotation min- or max-projection composite (see
_smear_sweep()), instead of a single instant of live rotation.
@@ -1110,7 +1111,7 @@ class XrayEyeAlign:
dev.omny_xray_gui.submit.set(0)
print(
"[rotation-center][smear] starting find_rotation_center_smear_experimental"
"[rotation-center][smear] starting find_rotation_center_smear"
f"(sweep_deg={sweep_deg!r}, projection={projection!r}, "
f"opaque_level={opaque_level!r})"
)
@@ -1265,7 +1266,7 @@ class XrayEyeAlign:
f.attrs["opaque_level"] = opaque_level
print(f"[rotation-center][smear] saved calibration record to {expanded}")
# --- end EXPERIMENTAL: rotation-center smear mode ---------------------
# --- end rotation-center smear mode -----------------------------------
def _ensure_at_configured_center(self, tol: float = 0.003):
"""Move lsamx/lsamy to their currently-configured center if they
@@ -1379,7 +1380,7 @@ class XrayEyeAlign:
previous centre.
Called right after a new lsamx/lsamy centre is actually applied (by
both find_rotation_center() and find_rotation_center_smear_experimental()).
both find_rotation_center() and find_rotation_center_smear()).
Reuses the LamNI alignment mixin's own reset methods rather than
introducing separate invalidation logic -- see
lamni.xrayeye_alignment_start()/tomo_alignment_scan()/tomo_scan(),
@@ -0,0 +1,59 @@
"""Idle-time bookkeeping for the tomo ETA, shared by LamNI and FlOMNI.
The ETA divides the projections done by the elapsed time minus
``accumulated_idle_time``. A gap between two projections only counts as idle
when it is much longer than the *measured* typical gap of this scan. An
earlier model based on the per-point exposure time counted most of every
normal projection (a whole ptycho scan, minutes long) as idle, which made the
ETA several times too short.
"""
from __future__ import annotations
import datetime
import statistics
# number of recent normal gaps kept to estimate the typical cadence
MAX_GAP_SAMPLES = 10
# below this many samples nothing is counted as idle yet
MIN_GAP_SAMPLES = 3
# a gap this long at the start of a scan is a pause, not a cadence sample
STARTUP_PAUSE_S = 30 * 60
# a gap is idle when it exceeds max(IDLE_FACTOR * typical, typical + IDLE_MARGIN_S)
IDLE_FACTOR = 3.0
IDLE_MARGIN_S = 300.0
def account_projection_gap(progress, now: datetime.datetime) -> float:
"""Account the gap since the last projection and refresh the heartbeat.
Args:
progress: the tomo progress mapping (``lamni.progress``/``flomni.progress``),
read and written via ``get``/item assignment.
now: time of the projection that is about to start.
Returns:
float: the idle time (s) added to ``accumulated_idle_time``, 0 if none.
"""
prev_heartbeat_str = progress.get("heartbeat")
progress["heartbeat"] = now.isoformat()
if prev_heartbeat_str is None:
return 0.0
gap = (now - datetime.datetime.fromisoformat(prev_heartbeat_str)).total_seconds()
gaps = list(progress.get("recent_projection_gaps") or [])
idle = 0.0
if len(gaps) < MIN_GAP_SAMPLES:
if gap <= STARTUP_PAUSE_S:
gaps.append(gap)
else:
typical = statistics.median(gaps)
if gap > max(IDLE_FACTOR * typical, typical + IDLE_MARGIN_S):
idle = gap - typical
progress["accumulated_idle_time"] = progress.get("accumulated_idle_time", 0.0) + idle
else:
gaps.append(gap)
progress["recent_projection_gaps"] = gaps[-MAX_GAP_SAMPLES:]
return idle
@@ -35,6 +35,56 @@ def umvr(*args):
return scans.umv(*args, relative=True)
def stitch_tile_offsets(n: int) -> list[float]:
"""Tile offsets, in units of (fov - stitch_overlap), for a stitch
parameter n: n+1 tiles placed symmetrically around the scan center.
n=0 -> [0], n=1 -> [-0.5, 0.5], n=2 -> [-1, 0, 1]."""
return [i - n / 2 for i in range(n + 1)]
def lamni_smallest_tile_position_count(
piezo_range_x: float,
piezo_range_y: float,
step: float,
stitch_x: int,
stitch_y: int,
stitch_overlap: float,
fov_circular: float,
) -> tuple[int, tuple[float, float]]:
"""Fermat point count of the stitch tile with the fewest points, and that
tile's offsets. With a circular FOV the corner tiles can hold far fewer
points than the center one, and any tile below the scan's minimum aborts
the whole projection. Evaluated at angle 0: the per-tile count only
varies by a few points with angle (random sub-step offset), and seed=0
keeps the estimate reproducible."""
from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan
counts = {}
for tile_x in stitch_tile_offsets(stitch_x):
for tile_y in stitch_tile_offsets(stitch_y):
counts[(tile_x, tile_y)] = len(
LamniFermatScan.get_lamni_fermat_spiral_pos(
-abs(piezo_range_x / 2),
abs(piezo_range_x / 2),
-abs(piezo_range_y / 2),
abs(piezo_range_y / 2),
step=step,
spiral_type=0,
center=False,
angle=0.0,
stitch_x=tile_x,
stitch_y=tile_y,
stitch_overlap=stitch_overlap,
fovx=piezo_range_x,
fovy=piezo_range_y,
fov_circular=fov_circular,
seed=0,
)
)
tile = min(counts, key=counts.get)
return counts[tile], tile
def read_escape_suffix(stream, timeout: float = 0.05) -> str | None:
"""The two characters after an ESC on a non-blocking stdin (arrow keys: "[A".."[D"),
or None if they do not arrive within `timeout` (ESC alone, or a slow terminal).
@@ -168,6 +168,39 @@ class _TomoQueueProxy:
return self._load()[index]
def format_command_step(step: dict, registry: dict | None = None) -> str:
"""One command-job step as ``action(name=value unit, ...)``.
Units come from the action's param schema (``unit``) or, for the
"move" action's device_positions, from the move allow-list -- so the
CLI (tomo_queue_show) and the GUI queue table never show a bare number
whose unit (mm vs. um) has to be guessed. ``registry`` is the
``tomo_queue_actions`` dict (see _describe_tomo_queue_actions()); without
it, values are shown without units.
"""
kwargs = step.get("kwargs") or {}
if not kwargs:
return step["action"]
registry = registry or {}
schema = registry.get("actions", {}).get(step["action"], {}).get("params", {})
move_devices = registry.get("move_devices", {})
def _with_unit(value, unit):
return f"{value} {unit}" if unit else f"{value}"
parts = []
for name, value in kwargs.items():
field = schema.get(name, {})
if field.get("type") == "device_positions" and isinstance(value, dict):
parts.extend(
f"{dev_name}={_with_unit(target, move_devices.get(dev_name, {}).get('unit'))}"
for dev_name, target in value.items()
)
else:
parts.append(f"{name}={_with_unit(value, field.get('unit'))}")
return f"{step['action']}({', '.join(parts)})"
class _GlobalVarParam:
"""Descriptor for a parameter backed by a BEC global variable.
@@ -816,11 +849,11 @@ class TomoQueueMixin:
if not jobs:
print("Tomo queue is empty.")
return jobs
registry = self._describe_tomo_queue_actions()
for i, job in enumerate(jobs):
if job.get("kind", "tomo") == "command":
steps_str = " > ".join(
f"{step['action']}{step['kwargs']}" if step["kwargs"] else step["action"]
for step in job["steps"]
format_command_step(step, registry) for step in job["steps"]
)
idem = "idem" if job.get("idempotent") else "NOT idem"
print(f"[{i}] {job['status']:>10s} {job['label']:<20s} CMD {steps_str} [{idem}]")
@@ -1175,6 +1208,7 @@ class TomoQueueMixin:
self.progress["estimated_remaining_time"] = None
self.progress["estimated_finish_time"] = None
self.progress["accumulated_idle_time"] = 0.0
self.progress["recent_projection_gaps"] = []
self.progress["heartbeat"] = None
# flOMNI: a completed scan is not resumed (tomo_scan_resume); reopened here on purpose
self.progress["tomo_completed"] = False
@@ -136,6 +136,10 @@ _MISMATCH_LOCAL_PORT = 8081
# Subfolders follow the pattern dset_NNNNN and contain S#####_*_phase.png etc.
_PTYCHO_ONLINE_DIR = "~/data/raw/analysis/online/ptycho"
_PTYCHO_THUMB_PX = 200 # longest edge of generated thumbnail in pixels
# The upload server (omny.psi.ch/upload.php) rejects large POSTs with HTTP 413;
# stitched reconstructions produce full-size PNGs above that. Full-size copies
# larger than this are downscaled until they fit.
_PTYCHO_FULL_MAX_BYTES = 900_000
VERBOSITY_SILENT = 0
VERBOSITY_NORMAL = 1
@@ -1324,7 +1328,7 @@ class WebpageGeneratorBase:
img.save(thumb_path, "JPEG", quality=85)
else:
shutil.copy2(src, thumb_path)
shutil.copy2(src, full_path)
self._copy_full_capped(src, full_path)
images.append({
"role": role,
"thumb": thumb_name,
@@ -1339,6 +1343,26 @@ class WebpageGeneratorBase:
self._last_ptycho_scan = scan_id
return {"scan_id": scan_id, "images": images, "folder": str(latest_dir)}
@staticmethod
def _copy_full_capped(src: Path, dst: Path, max_bytes: int = _PTYCHO_FULL_MAX_BYTES) -> None:
"""Copy src to dst; if src is larger than max_bytes, save a downscaled
PNG instead (halving the pixel area each step) so the upload server
accepts it. Without Pillow, or if it can't get small enough, the
plain/smallest copy is kept."""
if src.stat().st_size <= max_bytes or not _PIL_AVAILABLE:
shutil.copy2(src, dst)
return
img = _PILImage.open(src)
img.load()
w, h = img.size
scale = 1.0
while True:
scale *= 0.7071
size = (max(1, int(w * scale)), max(1, int(h * scale)))
img.resize(size, _PILImage.LANCZOS).save(dst, "PNG", optimize=True)
if dst.stat().st_size <= max_bytes or min(size) <= 64:
return
# ------------------------------------------------------------------
# Subclass hooks
# ------------------------------------------------------------------
@@ -31,9 +31,12 @@ class cSAXS(
self.device_manager = client.device_manager
self.OMNYTools = OMNYTools(self.client)
self.diagnostics = cSAXSDiagnostics()
self.diagnostics = cSAXSDiagnostics(client)
super().__init__(client=client)
# EPICS does not keep the slit offsets reliably; restore them from config
self.slits_apply_epics_offsets()
# ------------------------------------------------------------------
# Undulator
# ------------------------------------------------------------------
@@ -98,10 +101,14 @@ class cSAXS(
for access, desc in [
("csaxs.diagnostics.show_all()", "All diagnostic device readbacks"),
(
"csaxs.diagnostics.bpm_xbox1 / .bpm_xbox2",
"csaxs.diagnostics.show_plots(*extra)",
"Plot window: XBPM3 blades + XBIM; extra e.g. 'xbpm3_position', 'beamstop'",
),
(
"csaxs.diagnostics.xbpm2 / .xbpm3",
"BPM diagnostics — .show_all(), .gain(val)",
),
("csaxs.diagnostics.bim", "BIM diagnostics — .show_all(), .gain(val)"),
("csaxs.diagnostics.xbim", "XBIM diagnostics — .show_all(), .gain(val)"),
("csaxs.diagnostics.beamstop", "Beamstop diode — .show_all(), .gain(val)"),
("csaxs.diagnostics.polarization", "Polarization diodes — .show_all(), .gain(val)"),
("csaxs.OMNYTools.*", "OMNY instrument tools"),
@@ -1,5 +1,6 @@
from __future__ import annotations
import builtins
import time
# ------------------------------------------------------------
# Access to dev (as before)
@@ -167,28 +168,53 @@ class BPMDiag:
class cSAXSDiagnostics:
"""User-visible diagnostics namespace."""
def __init__(self):
PLOT_WINDOW_NAME = "csaxs_diagnostics"
GUI_RPC_TIMEOUT = 20
_BLADES = ["left_top", "right_top", "left_bot", "right_bot"]
# name -> (plot title, [(device, signal suffix), ...]); one waveform plot
# each, curves of devices missing from the config are skipped
PLOTS = {
"xbpm3": ("XBPM3 blades (ES hutch, XBox 2)", [("xbpm3_slowrb", b) for b in _BLADES]),
"xbim": ("XBIM (ES hutch, XBox 3)", [("xbim_slowrb", "signal")]),
"xbpm3_position": (
"XBPM3 position",
[("xbpm3_slowrb", "pos_x"), ("xbpm3_slowrb", "pos_y")],
),
"xbpm2": ("XBPM2 blades (OP hutch, XBox 1)", [("xbpm2_slowrb", b) for b in _BLADES]),
"beamstop": ("Beamstop diode (flight tube)", [("beamstop_intensity", "signal")]),
"polarization": (
"Polarization diodes (OP hutch, XBox 1)",
[
("diode_horizontal_xbox1_slowrb", "signal"),
("diode_vertical_xbox1_slowrb", "signal"),
],
),
}
DEFAULT_PLOTS = ("xbpm3", "xbim")
def __init__(self, client=None):
self._client = client
# BPMs
self.bpm_xbox1 = BPMDiag(
name="bpm_xbox1",
label="BPM XBox1 (OP hutch)",
gain_dev=_safe_dev("gain_bpm_xbox1"),
rb_dev=_safe_dev("bpm_xbox1_slowrb"),
self.xbpm2 = BPMDiag(
name="xbpm2",
label="XBPM2 (OP hutch, XBox 1)",
gain_dev=_safe_dev("xbpm2_gain"),
rb_dev=_safe_dev("xbpm2_slowrb"),
)
self.bpm_xbox2 = BPMDiag(
name="bpm_xbox2",
label="BPM XBox2 (ES hutch)",
gain_dev=_safe_dev("gain_bpm_xbox2"),
rb_dev=_safe_dev("bpm_xbox2_slowrb"),
self.xbpm3 = BPMDiag(
name="xbpm3",
label="XBPM3 (ES hutch, XBox 2)",
gain_dev=_safe_dev("xbpm3_gain"),
rb_dev=_safe_dev("xbpm3_slowrb"),
)
# Single-signal diagnostics
self.bim = SingleSignalDiag(
name="bim",
label="BIM XBox3 (ES)",
gain_dev=_safe_dev("gain_bim_xbox3"),
signal_dev=_safe_dev("bim_xbox3_slowrb"),
self.xbim = SingleSignalDiag(
name="xbim",
label="XBIM (ES hutch, XBox 3)",
gain_dev=_safe_dev("xbim_gain"),
signal_dev=_safe_dev("xbim_slowrb"),
)
self.beamstop = SingleSignalDiag(
@@ -213,8 +239,54 @@ class cSAXSDiagnostics:
print("CSAXS diagnostics")
print("=================\n")
self.bpm_xbox1.show_all()
self.bpm_xbox2.show_all()
self.bim.show_all()
self.xbpm2.show_all()
self.xbpm3.show_all()
self.xbim.show_all()
self.beamstop.show_all()
self.polarization.show_all()
self.polarization.show_all()
def show_plots(self, *extra: str, defaults: bool = True):
"""Open a BEC GUI window with waveform plots of the beam diagnostics,
e.g. for an ID gap scan. By default the XBPM3 blade signals and the
XBIM signal; further plots by name, e.g.
show_plots("xbpm3_position", "beamstop"), or only the named ones with
defaults=False. Calling it again rebuilds the plots in the open window."""
unknown = [p for p in extra if p not in self.PLOTS]
if unknown:
print(f"Unknown plot(s) {unknown}. Available: {', '.join(self.PLOTS)}")
return
gui = getattr(self._client, "gui", None)
if gui is None:
print("No BEC GUI is available in this session.")
return
names = list(self.DEFAULT_PLOTS) if defaults else []
names += [p for p in extra if p not in names]
plots = []
for plot_name in names:
title, curves = self.PLOTS[plot_name]
curves = [(d, s) for d, s in curves if _safe_dev(d) is not None]
if curves:
plots.append((plot_name, title, curves))
else:
print(f"Skipping plot '{plot_name}': its devices are not in the current config.")
if not plots:
return
name = self.PLOT_WINDOW_NAME
if name in gui.windows:
window = getattr(gui, name)
window.delete_all(timeout=self.GUI_RPC_TIMEOUT)
else:
gui.new(name, timeout=self.GUI_RPC_TIMEOUT)
time.sleep(1)
window = getattr(gui, name)
for plot_name, title, curves in plots:
waveform = window.new(
"Waveform", object_name=f"{plot_name}_plot", timeout=self.GUI_RPC_TIMEOUT
)
waveform.title = title
for device, signal in curves:
waveform.plot(device_y=device, signal_y=f"{device}_{signal}")
others = [p for p in self.PLOTS if p not in names]
if others:
options = ", ".join(repr(p) for p in others)
print(f"More plots: csaxs.diagnostics.show_plots({options})")
@@ -1,7 +1,9 @@
from __future__ import annotations
import builtins
from typing import Optional
import datetime
import os
from typing import Any, Optional
# ------------------------------------------------------------
# Make dev visible if running inside BEC
@@ -29,6 +31,16 @@ class cSAXSSlits:
"sl6": "Slit 6 (exposure box 2)",
}
# device tags of the beamline sections recorded by the full state dump
# (endstation instruments such as LamNI/flOMNI/OMNY carry their own tags)
_BEAMLINE_TAGS = ("machine", "bl_frontend", "bl_optics_hutch", "bl_endstation")
_STATE_DIR = "~/data/raw/logs/beamline_state"
# userParameter holding the EPICS user_offset of a motor; the IOC does not
# keep the offsets reliably, so the client writes them at startup
_EPICS_OFFSET_PARAM = "epics_offset"
def __init__(self, client):
self.client = client
self.device_manager = client.device_manager
@@ -83,14 +95,256 @@ class cSAXSSlits:
print(f"{label}:")
print(
f" x center = {self._sl_fmt(xc)} "
f"x size = {self._sl_fmt(xs)}"
f" {prefix}xc = {self._sl_fmt(xc)} "
f"{prefix}xs = {self._sl_fmt(xs)}"
)
print(
f" y center = {self._sl_fmt(yc)} "
f"y size = {self._sl_fmt(ys)}"
f" {prefix}yc = {self._sl_fmt(yc)} "
f"{prefix}ys = {self._sl_fmt(ys)}"
)
print("")
print("==========================================")
print("")
print("")
def slits_apply_epics_offsets(self) -> dict:
"""
Write the configured EPICS user offsets (userParameter 'epics_offset').
Called at cSAXS client startup. Only devices that define
userParameter 'epics_offset' are touched; the offset is written only
if it differs from the one currently in EPICS. Nothing moves - the
user readback shifts with the offset.
Returns:
dict: {device_name: offset} for every offset that was written.
"""
written = {}
for name, device in self.device_manager.devices.items():
try:
param = (getattr(device, "_config", {}) or {}).get("userParameter") or {}
if self._EPICS_OFFSET_PARAM not in param:
continue
if not getattr(device, "enabled", True):
continue
offset = float(param[self._EPICS_OFFSET_PARAM])
current = float(device.user_offset.get())
if abs(current - offset) < 1e-9:
continue
device.user_offset.put(offset)
written[name] = offset
print(f"{name}: EPICS user offset {current:.6f} -> {offset:.6f} (from config)")
except Exception as exc:
print(f"{name}: could not apply {self._EPICS_OFFSET_PARAM}: {exc}")
return written
# --------------------------------------------------------
# slit calibration
# --------------------------------------------------------
def _sl_read_value(self, device_name: str, cached: bool = False) -> Optional[float]:
"""
Return the readback of a device (fresh read by default) or None.
"""
try:
device = self.device_manager.devices[device_name]
reading = device.read(cached=cached)
return float(reading[device_name]["value"])
except Exception:
return None
def _sl_offset_entry(self, name: str, target: float) -> dict:
"""
Compute the offset that makes the displayed value of slit axis
*name* equal to *target*, assuming the slit is physically where
*target* should be.
EPICS motors (sl1-sl3): user = dir * dial + user_offset
SmarAct virtual slits (sl4-sl6): value = f(blades) + offset (config)
In both cases the displayed value shifts 1:1 with the offset, so
new_offset = offset + (target - displayed).
"""
entry = {"name": name, "target": target, "kind": None, "displayed": None,
"offset": None, "new_offset": None}
try:
device = self.device_manager.devices[name]
except Exception:
return entry
config = getattr(device, "_config", {}) or {}
if "VirtualSlit" in str(config.get("deviceClass", "")):
entry["kind"] = "smaract"
entry["offset"] = float((config.get("deviceConfig") or {}).get("offset", 0) or 0)
else:
entry["kind"] = "epics"
try:
entry["offset"] = float(device.user_offset.get())
except Exception:
entry["offset"] = None
configured = (config.get("userParameter") or {}).get(self._EPICS_OFFSET_PARAM)
entry["configured"] = None if configured is None else float(configured)
entry["displayed"] = self._sl_read_value(name)
if entry["displayed"] is not None and entry["offset"] is not None:
entry["new_offset"] = entry["offset"] + (target - entry["displayed"])
return entry
@staticmethod
def _sl_fmt_value(value: Any) -> str:
if isinstance(value, bool) or value is None:
return str(value)
if isinstance(value, float):
return f"{value:.6g}"
text = repr(value)
return text if len(text) <= 100 else text[:97] + "..."
def _sl_beamline_state_lines(self) -> list[str]:
"""
Dump readings and configuration signals (offsets, limits, ...) of
all enabled devices tagged with one of _BEAMLINE_TAGS.
"""
lines = []
for tag in self._BEAMLINE_TAGS:
try:
devices = self.device_manager.devices.get_devices_with_tags(tag)
except Exception:
devices = []
lines.append("")
lines.append(f"---------- {tag} ----------")
for device in devices:
if not getattr(device, "enabled", True):
continue
lines.append(f"{device.name}")
signals = {}
for fcn in (device.read, device.read_configuration):
try:
reading = fcn(cached=True) or fcn(cached=False) or {}
signals.update(reading)
except Exception as exc:
lines.append(f" <{fcn.__name__} failed: {exc}>")
for sig_name, sig in signals.items():
value = sig.get("value") if isinstance(sig, dict) else sig
lines.append(f" {sig_name:40s} {self._sl_fmt_value(value)}")
return lines
def slits_calibration_report(
self,
full: bool = True,
save: bool = False,
filename: Optional[str] = None,
target_width: float = 1.0,
) -> dict:
"""
Slit offset calibration report (and optional full beamline state dump).
Run with all slits physically centered on the beam and opened to
target_width (default 1 mm). Reports for every slit axis the offset
that makes the center read 0 and the size read target_width:
EPICS slits (sl1-sl3): new user_offset, plus commands to apply it.
SmarAct slits (sl4-sl6): new 'offset' for deviceConfig in bl_endstation.yaml.
Nothing is changed on the hardware or in the config files.
Args:
full: also record all beamline devices (machine, frontend, optics
incl. mirrors/monos, endstation; not LamNI/flOMNI/OMNY).
save: write the report to a text file.
filename: output file; default ~/data/raw/logs/beamline_state/
beamline_state_<timestamp>.txt
target_width: slit opening [mm] the slits are currently set to.
Returns:
dict: {device_name: new_offset} for all slit center/size axes.
Examples:
csaxs.slits_calibration_report()
csaxs.slits_calibration_report(save=True)
csaxs.slits_calibration_report(full=False)
"""
now = datetime.datetime.now()
lines = [
"========== cSAXS Slit Calibration Report ==========",
f"date: {now:%Y-%m-%d %H:%M:%S}",
f"assumed state: all slits centered, opening {target_width:g} mm",
"",
f"{'axis':8s} {'type':8s} {'displayed':>11s} {'offset':>11s}"
f" {'target':>8s} {'new offset':>11s} {'change':>10s}",
]
entries = []
for prefix, label in self._SLIT_LABELS.items():
lines.append(f"{label}")
for suffix, target in (("xc", 0.0), ("xs", target_width), ("yc", 0.0), ("ys", target_width)):
entry = self._sl_offset_entry(f"{prefix}{suffix}", target)
entries.append(entry)
change = (
None if entry["new_offset"] is None else entry["new_offset"] - entry["offset"]
)
lines.append(
f"{entry['name']:8s} {str(entry['kind'] or '---'):8s}"
f" {self._sl_fmt(entry['displayed']):>11s} {self._sl_fmt(entry['offset']):>11s}"
f" {target:8.4f} {self._sl_fmt(entry['new_offset']):>11s}"
f" {self._sl_fmt(change):>10s}"
)
if (
suffix.endswith("s")
and entry["displayed"] is not None
and abs(entry["displayed"] - target) > 0.5 * target_width
):
lines.append(
f" WARNING: {entry['name']} reads {entry['displayed']:.4f}, far"
f" from {target_width:g} mm - is this slit really set up?"
)
lines.append("")
missing = [e["name"] for e in entries if e["new_offset"] is None]
if missing:
lines.append(f"could not compute (device missing/unreadable): {', '.join(missing)}")
lines.append("")
for e in entries:
configured = e.get("configured")
if configured is not None and e["offset"] is not None and abs(configured - e["offset"]) > 1e-9:
lines.append(
f"NOTE: {e['name']} EPICS offset {e['offset']:.6f} differs from configured"
f" {self._EPICS_OFFSET_PARAM} {configured:.6f}"
)
lines.append(
f"EPICS slits - new userParameter '{self._EPICS_OFFSET_PARAM}'"
" (bl_frontend.yaml for sl1, bl_optics_hutch.yaml for sl2/sl3);"
)
lines.append("written to EPICS at client startup or with csaxs.slits_apply_epics_offsets():")
for e in entries:
if e["kind"] == "epics" and e["new_offset"] is not None:
lines.append(f" {e['name']}: {self._EPICS_OFFSET_PARAM}: {e['new_offset']:.6f}")
lines.append("")
lines.append("EPICS slits - to apply immediately in this session:")
for e in entries:
if e["kind"] == "epics" and e["new_offset"] is not None:
lines.append(f" dev.{e['name']}.user_offset.put({e['new_offset']:.6f})")
lines.append("")
lines.append("SmarAct slits - new 'offset' in deviceConfig (bl_endstation.yaml):")
for e in entries:
if e["kind"] == "smaract" and e["new_offset"] is not None:
lines.append(f" {e['name']}: offset: {e['new_offset']:.6f}")
lines.append("")
if full:
lines.append("========== Beamline state (cached readings) ==========")
lines.extend(self._sl_beamline_state_lines())
lines.append("")
text = "\n".join(lines)
print(text)
if save:
if filename is None:
filename = os.path.join(
self._STATE_DIR, f"beamline_state_{now:%Y%m%d_%H%M%S}.txt"
)
filename = os.path.expanduser(filename)
os.makedirs(os.path.dirname(filename) or ".", exist_ok=True)
with open(filename, "w", encoding="utf-8") as fh:
fh.write(text + "\n")
print(f"Report written to {filename}")
return {e["name"]: e["new_offset"] for e in entries}
@@ -86,7 +86,7 @@ sim session you started by hand.
**Do not use `bec_client_lib_with_demo_config` / `bec_ipython_client_with_demo_config`
as they ship.** Both call `bec.config.load_demo_config(force=True)` internally.
Against a running sim session, this **overwrites the loaded simulated device
config** (e.g. `simulated_flomni.yaml`) with BEC's own generic demo devices —
config** (e.g. `simulated_bl_simulated_flomni.yaml`) with BEC's own generic demo devices —
silently breaking every test that assumes your plugin's real devices exist.
Instead, write your own fixture that reuses the lower-level plumbing those
@@ -0,0 +1,250 @@
# flOMNI tomography: where the time goes (scan overhead analysis)
Written 2026-10-15 from the BEC logs of the flOMNI beamtime of **2026-10-07 08:46 to
2026-10-12 ~09:30** (setup changed to LamNI on 2026-10-12). Nothing proposed here has been
implemented yet. This is a findings + ideas document to pick the work up later.
The code analysed is the production deployment that ran during that beamtime. It is identical to
the current one: `flomni_fermat_scan.py` and `rt_flomni_ophyd.py` were diffed against
`old_production_deployments/20261012T202711` with no differences.
Data set: 10,403 completed + 365 aborted `flomni_fermat_scan`s, 120.8 h of wall time.
Sources: `/var/log/bec/{ScanServer,DeviceServer,ScanBundler,FileWriterManager}.*.log.gz`.
## 1. Time budget (120.8 h)
| Bucket | Hours | Share |
|---|---|---|
| Detector exposure (num_points × exp_time × frames_per_trigger) | 67.0 | 55 % |
| Per-point dead time inside the scan | ~19.5 | 16 % |
| Fixed per-scan setup/teardown inside open→closed (~4 s/scan) | ~11 | 9 % |
| Beam/shutter problems: aborted scans (3.9 h) + waiting for beam (6.4 h) | ~10.3 | 8.5 % |
| Long pauses without beam problem (manual work, `acquire` alignment, between tomograms) | ~7 | 6 % |
| Gaps between back-to-back scans (closed → next open, ~1 s) | 2.8 | 2.5 % |
| Eiger alarms after aborts, misc | ~1 | 1 % |
- Interlocks: 106 "abort and repeat" restarts, mostly `shutter_es_device_within_limits_state`
and `shutter_device_within_limits_state`.
- 314 `EigerError` alarms on `eiger2_si_500k`.
- Worst single gap: 3 h starting 2026-10-10 07:33 (beam/interlock).
"Fixed per-scan cost" plus "gaps between scans" together make up the scan-to-scan dead time
analysed in §3. The split between them is only the ScanServer's open/closed bookkeeping.
## 2. Per-point dead time (largest loss overall, cause not yet understood)
Real time per point, measured as open→closed / num_points:
| exp_time × frames | nominal s/pt | real s/pt | dead time/pt | relative |
|---|---|---|---|---|
| 0.01 × 20 (n=8065) | 0.20 | 0.268 | 68 ms | +34 % |
| 0.1 × 1 (n=1633) | 0.10 | 0.197 | 97 ms | +97 % |
| 0.05 × 1 (n=656) | 0.05 | 0.143 | 93 ms | +186 % |
These numbers still include the ~4 s fixed cost per scan spread over the points. The cadence of
`Read i out of N` during the scan gives 0.229 s/pt (mean) for 0.01×20, so the pure per-point
cost is **~30–50 ms**. It is roughly independent of exposure time, which points to a fixed
step/settle/trigger dead time in the RT controller point scan (`sd` command) or the trigger
chain (ddg1 external rising edge → Eiger). The BEC logs cannot resolve it. It needs RT
controller-side timing or a scope on the trigger lines.
## 3. Scan-to-scan dead time (detailed)
**True dead time from the last exposed point of projection N to the first exposed point of
N+1: 3.2 s median, 3.6 s mean.** About 10 h over the beamtime.
Mean timestamps, relative to the last `Read i out of N` of the previous scan, over 9,876
back-to-back transitions (gap < 5 s):
| t [s] | Event (log marker) | Step duration | What happens / code |
|---|---|---|---|
| 0.00 | last `Read i out of N` (DeviceServer) | | readout thread `read_positions_from_sampler` finishes, `rt_flomni_ophyd.py:862` |
| 0.01 | `Flomni statistics` | | status of `complete(rt_positions)` set finished |
| 0.70 | MCS `Current data index … complete` | **0.69** | ScanServer still in the `at_each_point` `time.sleep(1)` loop (§5.1), then `post_scan`: rtx/rty/rtz move to end position + `complete_all_devices` |
| 1.16 | ScanServer `closed` | 0.46 | `post_scan` ddg1 → single shot, `unstage`, `close_scan` waits baseline readout |
| 2.17 | FileWriter `Finished writing file` | (1.0, in parallel) | does **not** block the next scan |
| 1.27 | client RPC `flomni_samples.sample_names.sample0.get` | | client loop between projections |
| 1.30 | client RPC `fsamroy.read` | | |
| 1.46 | client RPC `rtx.controller.laser_tracker_check_signalstrength` (queued as device_rpc) | | `flomni.py:3661` |
| 1.62 | ScanServer `Receiving scan request: flomni_fermat_scan` | **0.46 client side** | |
| 1.71 | `Fermat spiral sub-step offset` | | `prepare_scan` positions + corridor optimizer (3 ms) |
| 1.72 | DeviceServer `flomni scan stopped and deleted` | | `prepare_setup` → `clear_trajectory_generator` |
| 1.89 | `Rotating to requested angle` | | `flomni_rotation`, starts fsamroy move without waiting |
| 2.11 | ScanServer `open` | 0.49 since request | `prepare_scan` incl. rty move (`actions.set` waits) + baseline read |
| 2.16 | MCS on_stage done (0.05 s) | | `stage_all_devices` |
| 2.36 | ddg1 on_stage done (0.22 s) | | |
| 2.39 | Eiger `staged for scan` | | Eiger stage median 0.24 s, **mean 0.60 s** (long tail) |
| 2.66 | fupr `Move finished` | rotation ~0.8 s | `prepare_setup_part_2` waits for rotation |
| 3.15 | `Laser tracker running!` | **0.5** | `laser_tracker_on` → `laser_tracker_wait_on_target`; 0.87 s when it has to wait (28 % of scans, 0.5 s poll) |
| 3.29 | `Sending N positions took …` (0.15 s, thread) | | `add_pos_to_scan`, second `laser_tracker_check_signalstrength`, `move_samx_to_scan_region` |
| ~3.4 | first point exposed | | `kickoff`: wait buffer, `start_scan`, `sleep(0.1)` |
| (4.95) | first `Read 0 out of N` logged | | logged ~6 points late, see §4 |
Rotation vs angle step (median Δangle between projections is 1.5°):
| Δangle | n | rotation (stopdel→Move finished) | Ra→R0 (uncorrected, includes the ~1.5 s Read-0 offset) |
|---|---|---|---|
| < 1° | 1186 | 0.63 s | 4.65 s |
| 1–2° | 4818 | 0.76 s | 4.71 s |
| 2–5° | 2130 | 0.87 s | 4.78 s |
| 5–20° | 1497 | 1.25 s | 5.34 s |
| > 20° | 245 | 4.76 s | 10.32 s |
For typical small steps the rotation largely overlaps staging and is not the bottleneck. The
dead time is dominated by serial bookkeeping: end-of-scan polling, close, client round-trip,
prepare, open, stage, then laser tracker.
Precise sub-second numbers taken from the log text itself:
- Eiger stage: 0.596 mean / 0.240 median (0.21 s of it JungfrauJoch communication).
- ddg1 stage: 0.221 s.
- MCS stage: 0.052 s.
- Sending positions: 0.147 s.
- FileWriter master file: 1.0 s.
## 4. Method and pitfalls (for re-running, e.g. on LamNI)
- **Timestamps only have 1 s resolution.** Averaging `floor(t2) - floor(t1)` over thousands of
scans is unbiased, so use means; medians of individual differences are meaningless at this
resolution.
- **`Read 0 out of N` is logged late.** `read_positions_from_sampler` only starts reading once
`current_position_in_scan > 5` (`rt_flomni_ophyd.py:885`) and lags one point behind. True
scan start ≈ t(Read i₀) − (i₀ + 6) × per-point time, with the per-point time taken from the
Read cadence. Without this correction the gap looks ~1.5 s longer.
- At the end, after the controller mode drops to 0, the remaining points are read immediately,
so the last `Read` line ≈ the real end of exposure.
- **Markers used:**
- ScanServer: `New scan status: <id> / open|closed|aborted` (also carries `num_points`,
`exp_time`, `frames_per_trigger`, `angle`), `Receiving scan request: {'scan_type':
'flomni_fermat_scan'`, the client device_rpc requests, `Rotating to requested angle`,
`Requesting to abort and repeat`, `Alarm (MAJOR)`, `Beamline state … out of bounds`.
- DeviceServer: `Read i out of N`, `Flomni statistics`, `Current data index`,
`flomni scan stopped and deleted`, `on_stage completed in`, `staged for scan. Time spent`,
fupr `Move finished`, `Waiting for laser tracker`, `Laser tracker running`,
`positions took`.
- ScanBundler: `Sending point 0 for`. FileWriterManager: `[SUCCESS] | Finished writing file`.
- Use only consecutive *completed* scans with closed→open < 5 s for the transition analysis.
Larger gaps are user pauses, alignment or interlocks.
Minimal snippet that rebuilds the §3 timeline (run from `/var/log/bec`, extend the marker list
as needed):
```python
import gzip, glob, re, bisect, statistics as st, datetime as dt
from collections import defaultdict
T = lambda s: dt.datetime.strptime(s, "%Y-%m-%d %H:%M:%S").timestamp()
files = lambda p: sorted(glob.glob(f"{p}.2026-10-0[7-9]*.gz") + glob.glob(f"{p}.2026-10-1[0-2]*.gz"))
scans, ev = {}, defaultdict(list)
for f in files("ScanServer"):
for l in gzip.open(f, "rt", errors="replace"):
if l.startswith("2026") and "New scan status:" in l and "'flomni_fermat_scan'" in l:
m = re.search(r"New scan status: (\S+) / (\w+) /", l)
scans.setdefault(m[1], {}).setdefault(m[2], T(l[:19]))
markers = {"read": " out of ", "mcs": "Current data index", "eig": "staged for scan",
"rot": "fupr_ophyd | [INFO] | Move finished", "lt": "Laser tracker running",
"send": "positions took"}
for f in files("DeviceServer"):
for l in gzip.open(f, "rt", errors="replace"):
for k, v in markers.items():
if l.startswith("2026") and v in l:
ev[k].append(T(l[:19])); break
for k in ev: ev[k].sort()
first = lambda k, a, b: next((t for t in ev[k][bisect.bisect_left(ev[k], a):] if t <= b), None)
def last(k, a, b):
i = bisect.bisect_right(ev[k], b) - 1
return ev[k][i] if i >= 0 and ev[k][i] >= a else None
done = sorted((s for s in scans.values() if "open" in s and "closed" in s), key=lambda s: s["open"])
rel = defaultdict(list)
for a, b in zip(done, done[1:]):
if b["open"] - a["closed"] > 5: continue
ra = last("read", a["open"], a["closed"]); r0 = first("read", b["open"], b["closed"])
if not (ra and r0): continue
for k, t in [("mcs", last("mcs", a["open"], a["closed"])), ("closed", a["closed"]),
("open", b["open"]), ("eig", first("eig", a["closed"], r0)),
("rot", first("rot", a["closed"], r0)), ("lt", last("lt", a["closed"], r0)),
("send", first("send", a["closed"], r0)), ("read0_logged", r0)]:
if t: rel[k].append(t - ra)
for k, v in rel.items(): print(f"{k:14s} {st.mean(v):6.2f} s n={len(v)}")
```
## 5. Improvement ideas (ranked by expected payoff per effort)
### 5.1 End-of-scan polling sleep (~0.5 s/scan, ~1.4 h per beamtime). Easy.
`flomni_fermat_scan.py:233-254`:
```python
status = self.actions.complete(device=self.dev.rt_positions, wait=False)
while not status.done:
self.at_each_point() # read_monitored_devices(); time.sleep(1)
```
The ScanServer checks `status.done` only once per second, so after the RT readout finishes it
idles 0–1 s (≈0.5 s mean) before `post_scan`. The 1 s sleep also sets the ~1 Hz monitored
readout rate (`num_monitored_readouts` ≈ scan duration in s), and that rate must be kept. Fix:
keep reading monitored devices every ~1 s but poll `done` finely, e.g.
```python
def at_each_point(self):
self.actions.read_monitored_devices()
t0 = time.time()
while time.time() - t0 < 1 and not self._rt_status.done:
time.sleep(0.02)
```
or `status.wait(timeout=1)` if `ScanStubStatus` supports it (BEC core, not checked).
**The same pattern exists in `lamni_fermat_scan.py:225-227` and `omny_fermat_scan.py:202-203`**
(there the `sleep(1)` is in `scan_core`). Fix all three.
### 5.2 Serial setup chain, starting the rotation earlier (~0.5–1 s). Medium.
The rotation only starts ~0.7 s after the previous scan closed (client round-trip +
`prepare_scan`), and the laser tracker, position upload and checks run only after staging and
rotation are done. Ideas:
- Start the fsamroy move to the next angle as early as possible. For example, the client could
send the next angle so the move overlaps the end of the previous scan, or a combined
multi-angle scan could run several projections inside one queue item.
- Overlap `laser_tracker_on`/`add_pos_to_scan` with staging where safe. Note that
`move_samx_to_scan_region` warns that an rtx move clears the trajectory, so ordering matters.
- `prepare_setup` moves rty with a blocking `actions.set`. Check whether it can overlap the
rotation.
### 5.3 Duplicate laser-tracker signal check (~0.15–0.45 s). Easy.
The client calls `dev.rtx.controller.laser_tracker_check_signalstrength()` before every
projection (`flomni.py:3661`, queued as a device_rpc). The scan calls it again in
`prepare_setup_part_2` (`flomni_fermat_scan.py:360`), which already raises an alarm/abort on
low signal. Drop the client-side call in the per-projection path; keep it where it is used
interactively. The `flomni_samples` and `fsamroy.read` RPCs around it also cost round-trips.
Check whether they are needed per projection or can be cached.
### 5.4 Laser tracker wait granularity (~0.2 s average on 28 % of scans). Easy.
`laser_tracker_wait_on_target` (`rt_flomni_ophyd.py:377-393`) sleeps 0.5 s between checks.
Use e.g. 0.05 s and scale `max_repeat` and the count==10 re-enable accordingly. The function is
also called twice per scan (`laser_tracker_on` and `laser_tracker_check_signalstrength`), and
each call does `laser_tracker_galil_enable()` plus a `Ts` query.
### 5.5 Eiger staging tail. Investigate.
Median 0.24 s, mean 0.60 s, so some stages take seconds. Extract the
`staged for scan. Time spent X s` distribution and correlate with time and JungfrauJoch state.
### 5.6 Small fixed sleeps
- `kickoff`: `shutdown_event.wait(0.1)` after `start_scan` (`rt_flomni_ophyd.py:963`).
- `move_samx_to_scan_region`: `sleep(0.05)` (`rt_flomni_ophyd.py:185`).
- `RtFlomniReadbackSignal._socket_get` has `sleep(0.1)` (`rt_flomni_ophyd.py:553`). Check how
often rt readbacks are read during the transition (e.g. `rty` set, baseline).
### 5.7 Per-point dead time (§2). Biggest, hardest.
~30–50 ms/pt, i.e. 16 % of beamtime. Needs RT controller timing (step/settle time per point in
the `sd` point scan) and the trigger chain. Short exposures (0.05/0.1 s × 1 frame) are hit
hardest. Using more frames at shorter exposure (0.01 × 20) already amortizes it better.
### 5.8 Interlock / Eiger-error recovery (~10 h)
Look at how long recovery after a shutter interlock takes vs the beam actually being back,
and why `EigerError` (314 alarms) happens.
### 5.9 Better instrumentation
- Millisecond timestamps in the BEC log format would make §3 exact per scan. This is BEC core;
write it up rather than change it here.
- Alternatively, add `logger.info` lines with `time.perf_counter()` deltas inside
`FlomniFermatScan` hooks (prepare/stage/pre_scan/scan_core/post_scan/unstage/close). That is
csaxs_bec-local and enough to time every phase directly.
- Re-run the analysis on LamNI data (from 2026-10-12 on) for comparison.
## 6. Ownership
Items in BEC core (`ScanStubStatus.wait`, log timestamp format) belong to the BEC core team.
Write them up rather than change them (see `AGENTS_mirko.md`). Everything in
`csaxs_bec/scans/*_fermat_scan.py`, `devices/omny/rt/` and the flomni client plugin can be
changed here.
@@ -59,7 +59,7 @@ Protocol fidelity is grounded in the actual hardware-side sources: `fgalil.dmc`,
- `devices/sim/sim_sample_storage.py` — `SimFlomniSampleStorage` (patched EPICS,
seedable via `sim_samples`, default slots 0–3 populated)
- `device_configs/simulated_omny/` — **all** simulated configs live here:
`simulated_flomni.yaml` (hand-maintained), `simulated_lamni.yaml` and
`simulated_bl_simulated_flomni.yaml` (hand-maintained), `simulated_bl_simulated_lamni.yaml` and
`simulated_omny.yaml` (generated), `simulated_bl_endstation.yaml` (pre-existing)
- `bin/generate_simulated_configs.py` — regenerates the LamNI/OMNY configs from
`ptycho_lamni.yaml`/`ptycho_omny.yaml` (rerun after real-config changes)
@@ -213,7 +213,7 @@ difference between *a reconfiguration tool* and *a remote control for the whole
endstation*.
**Decided with Mirko: `mokev` (energy) and `idgap` (undulator gap) — nothing else
yet.** Neither device is in the flomni sim config (`simulated_flomni.yaml`); both
yet.** Neither device is in the flomni sim config (`simulated_bl_simulated_flomni.yaml`); both
live in `bl_frontend.yaml` (shared beamline front-end devices), so `move` on either
is only exercisable against the real front end, not the flomni sim used for
`TOMO_QUEUE_TESTING.md`.
@@ -42,8 +42,8 @@ support on top of it:
per-action fields always match whatever's actually in `flomni.py` — no
hardcoded mirror. Lets you add steps in order (action + its own fields
only — a device dropdown and target for `move`, three bounded spinboxes
for `move_foptz_with_shift`'s `foptz_target`/`manual_shift_x`/
`manual_shift_y`, the first of which ranges over `foptz`'s live device
for `move_foptz_with_shift`'s `foptz_target`/`foptz_shift_x`/
`foptz_shift_y`, the first of which ranges over `foptz`'s live device
limits rather than a static number), remove a step, reorder steps within
the job (move up/down), and set a label + idempotent flag (defaults to
"every step idempotent" ANDed, same as the CLI, until you touch the
@@ -52,8 +52,8 @@ support on top of it:
2. **Command-job-aware queue table.** `TomoQueueDialog`'s table gained a
"Details" column; a command job shows `CMD` in the Type column and its
step summary + idempotency marker in Details (e.g.
`move{'positions': {'ccm_energy': 6.2}} > move_foptz_with_shift{'foptz_target':
15.0, 'manual_shift_x': 1.0, 'manual_shift_y': -2.0} [idem]`
`move(ccm_energy=6.2 keV) > move_foptz_with_shift(foptz_target=15.0 mm,
foptz_shift_x=1.0 um, foptz_shift_y=-2.0 um) [idem]`
— literally the same string `flomni.tomo_queue_show()` prints).
3. **Self-describing tomo jobs.** "Add current params to queue" now stamps
`"kind": "tomo"` and a fresh `"id"` on the job it writes, matching what
@@ -212,10 +212,10 @@ file is exactly how a regression sneaks in.
(should list `ccm_energy`, `idgap` per the current allow-list -- `foptz`
is deliberately absent, see point 8) and a **numeric target field**. Pick
a device, set a target, click **"Add step"**. Confirm a line appears in
the step list above, formatted like `move{'positions': {'ccm_energy': 6.2}}`.
the step list above, formatted like `move(ccm_energy=6.2 keV)`.
8. Switch the action dropdown to `move_foptz_with_shift`. Confirm the field
area now shows three spinboxes: **foptz target (absolute)**,
**New manual_shift_x**, **New manual_shift_y**. Confirm
**New foptz_shift_x**, **New foptz_shift_y**. Confirm
the foptz-target spinbox's range reflects `foptz`'s *live* device limits
(try typing a value past the real limit, confirm the widget clamps it
rather than accepting it -- this is the live-limit lookup, not a static
@@ -130,7 +130,7 @@ bec.config.load_demo_config(force=True)
Against a running sim session this **overwrites the loaded simulated-flOMNI device
config with BEC's demo devices**. Write our own fixture that reuses the plumbing
(`bec_servers`, `bec_redis_fixture`, `bec_services_config_file_path`) but either
loads `device_configs/simulated_omny/simulated_flomni.yaml` explicitly or assumes the
loads `device_configs/simulated_omny/simulated_bl_simulated_flomni.yaml` explicitly or assumes the
running session already has it.
### The fixture: `omny_e2e_tests/_bootstrap.py` + `omny_e2e_tests/conftest.py` → `flomni_sim` — BUILT
@@ -280,8 +280,8 @@ interruptible mid-scan for the crash-resume tests (§4, B5).
14. **✅ Back-compat + real dispatch, combined.**
`test_legacy_job_no_kind_defaults_to_tomo_and_command_moves_foptz`. A `kind`-less
legacy queue entry still runs as a tomogram; in the same mixed-queue run, a
command job actually moves `foptz` and sets new `manual_shift_x`/
`manual_shift_y` values via the real `move_foptz_with_shift` action
command job actually moves `foptz` and sets new `foptz_shift_x`/
`foptz_shift_y` values via the real `move_foptz_with_shift` action
path (the only sim-movable path now that `ftray` is no longer in the move
allow-list; `foptz` is also the real device this action exists for, unlike
`ftray`'s old sim-only stand-in role).
@@ -357,8 +357,8 @@ still just its design.
queue therefore never has to carry runtime values between steps; `kwargs` stays
plain JSON. The same "bundle, don't split" idea also applies to a compound action
with no runtime-dependent result but a correctness-critical pairing of writes --
`move_foptz_with_shift` (moves `foptz` + sets new `manual_shift_x`/
`manual_shift_y` values atomically, deliberately not reachable as two
`move_foptz_with_shift` (moves `foptz` + sets new `foptz_shift_x`/
`foptz_shift_y` values atomically, deliberately not reachable as two
separate steps) is the concrete example currently in the registry.
- **Publish the registry via a global var** (`tomo_queue_actions`) on `Flomni.__init__`
so the GUI builds its dropdowns/forms from it — no hardcoded mirror to drift (the
@@ -29,6 +29,7 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import (
filters_out_of_beam,
warn_and_confirm,
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.idle_time import account_projection_gap
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_angle_file_mixin import (
TomoAngleFileMixin,
)
@@ -40,6 +41,7 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import
OMNYTools,
PtychoReconstructor,
TomoIDManager,
stitch_tile_offsets,
read_escape_suffix,
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.ptouch_printer import PTouchLabelPrinter
@@ -999,6 +1001,7 @@ class FlomniSampleTransferMixin:
# The manual shifts belong to the previous sample; start the new one clean.
self.manual_shift_x = 0.0
self.manual_shift_y = 0.0
self.reset_foptz_shift()
def ftransfer_modify_storage(self, position: int, used: int):
if used:
@@ -1652,6 +1655,7 @@ class _ProgressProxy:
"estimated_finish_time": None,
"heartbeat": None,
"accumulated_idle_time": 0.0,
"recent_projection_gaps": [],
# set when tomo_scan() reached its end: tomo_scan_resume() then has nothing to resume
"tomo_completed": False,
}
@@ -1987,6 +1991,33 @@ class Flomni(
manual_shift_y = _GlobalVarParam(0.0)
# foptz_shift_x/y (um, added like manual_shift_x/y): compensates the
# scan-center drift caused by moving foptz after alignment
# (interferometer mirrors not perfectly parallel to the foptz motion).
# Kept separate from manual_shift_x/y on purpose: NOT in
# _TOMO_SCAN_PARAM_NAMES, so queued tomo jobs don't snapshot/restore it
# and a queued foptz move's shift survives the following scan jobs.
# Zeroed at X-ray eye alignment, where the current foptz is recorded as
# foptz_shift_reference (baseline for a future calibration).
foptz_shift_x = _GlobalVarParam(0.0)
foptz_shift_y = _GlobalVarParam(0.0)
foptz_shift_reference = _GlobalVarParam(None)
def reset_foptz_shift(self):
"""Zero foptz_shift_x/y and record the current foptz as reference."""
fx, fy = self.foptz_shift_x, self.foptz_shift_y
self.foptz_shift_x = 0.0
self.foptz_shift_y = 0.0
try:
self.foptz_shift_reference = float(dev.foptz.readback.get())
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Could not read foptz for foptz_shift_reference: {exc}")
self.foptz_shift_reference = None
if fx != 0.0 or fy != 0.0:
print(f"foptz shifts were reset to zero (previous values: x={fx} um, y={fy} um).")
# at_each_angle_hook is inherited from TomoQueueMixin: name of a
# registered at_each_angle hook (see register_at_each_angle_hook()), or
# None for the default per-projection behaviour. Snapshotted like any
@@ -2564,28 +2595,14 @@ class Flomni(
if self._min_scan_angle() <= angle < self._max_scan_angle() + 0.05:
now = datetime.datetime.now()
prev_heartbeat_str = self.progress.get("heartbeat")
if prev_heartbeat_str is not None:
gap = (now - datetime.datetime.fromisoformat(prev_heartbeat_str)).total_seconds()
# Normal cadence between consecutive projections is roughly
# the acquisition time plus motor/readout overhead. A gap
# well beyond that means something interrupted the scan in
# between (beamline-down interlock pause, a crash + manual
# restart, ...) -- attribute the excess to idle time so it
# doesn't drag down the apparent scan rate used for the ETA
# below. The 5x/60s margins are a heuristic, not a precise
# timing model -- tune if it over/under-triggers in practice.
normal_cadence = max(60.0, 5 * self.tomo_countingtime * self.frames_per_trigger)
if gap > normal_cadence:
idle = gap - normal_cadence
self.progress["accumulated_idle_time"] = (
self.progress.get("accumulated_idle_time", 0.0) + idle
)
print(
f"Detected a {self._format_duration(gap)} gap since the last projection"
f" -- excluding {self._format_duration(idle)} from the ETA estimate."
)
self.progress["heartbeat"] = now.isoformat()
# A gap well beyond this scan's measured typical gap (beamline-down
# pause, a crash + manual restart, ...) is excluded from the ETA.
idle = account_projection_gap(self.progress, now)
if idle > 0:
print(
"Detected a long gap since the last projection"
f" -- excluding {self._format_duration(idle)} from the ETA estimate."
)
print(f"Starting flOMNI scan for angle {angle} in subtomo {subtomo_number}")
self._print_progress()
@@ -2668,6 +2685,7 @@ class Flomni(
- self.compute_additional_correction_y(angle)
- self.compute_additional_correction_y_2(angle)
+ self.manual_shift_y
+ self.foptz_shift_y
)
# sum_offset_z = offsets[2]
# Todo
@@ -3620,14 +3638,16 @@ class Flomni(
# correction_xeye_mu = self.align.lamni_compute_additional_correction_xeye_mu(angle)
offsets = self.get_alignment_offset(angle)
sum_offset_x = offsets[0] + self.manual_shift_x
sum_offset_x = offsets[0] + self.manual_shift_x + self.foptz_shift_x
sum_offset_y = (
offsets[1]
- self.compute_additional_correction_y(angle)
- self.compute_additional_correction_y_2(angle)
+ self.manual_shift_y
+ self.foptz_shift_y
)
print(f"Constant shift: x={self.manual_shift_x}, y={self.manual_shift_y}")
print(f"Constant shift: x={self.manual_shift_x} um, y={self.manual_shift_y} um")
print(f"foptz shift: x={self.foptz_shift_x} um, y={self.foptz_shift_y} um")
sum_offset_z = offsets[2]
# TODO this fix is while the tracker z is broken
probe_propagation = -sum_offset_z * 1e-6
@@ -3641,8 +3661,8 @@ class Flomni(
start_scan_number = bec.queue.next_scan_number
projection_start = time.perf_counter()
for stitch_x in range(-self.stitch_x, self.stitch_x + 1):
for stitch_y in range(-self.stitch_y, self.stitch_y + 1):
for stitch_x in stitch_tile_offsets(self.stitch_x):
for stitch_y in stitch_tile_offsets(self.stitch_y):
# pylint: disable=undefined-variable
corridor_size = self.corridor_size if self.corridor_size > 0 else None
self._current_scan_list.append(bec.queue.next_scan_number)
@@ -3744,15 +3764,17 @@ class Flomni(
# --- alignment offset (same as tomo_scan_projection, no stitching) ---
offsets = self.get_alignment_offset(angle)
sum_offset_x = offsets[0] + self.manual_shift_x + random_shift_x
sum_offset_x = offsets[0] + self.manual_shift_x + self.foptz_shift_x + 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
+ self.foptz_shift_y
+ random_shift_y
)
print(f"Constant shift: x={self.manual_shift_x}, y={self.manual_shift_y}")
print(f"Constant shift: x={self.manual_shift_x} um, y={self.manual_shift_y} um")
print(f"foptz shift: x={self.foptz_shift_x} um, y={self.foptz_shift_y} um")
sum_offset_z = offsets[2]
# TODO this fix is while the tracker z is broken
@@ -3866,7 +3888,10 @@ class Flomni(
print(f"Stepsize microns <step> = {self.tomo_shellstep}")
print(f"Fermat asymmetry (x/y) = {self.fermat_asymmetry}")
print(f"FOV (220/100) <microns> = {self.fovx}, {self.fovy}")
print(f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}")
print(
f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}"
f" ({self.stitch_x + 1} x {self.stitch_y + 1} tiles)"
)
print(f"Stitching overlap = {self.tomo_stitch_overlap}")
if not self.single_point_instead_of_fermat_scan:
expected_positions = self._expected_fermat_position_count()
@@ -3882,6 +3907,8 @@ class Flomni(
print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}")
print(f" _manual_shift_x <um> = {self.manual_shift_x}")
print(f" _manual_shift_y <um> = {self.manual_shift_y}")
print(f" _foptz_shift_x <um> = {self.foptz_shift_x} (queue foptz moves)")
print(f" _foptz_shift_y <um> = {self.foptz_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:
@@ -3987,8 +4014,8 @@ class Flomni(
self.stitch_x = 0
self.stitch_y = 0
else:
self.stitch_x = self._get_val("<stitch X>", self.stitch_x, int, min_=0, max_=10)
self.stitch_y = self._get_val("<stitch Y>", self.stitch_y, int, min_=0, max_=10)
self.stitch_x = self._get_val("<stitch X> (n+1 tiles)", self.stitch_x, int, min_=0, max_=10)
self.stitch_y = self._get_val("<stitch Y> (n+1 tiles)", self.stitch_y, int, min_=0, max_=10)
self.ptycho_reconstruct_foldername = self._get_val(
"Reconstruction queue ", self.ptycho_reconstruct_foldername, str
)
@@ -4219,7 +4246,7 @@ class Flomni(
# control for the whole endstation. ccm_energy/idgap are real front-end
# devices (bl_frontend.yaml/bl_optics_hutch.yaml, not the flomni sim
# config). foptz is deliberately NOT here -- it has its own dedicated
# "move_foptz_with_shift" action below so the new manual_shift_x/y
# "move_foptz_with_shift" action below so the new foptz_shift_x/y
# correction can never be forgotten (see that action's docstring).
_TOMO_QUEUE_MOVE_DEVICES = {
"ccm_energy": {"label": "Energy", "unit": "keV"},
@@ -4250,7 +4277,7 @@ class Flomni(
"idempotent": True, # absolute move + absolute param set, re-runs harmlessly
"help": (
"Move foptz (FZP optics-Z) to an absolute target and set new "
"manual_shift_x/manual_shift_y values (replacing the current "
"foptz_shift_x/foptz_shift_y values (replacing the current "
"ones) in the same step -- changing foptz shifts the aligned "
"scan center, so this is the only way to queue a foptz move."
),
@@ -4270,33 +4297,35 @@ class Flomni(
# device can't be reached.
"device_limits_from": "foptz",
},
"manual_shift_x": {
"foptz_shift_x": {
"type": "float",
"default": 0.0,
"min": -10.0,
"max": 10.0,
"unit": "um",
"label": "New manual_shift_x",
"label": "New foptz_shift_x",
},
"manual_shift_y": {
"foptz_shift_y": {
"type": "float",
"default": 0.0,
"min": -50.0,
"max": 50.0,
"unit": "um",
"label": "New manual_shift_y",
"label": "New foptz_shift_y",
},
},
},
}
def _queue_action_move_foptz_with_shift(
self, foptz_target: float, manual_shift_x: float, manual_shift_y: float
self, foptz_target: float, foptz_shift_x: float, foptz_shift_y: float
) -> None:
"""Move foptz and set new manual_shift_x/y values together.
"""Move foptz and set new foptz_shift_x/y values together.
manual_shift_x/manual_shift_y are replaced outright by the values
given here, not adjusted relative to whatever they were before.
foptz_shift_x/foptz_shift_y are replaced outright by the values
given here, not adjusted relative to whatever they were before. Not
manual_shift_x/y: every queued tomo job snapshots and restores
those, so the following scan job would overwrite them.
foptz changes the FZP-to-sample distance, which shifts the aligned
scan center (interferometer mirrors aren't perfectly parallel to the
@@ -4304,9 +4333,9 @@ class Flomni(
foptz is NOT in _TOMO_QUEUE_MOVE_DEVICES, so this is the only way to
queue a foptz move, precisely so the shift can't be forgotten.
FUTURE: manual_shift_x/y could be auto-suggested from a calibration
proportional to the change in foptz, instead of typed in by hand --
deliberately out of scope here.
FUTURE: foptz_shift_x/y could be auto-suggested from a calibration
proportional to (foptz - foptz_shift_reference), instead of typed in
by hand.
Re-checks foptz's live limits at execution time too (not just add
time, see TomoQueueMixin._validate_action_kwargs) -- the actual
@@ -4321,8 +4350,8 @@ class Flomni(
f"outside foptz's current limits ({lo}, {hi})."
)
umv(dev.foptz, foptz_target)
self.manual_shift_x = manual_shift_x
self.manual_shift_y = manual_shift_y
self.foptz_shift_x = foptz_shift_x
self.foptz_shift_y = foptz_shift_y
def _describe_tomo_job_line(self, job: dict) -> str:
"""flomni's tomo_queue_show() one-line summary: fov/type/step/ctime/range.
@@ -262,6 +262,7 @@ class XrayEyeAlign:
self.flomni.reset_tomo_alignment_fit()
self.flomni.manual_shift_x = 0
self.flomni.manual_shift_y = 0
self.flomni.reset_foptz_shift()
self.flomni.lights_off()
@@ -0,0 +1,34 @@
# TODO: centered stitch tiles (stitch n -> n+1 tiles) for omny
Status: **not implemented for omny.** Decided and implemented for LamNI and
Flomni on 2026-10-21.
## What was implemented elsewhere
Stitch parameter `n` used to give `2n+1` tiles per direction (offsets
`-n .. +n`), so a single extra tile was impossible. Now `n` gives `n+1`
tiles placed symmetrically around the scan center, offsets `i - n/2` for
`i = 0..n`, in units of `fov - tomo_stitch_overlap`:
| n | tiles | offsets |
|---|---|---|
| 0 | 1 | 0 |
| 1 | 2 | -0.5, +0.5 |
| 2 | 3 | -1, 0, +1 |
Stored stitch values change meaning (old 1 == new 2); accepted by the user.
The reconstruction side needs no change.
Shared helper: `stitch_tile_offsets(n)` in
`OMNY_shared/omny_general_tools.py`.
## What to change in omny
- `omny.py` `tomo_scan_projection`: replace
`range(-self.stitch_x, self.stitch_x + 1)` (and y) with
`stitch_tile_offsets(self.stitch_x)`. `cenx`/`ceny` already multiply the
offset by `(fov - overlap)`, so float offsets work as is.
- Anything counting scans per projection must use `(sx+1)*(sy+1)`.
- `tomo_parameters` prompt/printout: show the tile count, as in flomni.
- When documenting stitching in `omny.md` (see
`TODO_document_stitching.md`), describe the new `(n+1)` semantics.
@@ -4,6 +4,10 @@ Status: **not implemented.** `docs/user/ptychography/omny.md` has zero
mentions of stitching, same gap just fixed 2026-09-19 in `flomni.md` and
`lamni.md`.
> Note 2026-10-21: LamNI/Flomni switched to `n+1` centered tiles; the
> formulas below are the old omny behaviour. See
> `TODO_centered_stitch_tiles.md` and document whichever omny then has.
## What to add
Omny's mechanism is essentially identical to Flomni's (same parameter
@@ -0,0 +1,47 @@
# TODO: separate optics-Z shift (ofzpz_shift_x/y) for omny
Status: **not implemented for omny.** Decided 2026-10-19; implemented for
LamNI (`loptz_shift_x/y`) and Flomni (`foptz_shift_x/y`) the same day. Omny
has no tomo queue yet (no `TomoQueueMixin`), so this goes in together with,
or after, a tomo-queue port.
## What was implemented elsewhere
Moving the FZP along the optical axis (loptz / foptz) after alignment
shifts the scan centre, because the interferometer mirrors are not
perfectly parallel to the stage motion. That drift used to be corrected
with `manual_shift_x/y`, but every queued tomo job snapshots and restores
`manual_shift_x/y` (they are in `_TOMO_SCAN_PARAM_NAMES`), so a queued
"move optics Z + set shift" command was overwritten by the next scan job.
The fix is a second, independent set of non-rotating shifts:
- `<optz>_shift_x/y`: global-var backed, **not** in
`_TOMO_SCAN_PARAM_NAMES`, added to the scan shift next to
`manual_shift_x/y` (`tomo_scan_projection()` and the other projection
paths), shown in `tomo_parameters()`.
- `<optz>_shift_reference`: optics-Z position at which the shifts were last
zeroed. Baseline for a future calibration (shift per mm of optics-Z
change), so a queued move can propose the shift values.
- `reset_<optz>_shift()`: zeroes the shifts and records the reference;
called at the start of the X-ray eye alignment (and in Flomni also on
sample change, where `manual_shift_x/y` are reset too).
- Tomo-queue command action `move_<optz>_with_shift`: moves optics Z and
sets the new `<optz>_shift_x/y` in one step. LamNI additionally refuses
the move if the OSA at its current position would collide with the
sample holder.
LamNI: `lamni.py` (search `loptz_shift`), Flomni: `flomni.py` (search
`foptz_shift`).
## What's needed for omny
1. Confirm the optics-Z device (`ofzpz`?) and the units of omny's
`manual_shift_x/y` (printed as mm in `tomo_parameters()`).
2. Add `ofzpz_shift_x/y`, `ofzpz_shift_reference`, `reset_ofzpz_shift()`
and apply the shift wherever `manual_shift_x/y` are applied.
3. Call the reset at X-ray eye alignment start.
4. Once omny has a tomo queue: add `move_ofzpz_with_shift`.
5. Check whether omny's RT controller locks the optics-Z axis read-only with
feedback on (LamNI's did; removed 2026-10-19 so loptz moves with feedback
running, like flomni's foptz).
@@ -52,8 +52,13 @@ 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.QtCore import QEvent, QObject, Qt, QTimer
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import (
format_command_step,
)
from qtpy.QtWidgets import (
QAbstractSpinBox,
QApplication,
QCheckBox,
QComboBox,
QDialog,
@@ -283,7 +288,7 @@ LAMNI_DEFAULTS: dict[str, Any] = {
"tomo_type": 1,
"tomo_angle_file": "",
"tomo_angle_stepsize": 10.0,
"golden_ratio_bunch_size": 20,
"golden_ratio_bunch_size": 100,
"golden_max_number_of_projections": 1000.0,
"golden_projections_at_0_deg_for_damage_estimation": 0,
"zero_deg_reference_at_each_subtomo": False,
@@ -343,6 +348,51 @@ def _hline() -> QFrame:
return line
class _NoWheelFilter(QObject):
"""Stop the mouse wheel from changing spin/combo box values; the wheel
event goes to the scroll area instead, so scrolling the panel over a
field scrolls the panel and cannot change a value unnoticed."""
def __init__(self, viewport: QWidget):
super().__init__(viewport)
self._viewport = viewport
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.Wheel:
QApplication.sendEvent(self._viewport, event)
return True
return super().eventFilter(obj, event)
def _block_wheel_edits(container: QWidget, viewport: QWidget) -> None:
"""Install a _NoWheelFilter on every spin and combo box in *container*."""
wheel_filter = _NoWheelFilter(viewport)
for w in container.findChildren(QAbstractSpinBox) + container.findChildren(QComboBox):
w.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
w.installEventFilter(wheel_filter)
class _SelectAllOnFocusFilter(QObject):
"""Select a spin box's whole text when it gets focus, so a typed number
replaces the value. Otherwise a click leaves the cursor at the end and
the typed digits are appended: e.g. bunch size 100 + "5" -> 1005, above
the maximum, so the spin box silently rejects every keystroke."""
def eventFilter(self, obj, event):
if event.type() == QEvent.Type.FocusIn:
# deferred: the mouse press that gave focus places the cursor
# (and clears the selection) after this event
QTimer.singleShot(0, obj.selectAll)
return super().eventFilter(obj, event)
def _select_all_on_focus(container: QWidget) -> None:
"""Install a _SelectAllOnFocusFilter on every spin box in *container*."""
focus_filter = _SelectAllOnFocusFilter(container)
for w in container.findChildren(QAbstractSpinBox):
w.installEventFilter(focus_filter)
# ── main widget ──────────────────────────────────────────────────────────────
@@ -521,6 +571,8 @@ class OMNY_TomoParams(BECWidget, QWidget):
params_vbox.addLayout(self._build_edit_buttons())
params_vbox.addStretch()
params_scroll.setWidget(params_inner)
_block_wheel_edits(params_inner, params_scroll.viewport())
_select_all_on_focus(params_inner)
root.addWidget(params_scroll)
self._btn_queue = QPushButton("☰ Queue control…")
@@ -611,7 +663,9 @@ class OMNY_TomoParams(BECWidget, QWidget):
# fov/step/stitch/piezo-range fields above change, flagged orange if
# below the scan server's own minimum (see _update_fermat_position_preview()).
self._lbl_fermat_positions = QLabel("---")
common_form.addRow("Estimated Fermat scan points:", self._lbl_fermat_positions)
common_form.addRow(
"Estimated Fermat scan points (smallest tile):", self._lbl_fermat_positions
)
for key in self._profile["fermat_position_fields"]:
widget = self._pw.get(key)
if widget is not None:
@@ -813,7 +867,7 @@ class OMNY_TomoParams(BECWidget, QWidget):
form, "_numprj_type3", "Projections per sub-tomo (type 3)", min_=1, max_=10000
)
# per-setup floor (SETUP_PROFILES[...]["golden_bunch_size_min"]): lamni's
# golden_ratio_bunch_size setter/CLI reject < 20, so a GUI-legal
# golden_ratio_bunch_size setter/CLI reject values outside 30..200, so a GUI-legal
# smaller value would raise ValueError in tomo_queue_execute() (which
# setattr()s every job param) and pause the queue.
self._add_int(
@@ -821,7 +875,7 @@ class OMNY_TomoParams(BECWidget, QWidget):
"golden_ratio_bunch_size",
"Bunch size (type 2 only)",
min_=self._profile.get("golden_bunch_size_min", 1),
max_=100,
max_=self._profile.get("golden_bunch_size_max", 100),
)
self._add_double(
form,
@@ -1159,7 +1213,8 @@ class OMNY_TomoParams(BECWidget, QWidget):
count, min_positions = self._profile["compute_fermat_positions"](params)
if count < min_positions:
return (
f"Estimated Fermat scan points ({count}) is below the minimum of "
f"Estimated Fermat scan points ({count}, smallest stitch tile) is below the "
f"minimum of "
f"{min_positions} required for the scan to run -- increase FOV, "
"reduce step size, or adjust stitch/piezo range before submitting."
)
@@ -1836,6 +1891,7 @@ class TomoQueueDialog(QDialog):
def _refresh(self) -> None:
jobs = self._load_queue()
registered_hooks = self._gv_get("tomo_at_each_angle_hooks") or []
action_registry = self._gv_get("tomo_queue_actions") or {}
self._table.setRowCount(len(jobs))
for row, job in enumerate(jobs):
status = job.get("status", "pending")
@@ -1849,7 +1905,7 @@ class TomoQueueDialog(QDialog):
"",
"",
"",
_format_command_summary(job),
_format_command_summary(job, action_registry),
]
else:
params = job.get("params", {})
@@ -1863,7 +1919,7 @@ class TomoQueueDialog(QDialog):
_fmt_num(params.get("tomo_shellstep")),
_format_hook_detail(params, registered_hooks),
]
tooltip = _job_tooltip(job, self._profile)
tooltip = _job_tooltip(job, registry=action_registry, profile=self._profile)
for col, text in enumerate(cells):
item = QTableWidgetItem(text)
# Label/Details are now wide, free-text columns -- left-align
@@ -2317,8 +2373,7 @@ class CommandJobBuilderDialog(QDialog):
action_name = step.get("action")
kwargs = step.get("kwargs", {})
self._steps.append({"action": action_name, "kwargs": kwargs})
summary = f"{action_name}{kwargs}" if kwargs else action_name
self._step_list.addItem(summary)
self._step_list.addItem(self._step_summary(action_name, kwargs))
# The job's actual saved flag, not a recomputed default -- mark
# overridden so _recompute_idempotent_default() leaves it alone as
# the operator edits steps.
@@ -2405,6 +2460,8 @@ class CommandJobBuilderDialog(QDialog):
if action_name == "move_foptz_with_shift" and "foptz_target" in self._step_field_widgets:
self._build_osa_collision_warning()
if action_name == "move_loptz_with_shift" and "loptz_target" in self._step_field_widgets:
self._build_lamni_osa_collision_warning()
def _build_osa_collision_warning(self) -> None:
"""Live, advisory-only OSA-collision warning for
@@ -2460,6 +2517,66 @@ class CommandJobBuilderDialog(QDialog):
foptz_widget.valueChanged.connect(_update)
_update()
def _build_lamni_osa_collision_warning(self) -> None:
"""LamNI counterpart of _build_osa_collision_warning() for
move_loptz_with_shift's loptz_target field: advisory warning against
losaz's *current* position (the OSA rides on loptz, so the queue
action itself refuses that case at execution time) and its defined
IN position (would collide if the OSA were swapped in later).
"""
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin import (
compute_osa_remaining_space,
)
lbl = QLabel()
lbl.setWordWrap(True)
lbl.setStyleSheet("color: #B8860B;")
lbl.setVisible(False)
self._field_form.addRow("", lbl)
loptz_widget, _field = self._step_field_widgets["loptz_target"]
def _update(_value=None):
devices = self._client.device_manager.devices
loptz, losaz = devices.get("loptz"), devices.get("losaz")
if loptz is None or losaz is None:
return
try:
collision_offset = (loptz.user_parameter or {}).get("collision_offset")
losaz_in = (losaz.user_parameter or {}).get("in")
losaz_now = losaz.readback.get()
except Exception:
return
if collision_offset is None:
return
target = loptz_widget.value()
msgs = []
clearance_now = compute_osa_remaining_space(target, losaz_now, collision_offset)
if clearance_now <= 0:
msgs.append(
f"at the current losaz ({losaz_now:.2f} mm) OSA clearance would be "
f"{clearance_now:.2f} mm -- the queued move will be refused"
)
if losaz_in is not None:
clearance_in = compute_osa_remaining_space(target, losaz_in, collision_offset)
if clearance_in <= 0:
msgs.append(
f"at losaz's defined IN position ({losaz_in:.2f} mm) OSA clearance "
f"would be {clearance_in:.2f} mm"
)
if msgs:
lbl.setText(
"Warning: at this loptz target, "
+ "; ".join(msgs)
+ " (<=0 = collision risk with a normal sample holder)."
)
lbl.setVisible(True)
else:
lbl.setVisible(False)
loptz_widget.valueChanged.connect(_update)
_update()
def _build_field_widget(self, field: dict) -> QWidget:
field_type = field.get("type")
if field_type == "float":
@@ -2526,10 +2643,13 @@ class CommandJobBuilderDialog(QDialog):
kwargs[param_name] = value
self._steps.append({"action": action_name, "kwargs": kwargs})
summary = f"{action_name}{kwargs}" if kwargs else action_name
self._step_list.addItem(summary)
self._step_list.addItem(self._step_summary(action_name, kwargs))
self._recompute_idempotent_default()
def _step_summary(self, action_name: str, kwargs: dict) -> str:
registry = {"actions": self._actions, "move_devices": self._move_devices}
return format_command_step({"action": action_name, "kwargs": kwargs}, registry)
def _on_remove_step(self) -> None:
row = self._step_list.currentRow()
if row < 0:
@@ -2795,47 +2915,35 @@ def _compute_fermat_positions_omny(params: dict[str, Any]) -> tuple[int, int]:
def _compute_fermat_positions_lamni(params: dict[str, Any]) -> tuple[int, int]:
"""Lamni sibling of _compute_fermat_positions_flomni(): calls
LamniFermatScan.get_lamni_fermat_spiral_pos() directly -- including its
rotated-stage and circular-FOV (tomo_circfov) cropping, which can matter
a lot and would be easy to get subtly wrong in a reimplementation.
Evaluated at the *currently edited* stitch tile and angle=0 (lamni has
no angle field in this widget -- the real per-projection count can vary
a bit with angle, since the circular crop is angle-dependent).
The real scan also draws a fresh random sub-step offset on every call
(see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a further
small amount of per-projection count variation on top of the
angle/stitch dependence above. A fixed seed is used here only so this
estimate is itself reproducible.
"""Lamni sibling of _compute_fermat_positions_flomni(), via
lamni_smallest_tile_position_count(): the real scan algorithm
(LamniFermatScan.get_lamni_fermat_spiral_pos(), including its
rotated-stage and circular-FOV cropping) run for every stitch tile, and
the smallest count returned. With a circular FOV the corner tiles can
fall below the minimum while the center tile is fine, and any such tile
aborts the projection, so submit is blocked on the smallest one.
Returns:
(estimated_count, minimum_required)
"""
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import (
lamni_smallest_tile_position_count,
)
from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan
piezo_x = params.get("lamni_piezo_range_x", 0.0)
piezo_y = params.get("lamni_piezo_range_y", 0.0)
step = params.get("tomo_shellstep", 0.0)
if step <= 0:
return 0, LamniFermatScan.MIN_POSITIONS
positions = LamniFermatScan.get_lamni_fermat_spiral_pos(
-abs(piezo_x / 2),
abs(piezo_x / 2),
-abs(piezo_y / 2),
abs(piezo_y / 2),
step=step,
spiral_type=0,
angle=0.0,
stitch_x=params.get("lamni_stitch_x", 0),
stitch_y=params.get("lamni_stitch_y", 0),
stitch_overlap=params.get("tomo_stitch_overlap", 1.0),
fovx=piezo_x,
fovy=piezo_y,
fov_circular=params.get("tomo_circfov", 0.0),
seed=0,
count, _tile = lamni_smallest_tile_position_count(
params.get("lamni_piezo_range_x", 0.0),
params.get("lamni_piezo_range_y", 0.0),
step,
params.get("lamni_stitch_x", 0),
params.get("lamni_stitch_y", 0),
params.get("tomo_stitch_overlap", 1.0),
params.get("tomo_circfov", 0.0),
)
return len(positions), LamniFermatScan.MIN_POSITIONS
return count, LamniFermatScan.MIN_POSITIONS
def _lamni_get_tomo_fov_offset(client, axis: str) -> float:
@@ -2945,9 +3053,10 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
# laminography: 180-deg-opposite angles are not redundant
"angle_file_opposite_warning": False,
"has_zero_deg_reference": True,
# lamni.golden_ratio_bunch_size setter/CLI require >= 20 (see
# lamni.golden_ratio_bunch_size setter/CLI require 30..200 (see
# _build_params_panel's bunch-size spinbox)
"golden_bunch_size_min": 20,
"golden_bunch_size_min": 30,
"golden_bunch_size_max": 200,
"fov_fields": [("tomo_circfov", "Circular FOV (µm, 0 = disabled)", 0.0, 500.0, 2)],
"stitch_fields": [
("lamni_stitch_x", "Stitch x", 0, 50),
@@ -3145,14 +3254,12 @@ def _job_kind(job: dict) -> str:
return job.get("kind", "tomo")
def _format_command_summary(job: dict) -> str:
"""Mirrors flomni.py's tomo_queue_show() command-job rendering exactly,
so the CLI and this table read as the same list."""
def _format_command_summary(job: dict, registry: dict | None = None) -> str:
"""Mirrors tomo_queue_show()'s command-job rendering exactly (same
format_command_step(), with units), so the CLI and this table read as
the same list."""
steps = job.get("steps", [])
parts = [
f"{step['action']}{step['kwargs']}" if step.get("kwargs") else step["action"]
for step in steps
]
parts = [format_command_step(step, registry) for step in steps]
idem = "idem" if job.get("idempotent") else "NOT idem"
return " > ".join(parts) + f" [{idem}]"
@@ -3170,7 +3277,7 @@ def _format_hook_detail(params: dict, registered_hooks: list) -> str:
return f"hook: {hook_name} (not registered)"
def _job_tooltip(job: dict, profile: Optional[dict] = None) -> str:
def _job_tooltip(job: dict, registry: dict | None = None, profile: Optional[dict] = None) -> str:
"""Full-detail multi-line description of a queue job, applied to every
cell in its row so hovering anywhere on the row shows what the Label
and Details columns truncate at typical dialog widths."""
@@ -3179,9 +3286,7 @@ def _job_tooltip(job: dict, profile: Optional[dict] = None) -> str:
lines.append(f"Idempotent: {'yes' if job.get('idempotent') else 'no'}")
lines.append("Steps:")
for i, step in enumerate(job.get("steps", []), start=1):
kwargs = step.get("kwargs") or {}
step_str = f"{step['action']}({kwargs})" if kwargs else step["action"]
lines.append(f" {i}. {step_str}")
lines.append(f" {i}. {format_command_step(step, registry)}")
else:
lines.append("Params:")
params = job.get("params", {})
+24 -23
View File
@@ -85,7 +85,7 @@ shutter_es:
################## XBOX 1 ES #####################
xbpm3x:
description: X-ray beam position x monitor 1 in ESbox1
description: XBPM3 (first XBox in ES hutch, beamline XBox 2) stage x
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
deviceConfig:
axis_Id: A
@@ -111,7 +111,7 @@ xbpm3x:
- bl_endstation
xbpm3y:
description: X-ray beam position y monitor 1 in ESbox1
description: XBPM3 (first XBox in ES hutch, beamline XBox 2) stage y
deviceClass: csaxs_bec.devices.smaract.smaract_ophyd.SmaractMotor
deviceConfig:
axis_Id: B
@@ -242,7 +242,7 @@ sl4xc:
deviceConfig:
left_slit: sl4xi
right_slit: sl4xo
offset: 0
offset: 0.356025
enabled: true
onFailure: retry
readOnly: false
@@ -260,7 +260,7 @@ sl4xs:
deviceConfig:
left_slit: sl4xi
right_slit: sl4xo
offset: 0
offset: -0.358556
enabled: true
onFailure: retry
readOnly: false
@@ -277,7 +277,7 @@ sl4yc:
deviceConfig:
left_slit: sl4yb
right_slit: sl4yt
offset: 0
offset: 1.049211
enabled: true
onFailure: retry
readOnly: false
@@ -295,7 +295,7 @@ sl4ys:
deviceConfig:
left_slit: sl4yb
right_slit: sl4yt
offset: 0
offset: -0.260001
enabled: true
onFailure: retry
readOnly: false
@@ -589,7 +589,7 @@ sl5xc:
deviceConfig:
left_slit: sl5xi
right_slit: sl5xo
offset: 0
offset: 0.515760
enabled: true
onFailure: retry
readOnly: false
@@ -607,7 +607,7 @@ sl5xs:
deviceConfig:
left_slit: sl5xi
right_slit: sl5xo
offset: 0
offset: -0.709994
enabled: true
onFailure: retry
readOnly: false
@@ -625,7 +625,7 @@ sl5yc:
deviceConfig:
left_slit: sl5yb
right_slit: sl5yt
offset: 0
offset: 1.200745
enabled: true
onFailure: retry
readOnly: false
@@ -643,7 +643,7 @@ sl5ys:
deviceConfig:
left_slit: sl5yb
right_slit: sl5yt
offset: 0
offset: -0.609914
enabled: true
onFailure: retry
readOnly: false
@@ -763,7 +763,7 @@ sl6xc:
deviceConfig:
left_slit: sl6xi
right_slit: sl6xo
offset: 0
offset: -1.796317
enabled: true
onFailure: retry
readOnly: false
@@ -780,7 +780,7 @@ sl6xs:
deviceConfig:
left_slit: sl6xi
right_slit: sl6xo
offset: 0
offset: -0.499822
enabled: true
onFailure: retry
readOnly: false
@@ -797,7 +797,7 @@ sl6yc:
deviceConfig:
left_slit: sl6yb
right_slit: sl6yt
offset: 0
offset: 0.903523
enabled: true
onFailure: retry
readOnly: false
@@ -814,7 +814,7 @@ sl6ys:
deviceConfig:
left_slit: sl6yb
right_slit: sl6yt
offset: 0
offset: -0.110837
enabled: true
onFailure: retry
readOnly: false
@@ -1121,7 +1121,7 @@ dettrx:
# ####################
# ### BPM XBox2 (first Xbox in ES) and Xbox3 BIM
# ### XBPM3 (first XBox in ES hutch, beamline XBox 2) and XBIM (beamline XBox 3)
# ### This requires galilrioesxbox device. On top of that the gain control devices and slow readback devices are built.
# ####################
@@ -1138,8 +1138,8 @@ galilrioesxbox:
deviceTags:
- bl_endstation
gain_bpm_xbox2:
description: Gain control for BPM XBox2
xbpm3_gain:
description: XBPM3 (first XBox in ES hutch, beamline XBox 2) gain control
deviceClass: csaxs_bec.devices.pseudo_devices.bpm_control.BPMControl
deviceConfig:
gain_lsb: galilrioesxbox.digital_out.ch0 # Pin 10 -> Galil ch0
@@ -1155,14 +1155,15 @@ gain_bpm_xbox2:
deviceTags:
- bl_endstation
bpm_xbox2_slowrb:
description: BPM Xbox 2 (First Xbox in ES hutch) readback (recommended gain 1e8)
xbpm3_slowrb:
description: XBPM3 (first XBox in ES hutch, beamline XBox 2) Galil slow readback (recommended gain 1e8)
deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM
deviceConfig:
left_top_ref: galilrioesxbox.analog_in.ch0
right_top_ref: galilrioesxbox.analog_in.ch1
right_bot_ref: galilrioesxbox.analog_in.ch2
left_bot_ref: galilrioesxbox.analog_in.ch3
invert: true # amplifier output is negative; positive values = more beam
enabled: true
readoutPriority: monitored
onFailure: retry
@@ -1171,8 +1172,8 @@ bpm_xbox2_slowrb:
deviceTags:
- bl_endstation
gain_bim_xbox3:
description: Gain control for beam intensity monitor ES Xbox3
xbim_gain:
description: XBIM beam intensity monitor (ES hutch, beamline XBox 3) gain control
deviceClass: csaxs_bec.devices.pseudo_devices.bpm_control.BPMControl
deviceConfig:
gain_lsb: galilrioesxbox.digital_out.ch6 # Pin 10 -> Galil ch0
@@ -1188,8 +1189,8 @@ gain_bim_xbox3:
deviceTags:
- bl_endstation
bim_xbox3_slowrb:
description: Beam intensity slow readback ES XBox3
xbim_slowrb:
description: XBIM beam intensity monitor (ES hutch, beamline XBox 3) Galil slow readback
deviceClass: csaxs_bec.devices.pseudo_devices.signal_forwarder.SignalForwarder
deviceConfig:
signal_ref: galilrioesxbox.analog_in.ch6
+9 -23
View File
@@ -99,6 +99,8 @@ sl1xc:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: 0.122368
deviceTags:
- bl_frontend
@@ -112,6 +114,8 @@ sl1xs:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: -0.0021
deviceTags:
- bl_frontend
@@ -125,6 +129,8 @@ sl1yc:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: -0.046220
deviceTags:
- bl_frontend
@@ -138,14 +144,15 @@ sl1ys:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: -0.101010
deviceTags:
- bl_frontend
#####################################
#### XBPM ###########################
#####################################
# Note: The following device may not be relevant anymore
# and can be fully replaced by the combined device "xbpm1", see below
# The single current channels of XBPM1; the combined device is "xbpm1", see below
xbpm1c1:
description: 'XBPM1 (frontend) current 1'
@@ -199,27 +206,6 @@ xbpm1c4:
deviceTags:
- bl_frontend
bpm1:
description: 'XBPM1 (frontend)'
deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM
deviceConfig:
left_top_ref: xbpm1c1
right_top_ref: xbpm1c2
right_bot_ref: xbpm1c3
left_bot_ref: xbpm1c4
onFailure: raise
enabled: true
readoutPriority: monitored
readOnly: true
softwareTrigger: false
needs:
- xbpm1c1
- xbpm1c2
- xbpm1c3
- xbpm1c4
deviceTags:
- bl_frontend
############################################
######### End of xbpm sub devices ##########
############################################
+22 -6
View File
@@ -60,6 +60,8 @@ sl2xc:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: 0.050061
deviceTags:
- bl_optics_hutch
@@ -73,6 +75,8 @@ sl2xs:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: -0.851018
deviceTags:
- bl_optics_hutch
@@ -86,6 +90,8 @@ sl2yc:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: 0.6703
deviceTags:
- bl_optics_hutch
@@ -99,6 +105,8 @@ sl2ys:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: -0.8545
deviceTags:
- bl_optics_hutch
@@ -191,7 +199,7 @@ ccm_energy:
##########################################################################
xbpm2x:
description: X-ray beam position monitor 1 in OPbox
description: XBPM2 (optics hutch, XBox 1) stage x
deviceClass: csaxs_bec.devices.mcs2.mcs2_ophyd.Mcs2Motor
deviceConfig:
axis_Id: A
@@ -216,7 +224,7 @@ xbpm2x:
- bl_optics_hutch
xbpm2y:
description: X-ray beam position monitor 1 in OPbox
description: XBPM2 (optics hutch, XBox 1) stage y
deviceClass: csaxs_bec.devices.mcs2.mcs2_ophyd.Mcs2Motor
deviceConfig:
axis_Id: B
@@ -355,8 +363,8 @@ galilrioop:
deviceTags:
- bl_optics_hutch
# gain_bpm_xbox1:
# description: Gain control for BPM XBox1 (OP hutch)
# xbpm2_gain:
# description: XBPM2 (optics hutch, XBox 1) gain control
# deviceClass: csaxs_bec.devices.pseudo_devices.bpm_control.BPMControl
# deviceConfig:
# gain_lsb: galilrioop.digital_out.ch0 # Pin 10 -> Galil ch0
@@ -372,8 +380,8 @@ galilrioop:
# deviceTags:
# - bl_optics_hutch
# bpm_xbox1_slowrb:
# description: BPM Xbox 1 (OP hutch) readback
# xbpm2_slowrb:
# description: XBPM2 (optics hutch, XBox 1) Galil slow readback
# deviceClass: csaxs_bec.devices.pseudo_devices.bpm.BPM
# deviceConfig:
# left_top_ref: galilrioop.analog_in.ch0
@@ -493,6 +501,8 @@ sl3xc:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: -0.029090
deviceTags:
- bl_optics_hutch
@@ -506,6 +516,8 @@ sl3xs:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: 2.749977
deviceTags:
- bl_optics_hutch
@@ -519,6 +531,8 @@ sl3yc:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: 0.290816
deviceTags:
- bl_optics_hutch
@@ -532,6 +546,8 @@ sl3ys:
readoutPriority: baseline
readOnly: false
softwareTrigger: false
userParameter:
epics_offset: -2.640883
deviceTags:
- bl_optics_hutch
+32 -2
View File
@@ -133,8 +133,8 @@ lsamrot:
axis_Id: C
host: mpc2680.psi.ch
limits:
- 0
- 0
- -2
- 362
port: 8081
sign: 1
tolerance: 1
@@ -349,6 +349,36 @@ rt_positions:
deviceTags:
- ptycho_lamni
############################################################
################ Keithley 2400 current source ##############
############################################################
# RS-232 through the TCP-serial bridge on mpc2680 (service lamni-serial-keithley, TCP 3336).
# The Keithley is not always connected; onFailure: buffer keeps the session running without it.
# compliance_voltage: the output never drives more than this, also with the load disconnected.
# remote_sense: true needs the sense leads connected at the load.
keithley:
description: Keithley 2400 current source
deviceClass: csaxs_bec.devices.keithley.keithley_2400.Keithley2400
deviceConfig:
host: mpc2680.psi.ch
port: 3336
limits:
- 0
- 3
tolerance: 0.01
compliance_voltage: 5
source_range_ma: 10
remote_sense: false
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
connectionTimeout: 20
deviceTags:
- ptycho_lamni
- keithley
############################################################
######################### Cameras ##########################
############################################################
@@ -9,7 +9,7 @@ ddg1:
readoutPriority: baseline
softwareTrigger: true
deviceTags:
- simulated_bl_endstation_flomni
- simulated_bl_real_flomni
fsh:
description: Simulated fast shutter manual control and readback
deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.cSAXSSimulatedFastShutter
@@ -19,7 +19,7 @@ fsh:
enabled: true
readoutPriority: monitored
deviceTags:
- simulated_bl_endstation_flomni
- simulated_bl_real_flomni
flomni:
- !include ../ptycho_flomni.yaml
@@ -9,7 +9,7 @@ ddg1:
readoutPriority: baseline
softwareTrigger: true
deviceTags:
- simulated_bl_endstation_lamni
- simulated_bl_real_lamni
fsh:
description: Simulated fast shutter manual control and readback
deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.cSAXSSimulatedFastShutter
@@ -19,9 +19,9 @@ fsh:
enabled: true
readoutPriority: monitored
deviceTags:
- simulated_bl_endstation_lamni
- simulated_bl_real_lamni
flomni:
lamni:
- !include ../ptycho_lamni.yaml
# machine:
@@ -28,7 +28,7 @@ omny_xray_gui:
readOnly: false
readoutPriority: on_request
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
############################################################
#################### flOMNI Galil motors ###################
@@ -59,7 +59,7 @@ feyex:
fttrx_in: 2.3
fttrx_out: -24
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
feyey:
description: Xray eye Y (simulated)
@@ -83,7 +83,7 @@ feyey:
userParameter:
in: -10.09
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
fheater:
description: Heater Y (simulated)
@@ -108,7 +108,7 @@ fheater:
in: -0.5
enabled: 1
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
foptx:
description: Optics X (simulated)
@@ -138,7 +138,7 @@ foptx:
detector_distance: -1 # mm, sample-to-detector; unknown for now
fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
fopty:
description: Optics Y (simulated)
@@ -164,7 +164,7 @@ fopty:
in: 0.42
out: 0.57
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
foptz:
description: Optics Z (simulated)
@@ -188,7 +188,7 @@ foptz:
userParameter:
in: 23
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
fsamroy:
description: Sample rotation (simulated)
@@ -209,7 +209,7 @@ fsamroy:
readoutPriority: baseline
connectionTimeout: 20
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
fsamx:
description: Sample coarse X (simulated)
@@ -233,7 +233,7 @@ fsamx:
userParameter:
in: -1.14
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
fsamy:
description: Sample coarse Y (simulated)
@@ -257,7 +257,7 @@ fsamy:
userParameter:
in: 3
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
ftracky:
description: Laser Tracker coarse Y (simulated)
@@ -279,7 +279,7 @@ ftracky:
readoutPriority: baseline
connectionTimeout: 20
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
ftrackz:
description: Laser Tracker coarse Z (simulated)
@@ -301,7 +301,7 @@ ftrackz:
readoutPriority: baseline
connectionTimeout: 20
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
ftransx:
description: Sample transer X (simulated)
@@ -323,7 +323,7 @@ ftransx:
readoutPriority: baseline
connectionTimeout: 20
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
ftransy:
description: Sample transer Y (simulated)
@@ -349,7 +349,7 @@ ftransy:
userParameter:
sensor_voltage: -1.6
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
ftransz:
description: Sample transer Z (simulated)
@@ -371,7 +371,7 @@ ftransz:
readoutPriority: baseline
connectionTimeout: 20
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
ftray:
description: Sample transfer tray (simulated)
@@ -395,7 +395,7 @@ ftray:
readoutPriority: baseline
connectionTimeout: 20
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
############################################################
#################### flOMNI Smaract motors #################
@@ -424,7 +424,7 @@ fosax:
in: 8.727079
out: 5.1
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
fosay:
description: OSA Y (simulated)
@@ -448,7 +448,7 @@ fosay:
#170 micron, 60 nm, 7.9 kev
in: -0.04603
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
fosaz:
description: OSA Z (simulated)
@@ -473,7 +473,7 @@ fosaz:
in: 13.1
out: 6
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
############################################################
#################### flOMNI RT motors ######################
@@ -500,7 +500,7 @@ rtx:
min_signal: 8000
rt_pid_voltage: -0.06219
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
rty:
description: flomni rt (simulated)
@@ -521,7 +521,7 @@ rty:
userParameter:
tomo_additional_offsety: 0
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
rtz:
description: flomni rt (simulated)
@@ -540,7 +540,7 @@ rtz:
readoutPriority: on_request
connectionTimeout: 20
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
rt_positions:
deviceClass: csaxs_bec.devices.sim.sim_rt_flomni.SimRtFlomniFlyer
@@ -554,7 +554,7 @@ rt_positions:
enabled: true
readOnly: False
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
############################################################
####################### Cameras ############################
@@ -574,7 +574,7 @@ cam_flomni_gripper:
readOnly: false
readoutPriority: on_request
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
cam_flomni_overview:
description: Camera flomni overview (simulated)
@@ -590,7 +590,7 @@ cam_flomni_overview:
readOnly: false
readoutPriority: on_request
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
cam_xeye:
description: Camera flOMNI Xray eye ID1 (simulated)
@@ -611,7 +611,7 @@ cam_xeye:
userParameter:
pixel_calibration: 0.00044247787610619477 # mm/pixel (= 0.05 / 113)
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
############################################################
#################### Sample storage ########################
@@ -632,7 +632,7 @@ flomni_samples:
readOnly: false
readoutPriority: baseline
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
############################################################
#################### Computed signal #######################
@@ -647,7 +647,7 @@ calculated_signal:
readOnly: false
readoutPriority: baseline
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
############################################################
######### Simulated beamline devices (shutter, DDG) ########
@@ -664,7 +664,7 @@ ddg1:
readoutPriority: baseline
softwareTrigger: true
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
fsh:
description: Simulated fast shutter manual control and readback
@@ -675,4 +675,4 @@ fsh:
enabled: true
readoutPriority: monitored
deviceTags:
- simulated_flomni
- simulated_bl_simulated_flomni
@@ -26,7 +26,7 @@ leyex:
userParameter:
in: 14.117
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
leyey:
description: Xray eye Y
deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor
@@ -50,7 +50,7 @@ leyey:
in: 48.069
out: 0.5
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
loptx:
description: Optics X
deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor
@@ -80,7 +80,7 @@ loptx:
detector_distance: -1 # mm, sample-to-detector; unknown for now
fzp_details: "manufacturing notes here" # free-text FZP manufacturing notes
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
lopty:
description: Optics Y
deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor
@@ -105,7 +105,7 @@ lopty:
in: 3.724
out: 3.53
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
loptz:
description: Optics Z
deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor
@@ -127,7 +127,7 @@ loptz:
readoutPriority: baseline
connectionTimeout: 20
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
lsamrot:
description: Sample rotation
deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor
@@ -150,7 +150,7 @@ lsamrot:
readoutPriority: baseline
connectionTimeout: 20
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
lsamx:
description: Sample coarse X
deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor
@@ -174,7 +174,7 @@ lsamx:
userParameter:
center: 8.768
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
lsamy:
description: Sample coarse Y
deviceClass: csaxs_bec.devices.sim.sim_lamni.SimLamniGalilMotor
@@ -198,7 +198,7 @@ lsamy:
userParameter:
center: 10.041
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
losax:
description: OSA X
deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor
@@ -219,7 +219,7 @@ losax:
userParameter:
in: -1.442
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
losay:
description: OSA Y
deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor
@@ -241,7 +241,7 @@ losay:
in: -0.171
out: 3.8
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
losaz:
description: OSA Z
deviceClass: csaxs_bec.devices.sim.sim_smaract.SimSmaractMotor
@@ -263,7 +263,7 @@ losaz:
in: -1
out: -3
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
rtx:
deviceClass: csaxs_bec.devices.sim.sim_lamni.SimRtLamniMotor
deviceConfig:
@@ -289,7 +289,7 @@ rtx:
enabled: true
readOnly: false
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
rty:
deviceClass: csaxs_bec.devices.sim.sim_lamni.SimRtLamniMotor
deviceConfig:
@@ -308,7 +308,7 @@ rty:
enabled: true
readOnly: false
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
rt_positions:
deviceClass: csaxs_bec.devices.sim.sim_lamni.SimRtLamniFlyer
deviceConfig:
@@ -321,7 +321,7 @@ rt_positions:
enabled: true
readOnly: false
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
cam_xeye:
description: Camera LamNI Xray eye ID15
deviceClass: csaxs_bec.devices.sim.sim_cameras.SimIDSCamera
@@ -354,7 +354,7 @@ cam_xeye:
userParameter:
pixel_calibration: 0.0009174311926605505 # mm/pixel (= 0.2 / 218)
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
ddg1:
description: Simulated main delay generator for triggering
deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.SimulatedDDG1
@@ -366,7 +366,7 @@ ddg1:
readoutPriority: baseline
softwareTrigger: true
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
fsh:
description: Simulated fast shutter manual control and readback
deviceClass: csaxs_bec.devices.sim.simulated_beamline_devices.cSAXSSimulatedFastShutter
@@ -376,7 +376,7 @@ fsh:
enabled: true
readoutPriority: monitored
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
############################################################
#################### GUI Signals ###########################
@@ -390,4 +390,4 @@ omny_xray_gui:
readOnly: false
readoutPriority: on_request
deviceTags:
- simulated_lamni
- simulated_bl_simulated_lamni
@@ -0,0 +1,27 @@
# Simulated LamNI endstation with the real Keithley 2400.
# The keithley entry mirrors the one in ptycho_lamni.yaml (bridge on mpc2680, TCP 3336);
# keep both in sync.
lamni:
- !include ./simulated_bl_simulated_lamni.yaml
keithley:
description: Keithley 2400 current source
deviceClass: csaxs_bec.devices.keithley.keithley_2400.Keithley2400
deviceConfig:
host: mpc2680.psi.ch
port: 3336
limits:
- 0
- 3
tolerance: 0.01
compliance_voltage: 5
source_range_ma: 10
remote_sense: false
enabled: true
onFailure: buffer
readOnly: false
readoutPriority: baseline
softwareTrigger: false
connectionTimeout: 20
deviceTags:
- keithley
Whitespace-only changes.
+423
View File
@@ -0,0 +1,423 @@
"""
Keithley 2400 SourceMeter used as a current source, RS-232 reached through a TCP-serial bridge.
The device is a positioner in mA, so ``umv(dev.keithley, 1.5)`` sources 1.5 mA. Voltage and
current are measured concurrently while the output is on.
Output handling, deliberate because a load hangs on this instrument:
- ``:MEAS?`` switches the output on by itself and is therefore never sent. Readbacks use ``:READ?``
and only while the output is on (it fails with the output off), reporting 0 otherwise.
- ``:SOUR:CLE:AUTO`` is kept off, otherwise ``:READ?`` would toggle the output.
- Connecting, disconnecting, stopping or a BEC restart leave the output and the level untouched. If
the output is on at connect, the source settings are not changed at all.
- A move programs the level first and only then switches the output on, so a stale level is
never sourced. The output is only switched off by ``output_off()``.
Commands end in CR. Replies end in CR LF, set by the RS-232 terminator on the front panel.
"""
from __future__ import annotations
import threading
import time
import numpy as np
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
from ophyd import Device, PositionerBase, Signal, SignalRO
from ophyd.status import MoveStatus
from ophyd.utils import LimitError, ReadOnlyError
from ophyd.utils.errors import InvalidState
from ophyd_devices.utils.controller import Controller, ControllerCommunicationError, threadlocked
from ophyd_devices.utils.socket import SocketIO, SocketSignal, raise_if_disconnected
from prettytable import PrettyTable
logger = bec_logger.logger
class Keithley2400Error(Exception):
"""Instrument in an unexpected state, or the requested current could not be reached."""
class Keithley2400Controller(Controller):
"""SCPI communication with the Keithley 2400."""
_axes_per_controller = 1
READBACK_MAX_AGE = 0.1 # s, the voltage and current signals share one :READ?
def _initialize(self):
super()._initialize()
self._reading = (0.0, 0.0)
self._reading_time = 0.0
@threadlocked
def socket_put(self, val: str) -> None:
self.command_history.append(f"[PUT]: {val}")
self.sock.put(f"{val}\r".encode())
@threadlocked
def socket_get(self) -> str:
"""Read one reply. At 9600 baud a reply can arrive in several TCP segments."""
buffer = b""
while not buffer.endswith(b"\n"):
chunk = self.sock.receive()
if not chunk:
raise ConnectionError("Connection closed while waiting for a reply.")
buffer += chunk
response = buffer.decode().strip()
self.command_history.append(f"[GET]: {response}")
return response
@threadlocked
def socket_put_and_receive(self, val: str, remove_trailing_chars: bool = True) -> str:
"""Query, not retried: a late reply to a repeated query would shift all following replies."""
try:
self.socket_put(val)
return self.socket_get()
except Exception as exc:
logger.error(f"Keithley query {val} failed: {exc}. {list(self.command_history)}")
raise ControllerCommunicationError(f"Keithley query {val} failed: {exc}") from exc
@threadlocked
def configure(
self, compliance_voltage: float, source_range_ma: float, remote_sense: bool
) -> None:
"""Set up the current source. With the output on only harmless settings are sent."""
idn = self.socket_put_and_receive("*IDN?")
if "MODEL 2400" not in idn:
raise Keithley2400Error(f"Unexpected instrument identification: {idn}")
self.socket_put(":SOUR:CLE:AUTO OFF")
self.socket_put(":FORM:ELEM VOLT,CURR")
if self.output_enabled():
source_function = self.socket_put_and_receive(":SOUR:FUNC?")
if source_function != "CURR":
raise Keithley2400Error(
f"Output is on with source function {source_function}, not CURR. "
"Check the instrument and switch the output off."
)
actual = self.get_compliance_voltage()
if not np.isclose(actual, compliance_voltage):
logger.warning(
f"Keithley output is on, compliance stays at {actual} V "
f"(configured: {compliance_voltage} V)."
)
else:
for command in (
":SOUR:FUNC CURR",
":SOUR:CURR:MODE FIX",
":SOUR:CURR:RANG:AUTO OFF",
f":SOUR:CURR:RANG {source_range_ma * 1e-3:.6E}",
":SENS:FUNC:CONC ON",
':SENS:FUNC:ON "VOLT","CURR"',
f":SYST:RSEN {'ON' if remote_sense else 'OFF'}",
":ARM:COUN 1",
":TRIG:COUN 1",
):
self.socket_put(command)
self.set_compliance_voltage(compliance_voltage)
actual = self.get_compliance_voltage()
if not np.isclose(actual, compliance_voltage):
raise Keithley2400Error(
f"Compliance is {actual} V, expected {compliance_voltage} V."
)
error = self.socket_put_and_receive(":SYST:ERR?")
if not error.startswith("0,"):
raise Keithley2400Error(f"Instrument error after setup: {error}")
def output_enabled(self) -> bool:
return self.socket_put_and_receive(":OUTP?") == "1"
def set_output(self, enabled: bool) -> None:
self.socket_put(f":OUTP {'ON' if enabled else 'OFF'}")
self._reading_time = 0.0
def get_level(self) -> float:
"""Programmed source level in A."""
return float(self.socket_put_and_receive(":SOUR:CURR?"))
def set_level(self, amps: float) -> None:
self.socket_put(f":SOUR:CURR {amps:.6E}")
self._reading_time = 0.0
def get_compliance_voltage(self) -> float:
return float(self.socket_put_and_receive(":SENS:VOLT:PROT?"))
def set_compliance_voltage(self, volts: float) -> None:
self.socket_put(f":SENS:VOLT:PROT {volts:.6E}")
def in_compliance(self) -> bool:
return self.socket_put_and_receive(":SENS:VOLT:PROT:TRIP?") == "1"
@threadlocked
def read_output(self, max_age: float | None = None) -> tuple[float, float]:
"""Measured (volts, amps), (0, 0) with the output off."""
max_age = self.READBACK_MAX_AGE if max_age is None else max_age
if time.monotonic() - self._reading_time > max_age:
if self.output_enabled():
volts, amps = self.socket_put_and_receive(":READ?").split(",")[:2]
self._reading = (float(volts), float(amps))
else:
self._reading = (0.0, 0.0)
self._reading_time = time.monotonic()
return self._reading
class Keithley2400SignalBase(SocketSignal):
def __init__(self, signal_name, **kwargs):
self.signal_name = signal_name
super().__init__(**kwargs)
self.controller: Keithley2400Controller = self.parent.controller
class Keithley2400SignalRO(Keithley2400SignalBase):
def __init__(self, signal_name, **kwargs):
super().__init__(signal_name, **kwargs)
self._metadata["write_access"] = False
def _socket_set(self, val):
raise ReadOnlyError("Read-only signals cannot be set")
class Keithley2400ReadbackSignal(Keithley2400SignalRO):
"""Measured current in mA."""
def _socket_get(self):
return self.controller.read_output()[1] * 1e3
class Keithley2400VoltageSignal(Keithley2400SignalRO):
"""Measured voltage in V."""
def _socket_get(self):
return self.controller.read_output()[0]
class Keithley2400OutputSignal(Keithley2400SignalRO):
def _socket_get(self):
return int(self.controller.output_enabled())
class Keithley2400ComplianceTrippedSignal(Keithley2400SignalRO):
def _socket_get(self):
return int(self.controller.in_compliance())
class Keithley2400SetpointSignal(Keithley2400SignalBase):
"""Programmed source level in mA."""
def _socket_get(self):
return self.controller.get_level() * 1e3
def _socket_set(self, val):
self.parent.check_value(val)
self.controller.set_level(val * 1e-3)
class Keithley2400ComplianceVoltageSignal(Keithley2400SignalBase):
"""Voltage limit in V."""
def _socket_get(self):
return self.controller.get_compliance_voltage()
def _socket_set(self, val):
self.controller.set_compliance_voltage(val)
class Keithley2400IsMoving(SignalRO):
def set_is_moving(self, value: int) -> None:
self._readback = value
class Keithley2400(Device, PositionerBase):
"""
Keithley 2400 as a current source, positioner in mA.
Args:
limits: Allowed source current in mA.
tolerance: Allowed difference between measured and requested current in mA.
compliance_voltage: Voltage limit in V.
source_range_ma: Fixed source range in mA, fixed so level changes cause no range glitch.
remote_sense: True for 4-wire sensing, needs the sense leads connected at the load.
"""
USER_ACCESS = ["output_on", "output_off", "status"]
readback = Cpt(Keithley2400ReadbackSignal, signal_name="readback", kind="hinted")
user_setpoint = Cpt(Keithley2400SetpointSignal, signal_name="setpoint")
voltage = Cpt(Keithley2400VoltageSignal, signal_name="voltage", kind="normal")
output_enabled = Cpt(Keithley2400OutputSignal, signal_name="output_enabled", kind="normal")
compliance_tripped = Cpt(
Keithley2400ComplianceTrippedSignal, signal_name="compliance_tripped", kind="normal"
)
compliance_voltage = Cpt(
Keithley2400ComplianceVoltageSignal, signal_name="compliance_voltage", kind="config"
)
motor_is_moving = Cpt(Keithley2400IsMoving, value=0, kind="normal")
high_limit_travel = Cpt(Signal, value=0, kind="omitted")
low_limit_travel = Cpt(Signal, value=0, kind="omitted")
SUB_READBACK = "readback"
SUB_CONNECTION_CHANGE = "connection_change"
_default_sub = SUB_READBACK
SETTLE_TIMEOUT = 2.0 # s
def __init__(
self,
prefix="",
*,
name,
kind=None,
read_attrs=None,
configuration_attrs=None,
parent=None,
host="mpc2680.psi.ch",
port=3336,
limits=(0.0, 3.0),
tolerance: float = 0.01,
compliance_voltage: float = 5.0,
source_range_ma: float = 10.0,
remote_sense: bool = False,
socket_cls=SocketIO,
device_manager=None,
**kwargs,
):
self.controller = Keithley2400Controller(
socket_cls=socket_cls, socket_host=host, socket_port=port, device_manager=device_manager
)
self.controller.set_axis(axis=self, axis_nr=0)
self.tolerance = tolerance
self._compliance_voltage = compliance_voltage
self._source_range_ma = source_range_ma
self._remote_sense = remote_sense
super().__init__(
prefix,
name=name,
kind=kind,
read_attrs=read_attrs,
configuration_attrs=configuration_attrs,
parent=parent,
**kwargs,
)
self.readback.name = self.name
self.controller.subscribe(
self._update_connection_state, event_type=self.SUB_CONNECTION_CHANGE
)
self._update_connection_state()
self.low_limit_travel.put(limits[0])
self.high_limit_travel.put(limits[1])
def wait_for_connection(self, timeout: float = 30.0) -> bool:
self.controller.on(timeout=timeout)
self.controller.configure(
self._compliance_voltage, self._source_range_ma, self._remote_sense
)
def destroy(self):
"""Close the socket. The output stays as it is."""
self.controller.off(update_config=False)
return super().destroy()
@property
def limits(self):
return (self.low_limit_travel.get(), self.high_limit_travel.get())
@property
def low_limit(self):
return self.limits[0]
@property
def high_limit(self):
return self.limits[1]
def check_value(self, pos):
low_limit, high_limit = self.limits
if not low_limit <= pos <= high_limit:
raise LimitError(f"current={pos} mA not within limits {self.limits}")
def _update_connection_state(self, **kwargs):
for walk in self.walk_signals():
walk.item._metadata["connected"] = self.controller.connected
@raise_if_disconnected
def move(self, position, wait=True, **kwargs) -> MoveStatus:
"""Source ``position`` mA, switching the output on if it is off."""
timeout = kwargs.pop("timeout", 10)
status = super().move(position, timeout=timeout, **kwargs)
self.user_setpoint.put(position, wait=False) # level first, then the output
def apply_and_finish():
self.motor_is_moving.set_is_moving(1)
error = None
try:
self.controller.set_output(True)
self._wait_for_current(position)
except Exception as exc: # pylint: disable=broad-except
error = exc
self.motor_is_moving.set_is_moving(0)
val = self.readback.read()
self._run_subs(sub_type=self.SUB_READBACK, value=val, timestamp=time.time())
if error is None:
self._done_moving(success=True)
return
# BEC forwards this exception to the client, _done_moving would lose the reason.
logger.error(f"{self.name}: {error}")
try:
status.set_exception(error)
except InvalidState:
pass # stopped or timed out in the meantime
self._reset_sub(self._SUB_REQ_DONE)
threading.Thread(target=apply_and_finish, daemon=True).start()
if wait:
status.wait()
return status
def _wait_for_current(self, position: float) -> None:
deadline = time.monotonic() + self.SETTLE_TIMEOUT
while True:
volts, amps = self.controller.read_output(max_age=0)
if np.isclose(amps * 1e3, position, atol=self.tolerance, rtol=0):
return
if time.monotonic() > deadline:
break
time.sleep(0.05)
hint = (
f" Voltage compliance reached at {volts} V: check the wiring, or the load needs "
"more than the compliance voltage."
if self.controller.in_compliance()
else ""
)
raise Keithley2400Error(f"Requested {position} mA, measured {amps * 1e3} mA.{hint}")
def output_on(self) -> None:
"""Switch the output on at the programmed level. Use ``umv`` to set a new level."""
self.controller.set_output(True)
def output_off(self) -> None:
"""Switch the output off. The programmed level is kept."""
self.controller.set_output(False)
def status(self) -> None:
"""Print the state of the instrument."""
volts, amps = self.controller.read_output(max_age=0)
table = PrettyTable()
table.header = False
table.add_rows(
[
["Output", "on" if self.controller.output_enabled() else "off"],
["Programmed current", f"{self.controller.get_level() * 1e3:.4f} mA"],
["Measured current", f"{amps * 1e3:.4f} mA"],
["Measured voltage", f"{volts:.4f} V"],
["Compliance voltage", f"{self.controller.get_compliance_voltage()} V"],
["In compliance", "YES" if self.controller.in_compliance() else "no"],
]
)
print(table)
@property
def egu(self):
return "mA"
@@ -441,7 +441,10 @@ class RtFlomniController(Controller):
print(f"min signal: {min_signal}")
print(f"signal: {signal}")
if signal < min_signal:
# second chance: re-read after a short wait (the old code
# compared the same stale value again)
time.sleep(1)
signal = self.read_ssi_interferometer(1)
if signal < min_signal:
print(
f"\x1b[91mThe signal of the tracker {signal} is below the minimum required signal of {min_signal}. Readjustment requred!\x1b[0m"
@@ -152,7 +152,6 @@ class RtLamniController(Controller):
self.set_device_read_write("lsamy", False)
self.set_device_read_write("loptx", False)
self.set_device_read_write("lopty", False)
self.set_device_read_write("loptz", False)
@threadlocked
def feedback_disable_and_even_reset_lamni_angle_interferometer(self):
@@ -381,7 +380,6 @@ class RtLamniController(Controller):
self.set_device_read_write("lsamy", False)
self.set_device_read_write("loptx", False)
self.set_device_read_write("lopty", False)
self.set_device_read_write("loptz", False)
if interferometer_feedback_not_running == 1:
logger.error(
+9 -2
View File
@@ -7,7 +7,12 @@ from ophyd_devices.utils.bec_processed_signal import BECProcessedSignal
class BPM(PSIPseudoDeviceBase):
"""BPM positioner pseudo device."""
"""BPM positioner pseudo device.
invert=True negates the four blade signals, e.g. for an amplifier whose
output current is negative. pos_x, pos_y and diagonal are ratios and do
not change; intensity changes sign.
"""
# Blade signals, a,b,c,d
left_top = Cpt(
@@ -76,11 +81,13 @@ class BPM(PSIPseudoDeviceBase):
right_top_ref: str,
right_bot_ref: str,
left_bot_ref: str,
invert: bool = False,
device_manager=None,
scan_info=None,
**kwargs,
):
super().__init__(name=name, device_manager=device_manager, scan_info=scan_info, **kwargs)
self._sign = -1 if invert else 1
# Get all blade signal objects from utility method
signal_t = self.left_top.get_device_object_from_bec(
object_name=left_top_ref, signal_name=self.name, device_manager=device_manager
@@ -131,7 +138,7 @@ class BPM(PSIPseudoDeviceBase):
)
def _compute_blade_signal(self, signal: Signal) -> float:
return signal.get()
return self._sign * signal.get()
def _compute_intensity(
self, left_top: Signal, right_top: Signal, right_bot: Signal, left_bot: Signal
@@ -9,7 +9,7 @@ from ophyd_devices.utils.bec_processed_signal import BECProcessedSignal
class SignalForwarder(PSIPseudoDeviceBase):
"""Forward one signal unchanged."""
"""Forward one signal unchanged, or negated with invert=True."""
signal = Cpt(
BECProcessedSignal,
@@ -20,8 +20,17 @@ class SignalForwarder(PSIPseudoDeviceBase):
doc="Forwarded signal",
)
def __init__(self, name, signal_ref: str, device_manager=None, scan_info=None, **kwargs):
def __init__(
self,
name,
signal_ref: str,
invert: bool = False,
device_manager=None,
scan_info=None,
**kwargs,
):
super().__init__(name=name, device_manager=device_manager, scan_info=scan_info, **kwargs)
self._sign = -1 if invert else 1
src = self.signal.get_device_object_from_bec(
object_name=signal_ref, signal_name=self.name, device_manager=device_manager
@@ -30,4 +39,4 @@ class SignalForwarder(PSIPseudoDeviceBase):
self.signal.set_compute_method(self._compute_signal, signal=src)
def _compute_signal(self, signal: Signal) -> float:
return signal.get()
return self._sign * signal.get()
+2 -2
View File
@@ -1,6 +1,6 @@
# LamNI smear rotation-center aid: architecture notes
The experimental continuous-rotation "smear" calibration aid (see
The continuous-rotation "smear" calibration aid (see
[the user-facing description](../user/ptychography/lamni.md)) touches all
three layers of a typical BEC GUI feature — a **device**, an **ipython-client
plugin**, and a **GUI widget** — running as three separate processes talking
@@ -106,7 +106,7 @@ at the start and pushes composite updates there as the rotation
progresses — but **deliberately does not switch back to `"image"` itself**
on a successful return. The composite has to stay on screen until the
operator's click has actually been collected
(`find_rotation_center_smear_experimental` switches back right after
(`find_rotation_center_smear` switches back right after
`_collect_click()` returns); switching back inside `_smear_sweep()` would
show the operator a live raw frame instead of the composite at exactly the
moment they need to click on it. On `KeyboardInterrupt` mid-sweep there's
@@ -0,0 +1,168 @@
# Plan: Batched single-point tomography for flOMNI (one armed scan for many angles)
Status: **not implemented** (planned 2026-10-15 during the LamNI beamtime; the
only code change made alongside it is the laser-tracker re-read bug fix, see
§6).
## Goal
flOMNI's single-point tomo mode (`single_point_instead_of_fermat_scan`) takes
one BEC scan per angle: `Flomni.tomo_acquire_at_angle()` rotates `fsamroy`,
moves `rtx/rty/rtz`, checks the laser tracker and then calls `scans.acquire()`.
Every angle therefore pays the full scan open/stage/unstage/close cycle plus
the client round-trip.
Instead: arm the detectors **once per batch of angles**, and inside that one
scan, for each point: rotate, move to the target position, trigger one
acquisition, read out, continue with the next angle.
## Expected saving
From `csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/FLOMNI_SCAN_OVERHEAD.md`
(flOMNI beamtime 2026-10-07…12, 10,403 fermat scans):
- Scan-to-scan dead time **3.2 s median**, dominated by serial bookkeeping
(end-of-scan polling, close, client round-trip, prepare, open, detector
staging, laser tracker on), not by the rotation (0.6–0.9 s for 1–2° steps).
- In a batched scan a point should cost roughly rotation + rt move + tracker
on-target + exposure. Estimated saving **~2–2.5 s per angle** at small
angular steps (e.g. ~1.5 h on a 2000-angle tomogram).
- The overhead document measured `flomni_fermat_scan`, not `scans.acquire`.
**First step:** measure the current single-point mode per angle (same log
markers as in that document) to confirm the baseline.
## Decisions (agreed 2026-10-15)
1. **Analysis:** one scan containing many angles is fine for the data
analysis. The scan must record, per point, the angle and the rtx/rty/rtz
target positions.
2. **Batching:** configurable batch size; one BEC scan per batch. Limits what
has to be repeated after a failure or an interlock "abort and repeat".
3. **Laser tracker:** checked regularly inside the scan depending on the
angular step: after a rotation once the accumulated rotation since the last
check exceeds a parameter (degrees), and once at the start of each batch.
"toolow" aborts (→ batch repeated), "low" warns, as in the fermat scan.
4. **Shutter:** no extra handling. The trigger via the delay generator (ddg1)
already opens the shutter for each exposure, so it stays closed during
rotation and moves.
5. **Trigger source: the RT system (preferred).** The RT controller fires
the trigger only once the target position is truly reached (in its own
position loop), and the scheme does not depend on beamline trigger
hardware or BEC software-trigger timing, so it also runs independently of
the beamline. This is the same chain the fermat scan already uses: ddg1 on
`EXT_RISING_EDGE`, RT controller → ddg1 → detectors/shutter
(`flomni_fermat_scan.py` `prepare_setup_part_2`).
- Possible reuse of the existing trajectory path: per angle, load a
one-point trajectory (`add_pos_to_scan([[x, y, z]])`) and start it; the
RT controller moves there and triggers when in position, exactly as for
each fermat point. To check with the RT controller code: whether a
one-point trajectory is accepted, its start/stop overhead, and whether
the `rt_positions` flyer readout can stay armed across many short
trajectories in one BEC scan. Otherwise a dedicated RT command
"move to x/y/z, trigger once in position" is needed (to be provided on
the RT side).
- **Fallback without RT changes:** BEC's software-triggered step-scan path
already arms once for many points: Eiger stages
`num_points × frames_per_trigger` triggers
(`devices/jungfraujoch/eiger.py:490`); ddg1 in `SINGLE_SHOT` sends one
software trigger per BEC `trigger` (`ddg_1.py:679`); the MCS card
handles `software_triggered` scans (`mcs_card_csaxs.py:383`). Here
"in position" relies on the rt move's completion status instead of the
RT loop. The scan plugin can be built so that only the trigger step
differs between the two variants.
## Design
### Scan plugin `flomni_point_tomo_scan` (new, `csaxs_bec/scans/`)
Software-triggered step scan (`ScanBase`), modelled on `flomni_fermat_scan.py`.
- **Inputs:** `angles` (list), `positions` (list of `[x, y, z]` per angle,
computed on the client), `exp_time`, `frames_per_trigger`,
`tracker_check_every_deg`.
- **prepare_scan:** validate lengths; `update_scan_info(num_points=len(angles),
positions=...)`; store angles/positions in the scan metadata for the
analysis.
- **stage / pre_scan:** `stage_all_devices()` (arms Eiger/MCS once), ddg1 →
`SINGLE_SHOT`, `laser_tracker_on()`, `move_samx_to_scan_region()` for the
first point.
- **scan_core, per point:**
1. rotate `fsamroy` (reuse `flomni_rotation()` incl. its progress report);
skip when already at the angle;
2. (fallback variant) move `rtx/rty/rtz` to the target; in the RT
variant the RT controller does this move itself in step 4; call `move_samx_to_scan_region()` when
x leaves the current coarse region;
3. if the accumulated rotation exceeds `tracker_check_every_deg`:
**wait for tracker on target** (must happen before the exposure, the
interferometer positions depend on it);
4. trigger **without waiting**: RT variant = start the RT move/trigger
(ddg1 on `EXT_RISING_EDGE`), the RT fires when in position; fallback =
BEC software trigger of ddg1 (`SINGLE_SHOT`). While the exposure runs,
**read and judge the tracker signal strength** (read-only; in the RT
variant check that the controller accepts a query during the move/
trigger sequence);
5. wait for the trigger status, `read_monitored_devices()`;
6. "toolow" → raise `ScanAbortion` (the point was taken with a bad signal;
batch is repeated); "low" → alarm WARNING.
- **post_scan / on_exception:** `complete_all_devices()`, ddg1 back to
`SINGLE_SHOT` default, same as the fermat scan.
### Laser tracker changes (`devices/omny/rt/rt_flomni_ophyd.py`)
- Split `laser_tracker_check_signalstrength()` into
`laser_tracker_wait_on_target()` (already separate) and a new read-only
`laser_tracker_signal_status()` that only reads and classifies the signal,
so the scan can call them at different times (steps 3 and 4 above).
- **Polling period of `laser_tracker_wait_on_target()` 0.5 s → 50 ms**
(agreed). Its loop counts iterations (`count == 10` re-enables tracking,
`count > 100` raises), so convert both thresholds to elapsed time
(currently 5 s and 50 s) to keep the behaviour. Benefits every flOMNI scan:
the overhead document measured 0.5 s, or 0.87 s when it has to wait, here.
### Client (`bec_ipython_client/plugins/flomni/flomni.py`)
- New switch on the existing single-point mode, e.g.
`single_point_batched` + `single_point_batch_size`, rather than a new tomo
type. The angle plan (tomo types 1/2/3, sub-tomograms, golden ratio) is
unchanged; only the execution changes.
- Compute per-angle positions exactly as `tomo_acquire_at_angle()` does today
(`get_alignment_offset`, `compute_additional_correction_y(_2)`,
`manual_shift_x/y`, random shift), then submit one
`flomni_point_tomo_scan` per batch.
- **Progress / resume:** advance per completed batch; resume starts at the
first angle of the interrupted batch. Interlock "restart_scan" repeats the
current batch.
- **Projection logs, scilog, reconstruction queue:** one scan number now
covers a batch of angles; record the scan number with its angle range /
angle list.
- Webpage/GUI progress: update per point from the scan's progress report if
possible, else per batch.
## Implementation order
1. Measure the current single-point overhead per angle (baseline).
2. Tracker: `laser_tracker_signal_status()` split + 50 ms polling with
time-based thresholds; tests.
3. RT side: one-point trajectory test or new "move + trigger in
position" command.
4. Scan plugin with tests (simulated devices, see
`flomni/AI_docs/SIMULATED_ENDSTATIONS.md`).
5. Client batching, progress/resume, logging; tests.
6. Hardware test: short batch (e.g. 10 angles), check data, tracker
behaviour, shutter timing; then compare per-angle time with step 1.
## Open points
- Eiger: confirm no trigger timeout while armed and waiting during a large
rotation (> 20° took up to ~4.8 s in the overhead data).
- Whether a tracker check before *every* exposure is needed at very small
steps, or only on the angular threshold.
- Per-point dead time inside scans (~30–50 ms, overhead document §2) is a
separate issue and not addressed here.
## Done alongside this plan
- `laser_tracker_check_signalstrength()`: the "too low" branch slept 1 s and
then compared the **same** value again, so the intended second chance did
nothing. It now re-reads the signal (`rt_flomni_ophyd.py`), with a test in
`tests/tests_devices/test_rt_flomni.py`.
+1 -3
View File
@@ -121,8 +121,6 @@ During operation the BEC GUI will show the relevant cameras or progress informat
## How to setup flOMNI (software)
This part of the manual is intended for beamline staff and expert users
The nano-positioning is controlled by a feedback loop running on a real-time linux based computer. With all related hardware connected, this loop has to be started manually.
1. Login to the computer by `ssh control@mpc2680`. The password is written on the physical machine.
@@ -408,7 +406,7 @@ For the "8 sub-tomograms" mode, `flomni.tomo_parameters()` also offers a `zero_d
By default odd sub-tomograms sweep 0→180° and even ones 180→0°, so the reference is taken before every second sub-tomogram. With the `tomo_type1_always_forward` option ("Run all sub-tomos forward", 180° range only), every sub-tomogram sweeps 0→180°; the rotation returns to 0° between sub-tomograms and a 0° reference is taken before each of the 8 (plus one at the end). The angle set of every sub-tomogram, and thus the combined angular grid, is unchanged.
`flomni.tomo_parameters()` also offers `stitch_x`/`stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single Fermat scan can cover: when nonzero, every projection angle is acquired as a `(2*stitch_x+1) × (2*stitch_y+1)` grid of separate Fermat scans instead of a single one, tiled around the normal alignment-corrected center and spaced by `fovx`/`fovy` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(2*stitch_x+1) × fovx` by `(2*stitch_y+1) × fovy`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. Stitching is forced to 0 (disabled) whenever `single_point_instead_of_fermat_scan` is enabled.
`flomni.tomo_parameters()` also offers `stitch_x`/`stitch_y` (integers, default 0, up to 10 each) for a field of view larger than a single Fermat scan can cover: when nonzero, every projection angle is acquired as a `(stitch_x+1) × (stitch_y+1)` grid of separate Fermat scans instead of a single one, placed symmetrically around the normal alignment-corrected center and spaced by `fovx`/`fovy` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(stitch_x+1) × fovx` by `(stitch_y+1) × fovy`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. For example, a value of 1 gives two tiles, each shifted by half a step from the center; 2 gives three tiles (one centered, one on each side). Stitching is forced to 0 (disabled) whenever `single_point_instead_of_fermat_scan` is enabled.
The parameters above can be used to __restart an interrupted acquisition__ manually, or - more conveniently - by running
`flomni.tomo_scan_resume()`
+26 -15
View File
@@ -4,8 +4,6 @@ LamNI is an instrument for 3D ptychography via ptychographic X-ray computed lami
## How to LamNI
… a step-by-step guide for *beamline staff and expert users*.
### Quick guide
Only the commands, in the order they are used. Details are in the linked sections below.
@@ -19,7 +17,7 @@ Only the commands, in the order they are used. Details are in the linked section
- `lamni.xrayeye_rotation_center_calibration_isolated()` for an isolated particle, or
- `lamni.xrayeye_rotation_center_calibration_extended()` for an extended/textured sample
- experimental: `lamni.xrayeye_rotation_center_calibration_smear_experimental()`
- `lamni.xrayeye_rotation_center_calibration_smear()` for an extended sample, using a continuous-rotation composite
Applying a new center erases an existing X-ray eye alignment and fine-alignment correction. You are warned before that happens.
@@ -64,13 +62,13 @@ This opens the X-ray eye widget and walks through the calibration interactively.
- `lamni.xrayeye_rotation_center_calibration_isolated()` — for a sparse/isolated particle. You submit its centre position once at 0° and once at 180°; the midpoint of the two is the rotation axis' projected position. Pass `keep_shutter_open=True` if it's hard to relocate the sample between steps, matching `xrayeye_alignment_start()` below.
- `lamni.xrayeye_rotation_center_calibration_extended()` — for a non-isolated/textured sample where the rotation centre can be identified visually. The sample sweeps 0° → 180° → 0° with the live view left open so you can watch for the point that doesn't move, then you submit the rotation centre. The correction is applied, a short verification sweep (0° → 45° → 0°) runs, and you're asked *"Alignment acceptable — stop here?"* — answer `n` to run again.
In `..._extended()` and `..._smear_experimental()` the X-ray eye GUI also offers an elliptical selection, with its angle fixed at -76°. The isolated-particle mode keeps the rectangle/circle selection only.
In `..._extended()` and `..._smear()` the X-ray eye GUI also offers an elliptical selection, with its angle fixed at -76°. The isolated-particle mode keeps the rectangle/circle selection only.
At the end you're shown the computed `lsamx_center`/`lsamy_center` and asked to confirm before they're written via `dev.lsamx.update_user_parameter(...)` / `dev.lsamy.update_user_parameter(...)`.
**Experimental: continuous-rotation "smear" aid**
**Continuous-rotation "smear" aid**
`lamni.xrayeye_rotation_center_calibration_smear_experimental(sweep_deg=360.0, projection="min", opaque_level=0.25)` is an **experimental** alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` while accumulating a "star-trail" composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. The composite builds up live on screen as the sweep progresses and stays frozen once the sweep ends, until you submit your click. `sweep_deg` can be reduced below 360 if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`.
`lamni.xrayeye_rotation_center_calibration_smear(sweep_deg=360.0, projection="min", opaque_level=0.25)` is an alternative to `..._extended()` for the same non-isolated-sample case. Instead of judging the rotation centre from a single instant of live rotation, it rotates `lsamrot` continuously through `sweep_deg` while accumulating a "star-trail" composite from the camera: off-axis features smear into circular arcs, and the common center of curvature of those arcs is the rotation axis — usually an easier target to click than one live frame. The composite builds up live on screen as the sweep progresses and stays frozen once the sweep ends, until you submit your click. `sweep_deg` can be reduced below 360 if a shorter arc already shows enough curvature. There is no automatic circle fitting — you still submit the centre by eye, same click mechanism as `..._extended()`.
`projection` selects how the frames are combined:
@@ -143,7 +141,7 @@ sx, sy = LamNIComponents.lamni_compute_scan_center(lamni.tomo_fovx_offset, lamni
lamni.lamni_move_to_scan_center(sx + cx, sy + cy, 0).wait()
```
The base shift is rotated to the angle first, so the move itself is called with angle 0 to pass the target through unchanged. This matches a projection without stitching, manual shifts (`lamni.manual_shift_x/y`, add them) and the correction tables from fine alignment (`lamni.compute_additional_correction(angle)` and `..._2(angle)`, subtract them). The positions are relative to the `center` user parameters of `lsamx`/`lsamy`, which must be set. Check the result with `lamni.show_interferometer_positions()`.
The base shift is rotated to the angle first, so the move itself is called with angle 0 to pass the target through unchanged. This matches a projection without stitching, manual shifts (`lamni.manual_shift_x/y` and `lamni.loptz_shift_x/y`, add them) and the correction tables from fine alignment (`lamni.compute_additional_correction(angle)` and `..._2(angle)`, subtract them). The positions are relative to the `center` user parameters of `lsamx`/`lsamy`, which must be set. Check the result with `lamni.show_interferometer_positions()`.
If the drift between coarse stages and interferometer found at the start of the move is larger than 150 µm, it is considered unreliable and ignored; the move then relies on the second, interferometer-based iteration.
@@ -161,17 +159,18 @@ The sample fine alignment can be obtained using ptychography. For this a short l
| Base shift | first click (0°) of the x-ray eye alignment, stored as `lamni.tomo_fovx_offset` / `tomo_fovy_offset` | scan centre (`center_x`, `center_y`), rotated with the angle |
| X-ray eye correction | sinusoidal fit `A·sin(angle + B) + C` of the 8 clicked positions, `lamni.lamni_compute_additional_correction_xeye_mu(angle)` | added to the shift |
| Manual shift | `lamni.manual_shift_x` / `manual_shift_y`, constant for all angles | added to the shift |
| loptz shift | `lamni.loptz_shift_x` / `loptz_shift_y`, constant for all angles; compensates moving loptz after alignment, reset to zero at x-ray eye alignment | added to the shift |
| Additional correction 1 | lookup table from the fine alignment, `lamni.read_additional_correction(file)` | subtracted from the shift |
| Additional correction 2 | lookup table of the second iteration, `lamni.read_additional_correction_2(file)` | subtracted from the shift |
The shift is therefore `manual + x-ray eye − correction 1 − correction 2`, on top of the rotated base shift. The x-ray eye fit was measured after the base shift was applied, so the two do not count twice.
The shift is therefore `manual + loptz shift + x-ray eye − correction 1 − correction 2`, on top of the rotated base shift. The x-ray eye fit was measured after the base shift was applied, so the two do not count twice.
The correction tables are read from the files written by `BEC_ptycho_align`, with one shift per alignment angle. There is no interpolation: for each projection the entry with the closest angle is used. If a table has no data, it is skipped and `Not applying additional correction` is printed. Without an x-ray eye fit, `Not applying any X-ray eye correction` is printed. Each correction can be removed again with `lamni.reset_correction()`, `lamni.reset_correction_2()` and `lamni.reset_xray_eye_correction()`.
The alignment scan itself already uses the x-ray eye correction and any correction tables that are loaded. So the ptychographic alignment measures what is left over after those corrections, which is why a second iteration with `read_additional_correction_2()` is useful.
* `lamni.tomo_parameters()` adjust the ptychographic scan parameters for the alignment scan (FOV/step size/counting time) — `tomo_type` and number of projections are ignored, since the alignment scan always runs its own fixed 12 points spread evenly across the full 360 degrees, independent of the main tomogram's settings.
* `lamni.tomo_alignment_scan()` perform the alignment scan. Requires x-ray-eye alignment to have already been done — it will abort with a message otherwise. Scan numbers, angles and the existing x-ray-eye-fit offset at each angle are written to `~/data/raw/logs/ptychotomoalign_scannum.txt` and also printed at the end.
* `lamni.tomo_parameters()` adjust the ptychographic scan parameters for the alignment scan (FOV/step size/counting time) — `tomo_type` and number of projections are ignored, since the alignment scan runs its own set of equally spaced angles from 0 to 360 inclusive (360 repeats 0 as a drift check), independent of the main tomogram's settings. By default this is 13 points, every 30 degrees; see `number_of_projections` below.
* `lamni.tomo_alignment_scan()` perform the alignment scan. Optional argument `number_of_projections` (default 13) sets the number of angles, 0 and 360 both counted, e.g. `lamni.tomo_alignment_scan(number_of_projections=25)` for every 15 degrees. It must be at least 2. Requires x-ray-eye alignment to have already been done — it will abort with a message otherwise. Scan numbers, angles and the existing x-ray-eye-fit offset at each angle are written to `~/data/raw/logs/ptychotomoalign_scannum.txt` and also printed at the end.
* Run `BEC_ptycho_align` (in Matlab, **force ptycho=1**, and **correct scan numbers**) using the printed/logged scan numbers.
* Click the sample position in the Matlab GUI and then load the generated file by, for example
`lamni.read_additional_correction('/sls/X12SA/data/e20632/Data10/cxs_software/ptycho/correction_lamni_um_S05389_lamni_fit.txt')`
@@ -209,7 +208,7 @@ which reads the last recorded progress and resumes automatically at the exact po
In case of eight equally spaced sub-tomograms, an individual sub-tomogram can be scanned by `lamni.sub_tomo_scan(subtomo_number, start_angle)`. If the start angle is not specified, it is computed depending on `subtomo_number`, ranging from 1 to 8.
`lamni.tomo_parameters()` also offers `lamni_stitch_x`/`lamni_stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single scan can cover: when nonzero, every projection angle is acquired as a `(2*lamni_stitch_x+1) × (2*lamni_stitch_y+1)` grid of separate Fermat scans instead of a single one, tiled around the normal alignment-corrected center and spaced by `lamni_piezo_range_x`/`lamni_piezo_range_y` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(2*lamni_stitch_x+1) × lamni_piezo_range_x` by `(2*lamni_stitch_y+1) × lamni_piezo_range_y`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. Same mechanism as flomni's `stitch_x`/`stitch_y`, just under lamni's own parameter names (see `lamni.tomo_scan_projection()`, which passes `stitch_x`/`stitch_y`/`stitch_overlap` straight through to `scans.lamni_fermat_scan()`).
`lamni.tomo_parameters()` also offers `lamni_stitch_x`/`lamni_stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single scan can cover: when nonzero, every projection angle is acquired as a `(lamni_stitch_x+1) × (lamni_stitch_y+1)` grid of separate Fermat scans instead of a single one, placed symmetrically around the normal alignment-corrected center and spaced by `lamni_piezo_range_x`/`lamni_piezo_range_y` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(lamni_stitch_x+1) × lamni_piezo_range_x` by `(lamni_stitch_y+1) × lamni_piezo_range_y`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. For example, a value of 1 gives two tiles, each shifted by half a step from the center; 2 gives three tiles (one centered, one on each side). With a circular FOV (`tomo_circfov`) the corner tiles can contain far fewer scan points than the center one; the estimated point count shown by `tomo_parameters()` is that of the smallest tile, and the parameters cannot be submitted while it is below the scan minimum (20). Reduce the piezo range or the step size, or adjust the stitching or circular FOV. Same mechanism as flomni's `stitch_x`/`stitch_y`, just under lamni's own parameter names (see `lamni.tomo_scan_projection()`, which passes `stitch_x`/`stitch_y`/`stitch_overlap` straight through to `scans.lamni_fermat_scan()`).
#### Angles from a file (tomo_type 4)
@@ -334,6 +333,8 @@ Only actions from a fixed, reviewed registry can be queued — not arbitrary cod
| --- | --- | --- |
| `move` | Move device(s) to absolute position(s). Only devices on the allow-list below can be targeted. | `positions`: `{device: target}`, e.g. `{"mokev": 6.2}` |
| `optimize_idgap` | Scan the undulator gap over a range and move to the peak. **Not yet implemented (no-op stub).** | `search_range`: mm, default 0.5, range 0–2 |
| `set_keithley_current` | Set the Keithley 2400 current, switching its output on if it is off, then sleep. The queue pauses if the current is not reached, e.g. in voltage compliance. | `current_mA`: mA, range from the device limits; `sleep_s`: s, default 0 |
| `sleep` | Wait. | `seconds`: s, default 60 |
Devices allowed for `move`: `mokev` (energy, keV), `idgap` (undulator gap, mm) — the
same beamline-wide devices as flOMNI's queue. A `move` naming any other device is
@@ -348,6 +349,15 @@ lamni.tomo_queue_add_command(
)
```
Example — change the sample current and wait for it to act, then wait further:
```python
lamni.tomo_queue_add_command(
{"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 120}},
label="1.5 mA",
)
lamni.tomo_queue_add_command({"action": "sleep", "kwargs": {"seconds": 600}}, label="wait 10 min")
```
`tomo_queue_show()` lists command jobs alongside tomogram jobs, e.g.:
```text
[2] pending reconfigure to 6.2 keV CMD move{'positions': {'mokev': 6.2}} > optimize_idgap{'search_range': 0.5} [idem]
@@ -501,10 +511,11 @@ if you want to actually clear the selection, not just remove the hook it points
If the beam size needs to be changed with feedback running, e.g. to switch from near-field to far-field ptychography, following steps can be taken:
1. `dev.loptz.enable_set=True` to enable loptz movements with feedback running
2. `umvr(dev.lopz,_value_)` move loptz to the desired position.
3. `lamni._manual_shift_x/y = _value_` correct the stage run out from motion along the optical axis (units of um). The exact value can be checked by comparing feature positions in projections before/after adjusting loptz.
4. Potentially correct the alignment of the OSA
1. `umv(dev.loptz,_value_)` move loptz to the desired position. loptz stays movable with feedback running. Check the OSA clearance with `lamni.lfzp_info()` first.
2. `lamni.loptz_shift_x/y = _value_` correct the stage run out from motion along the optical axis (units of mm, not rotated with the angle). The exact value can be checked by comparing feature positions in projections before/after adjusting loptz. These shifts are reset to zero at the x-ray eye alignment, where the loptz position is stored as `lamni.loptz_shift_reference`. Unlike `manual_shift_x/y` they are not saved with tomo queue jobs.
3. Potentially correct the alignment of the OSA
Both steps can also be queued between tomograms with the tomo queue command action `move_loptz_with_shift` (loptz target and new `loptz_shift_x/y`). It refuses the move if the OSA at its current losaz position would collide with the sample holder.
#### BEC tips
-2
View File
@@ -4,8 +4,6 @@ OMNY is a microscope setup for 3D mesurements via ptychographic X-ray computed t
## HowTo OMNY
… a step-by-step guide for _beamline staff and expert users_.
### Change to a new sample
The sample storage, shuttles and parking positions are described in detail [here](omny.md#sample-storage-and-transfer).
+1 -1
View File
@@ -8,7 +8,7 @@ neutralization is needed. See TOMO_QUEUE_TESTING.md section 3.
Deliberately does NOT call bec.config.load_demo_config()/
update_session_with_file(): assumes the running sim session already has
device_configs/simulated_omny/simulated_flomni.yaml loaded.
device_configs/simulated_omny/simulated_bl_simulated_flomni.yaml loaded.
"""
from __future__ import annotations
@@ -1,7 +1,7 @@
"""Step 2 -- command jobs. See AI_docs/TOMO_QUEUE_COMMAND_JOBS_PLAN.md.
ccm_energy/idgap (the plain "move" allow-list) aren't in the flomni sim
config (simulated_flomni.yaml) -- only on the real front end
config (simulated_bl_simulated_flomni.yaml) -- only on the real front end
(bl_frontend.yaml/bl_optics_hutch.yaml). Tests that only exercise add-time
validation/rendering (no tomo_queue_execute()) reference idgap for that
reason, while tests that actually execute the queue use the dedicated
@@ -43,3 +43,158 @@ def test_slits_show_all_reads_every_slit_device_cached(capsys):
for dev in devices.values():
dev.read.assert_called_once_with(cached=True)
assert "cSAXS Slits Overview" in capsys.readouterr().out
def _slit_devices(displayed: dict, epics_offset=0.1, smaract_offset=0.2):
devices = {}
for prefix in cSAXSSlits._SLIT_LABELS:
smaract = prefix in ("sl4", "sl5", "sl6")
for suffix in ("xc", "xs", "yc", "ys"):
name = f"{prefix}{suffix}"
dev = mock.MagicMock()
dev.read.return_value = {name: {"value": displayed[suffix]}}
if smaract:
cls = "VirtualSlitWidth" if suffix.endswith("s") else "VirtualSlitCenter"
dev._config = {
"deviceClass": f"ophyd_devices.devices.virtual_slit.{cls}",
"deviceConfig": {"offset": smaract_offset},
}
else:
dev._config = {"deviceClass": "ophyd_devices.EpicsMotorEC", "deviceConfig": {}}
dev.user_offset.get.return_value = epics_offset
devices[name] = dev
return devices
def test_slits_calibration_report_offsets(capsys):
displayed = {"xc": 0.03, "xs": 1.05, "yc": -0.02, "ys": 0.9}
slits = _make_slits(_slit_devices(displayed))
result = slits.slits_calibration_report(full=False)
assert len(result) == 24
# EPICS: new user_offset = old + (target - displayed)
assert abs(result["sl1xc"] - (0.1 - 0.03)) < 1e-12
assert abs(result["sl2xs"] - (0.1 + 1.0 - 1.05)) < 1e-12
assert abs(result["sl3yc"] - (0.1 + 0.02)) < 1e-12
# SmarAct: new config offset = old + (target - displayed)
assert abs(result["sl4ys"] - (0.2 + 1.0 - 0.9)) < 1e-12
assert abs(result["sl6xc"] - (0.2 - 0.03)) < 1e-12
out = capsys.readouterr().out
assert "dev.sl1xc.user_offset.put(0.070000)" in out
assert "sl4ys: offset: 0.300000" in out
assert "dev.sl4" not in out
assert "Beamline state" not in out
def test_slits_calibration_report_uses_fresh_reads_and_target_width():
displayed = {"xc": 0.0, "xs": 2.0, "yc": 0.0, "ys": 2.0}
devices = _slit_devices(displayed, epics_offset=0.0, smaract_offset=0.0)
slits = _make_slits(devices)
result = slits.slits_calibration_report(full=False, target_width=2.0)
assert all(abs(v) < 1e-12 for v in result.values())
devices["sl1xc"].read.assert_called_with(cached=False)
def test_slits_calibration_report_missing_device_and_save(tmp_path):
displayed = {"xc": 0.0, "xs": 1.0, "yc": 0.0, "ys": 1.0}
devices = _slit_devices(displayed)
del devices["sl5xc"]
slits = _make_slits(devices)
fname = tmp_path / "sub" / "state.txt"
result = slits.slits_calibration_report(full=False, save=True, filename=str(fname))
assert result["sl5xc"] is None
text = fname.read_text()
assert "could not compute" in text and "sl5xc" in text
assert "Slit Calibration Report" in text
def test_slits_calibration_report_full_dump(capsys):
displayed = {"xc": 0.0, "xs": 1.0, "yc": 0.0, "ys": 1.0}
slit_devs = _slit_devices(displayed)
devices = mock.MagicMock()
devices.__getitem__.side_effect = slit_devs.__getitem__
kb = mock.MagicMock()
kb.name = "kbvpitch"
kb.enabled = True
kb.read.return_value = {"kbvpitch": {"value": 3.25}}
kb.read_configuration.return_value = {"kbvpitch_user_offset": {"value": -0.5}}
disabled = mock.MagicMock()
disabled.name = "disabled_dev"
disabled.enabled = False
devices.get_devices_with_tags.side_effect = lambda tag: (
[kb, disabled] if tag == "bl_optics_hutch" else []
)
client = mock.MagicMock()
client.device_manager.devices = devices
slits = cSAXSSlits(client)
slits.slits_calibration_report(full=True)
out = capsys.readouterr().out
assert "---------- bl_optics_hutch ----------" in out
assert "kbvpitch_user_offset" in out and "-0.5" in out
assert "3.25" in out
assert "disabled_dev" not in out
kb.read.assert_called_once_with(cached=True)
tags = [c.args[0] for c in devices.get_devices_with_tags.call_args_list]
assert tags == list(cSAXSSlits._BEAMLINE_TAGS)
def _epics_motor(user_parameter: dict | None, live_offset: float, enabled=True):
dev = mock.MagicMock()
dev.enabled = enabled
dev._config = {"deviceClass": "ophyd_devices.EpicsMotorEC", "deviceConfig": {}}
if user_parameter is not None:
dev._config["userParameter"] = user_parameter
dev.user_offset.get.return_value = live_offset
return dev
def test_slits_apply_epics_offsets_only_where_configured_and_different():
devices = {
"sl1xc": _epics_motor({"epics_offset": 0.12}, live_offset=0.0),
"sl1xs": _epics_motor({"epics_offset": 0.5}, live_offset=0.5), # already right
"sl2xc": _epics_motor({"other": 1}, live_offset=0.3), # no epics_offset
"sl2xs": _epics_motor(None, live_offset=0.3), # no userParameter
"sl3xc": _epics_motor({"epics_offset": -1.0}, live_offset=0.0, enabled=False),
}
slits = _make_slits(devices)
written = slits.slits_apply_epics_offsets()
assert written == {"sl1xc": 0.12}
devices["sl1xc"].user_offset.put.assert_called_once_with(0.12)
for name in ("sl1xs", "sl2xc", "sl2xs", "sl3xc"):
devices[name].user_offset.put.assert_not_called()
def test_slits_apply_epics_offsets_survives_device_errors(capsys):
bad = _epics_motor({"epics_offset": 0.1}, live_offset=0.0)
bad.user_offset.get.side_effect = RuntimeError("no connection")
good = _epics_motor({"epics_offset": 0.2}, live_offset=0.0)
slits = _make_slits({"sl1xc": bad, "sl1yc": good})
written = slits.slits_apply_epics_offsets()
assert written == {"sl1yc": 0.2}
assert "sl1xc: could not apply epics_offset" in capsys.readouterr().out
def test_slits_calibration_report_prints_epics_offset_param_and_mismatch(capsys):
displayed = {"xc": 0.03, "xs": 1.0, "yc": 0.0, "ys": 1.0}
devices = _slit_devices(displayed, epics_offset=0.1)
devices["sl1xc"]._config["userParameter"] = {"epics_offset": 0.25}
slits = _make_slits(devices)
slits.slits_calibration_report(full=False)
out = capsys.readouterr().out
assert "sl1xc: epics_offset: 0.070000" in out
assert "NOTE: sl1xc EPICS offset 0.100000 differs from configured epics_offset 0.250000" in out
@@ -0,0 +1,127 @@
"""csaxs.diagnostics.show_plots(): one waveform per available diagnostic
device, in its own BEC GUI window."""
import types
import csaxs_bec.bec_ipython_client.plugins.cSAXS.diagnostics as diag_module
from csaxs_bec.bec_ipython_client.plugins.cSAXS.diagnostics import cSAXSDiagnostics
class _Waveform:
def __init__(self):
self.title = None
self.curves = []
def plot(self, device_y, signal_y):
self.curves.append((device_y, signal_y))
class _Window:
def __init__(self):
self.widgets = {}
self.deleted = 0
def new(self, widget, object_name, timeout):
assert widget == "Waveform"
self.widgets[object_name] = _Waveform()
return self.widgets[object_name]
def delete_all(self, timeout):
self.deleted += 1
self.widgets = {}
class _Gui:
def __init__(self):
self.windows = {}
def new(self, name, timeout):
self.windows[name] = _Window()
setattr(self, name, self.windows[name])
def _diagnostics(monkeypatch, devices):
fake_dev = types.SimpleNamespace(**{d: object() for d in devices})
monkeypatch.setattr(diag_module, "dev", fake_dev, raising=False)
monkeypatch.setattr(diag_module.time, "sleep", lambda _s: None)
gui = _Gui()
return cSAXSDiagnostics(types.SimpleNamespace(gui=gui)), gui
_ALL = [
"xbpm2_slowrb",
"xbpm3_slowrb",
"xbim_slowrb",
"beamstop_intensity",
"diode_horizontal_xbox1_slowrb",
"diode_vertical_xbox1_slowrb",
]
def test_show_plots_default_is_xbpm3_blades_and_xbim(monkeypatch):
diagnostics, gui = _diagnostics(monkeypatch, _ALL)
diagnostics.show_plots()
window = gui.windows["csaxs_diagnostics"]
assert list(window.widgets) == ["xbpm3_plot", "xbim_plot"]
assert window.widgets["xbpm3_plot"].curves == [
("xbpm3_slowrb", "xbpm3_slowrb_left_top"),
("xbpm3_slowrb", "xbpm3_slowrb_right_top"),
("xbpm3_slowrb", "xbpm3_slowrb_left_bot"),
("xbpm3_slowrb", "xbpm3_slowrb_right_bot"),
]
assert window.widgets["xbim_plot"].curves == [("xbim_slowrb", "xbim_slowrb_signal")]
assert window.widgets["xbim_plot"].title.startswith("XBIM")
def test_show_plots_extra_and_only(monkeypatch):
diagnostics, gui = _diagnostics(monkeypatch, _ALL)
diagnostics.show_plots("xbpm3_position", "polarization")
window = gui.windows["csaxs_diagnostics"]
assert list(window.widgets) == [
"xbpm3_plot",
"xbim_plot",
"xbpm3_position_plot",
"polarization_plot",
]
assert window.widgets["polarization_plot"].curves == [
("diode_horizontal_xbox1_slowrb", "diode_horizontal_xbox1_slowrb_signal"),
("diode_vertical_xbox1_slowrb", "diode_vertical_xbox1_slowrb_signal"),
]
diagnostics.show_plots("beamstop", defaults=False)
assert list(window.widgets) == ["beamstop_plot"]
def test_show_plots_skips_devices_not_in_config(monkeypatch, capsys):
diagnostics, gui = _diagnostics(monkeypatch, ["xbpm3_slowrb"])
diagnostics.show_plots("xbpm2")
assert list(gui.windows["csaxs_diagnostics"].widgets) == ["xbpm3_plot"]
out = capsys.readouterr().out
assert "Skipping plot 'xbim'" in out and "Skipping plot 'xbpm2'" in out
def test_show_plots_unknown_name(monkeypatch, capsys):
diagnostics, gui = _diagnostics(monkeypatch, _ALL)
diagnostics.show_plots("nonsense")
assert gui.windows == {}
assert "Unknown plot(s)" in capsys.readouterr().out
def test_show_plots_again_rebuilds_in_same_window(monkeypatch):
diagnostics, gui = _diagnostics(monkeypatch, _ALL)
diagnostics.show_plots()
diagnostics.show_plots()
window = gui.windows["csaxs_diagnostics"]
assert window.deleted == 1
assert list(window.widgets) == ["xbpm3_plot", "xbim_plot"]
def test_show_plots_without_gui(monkeypatch, capsys):
monkeypatch.setattr(diag_module, "dev", types.SimpleNamespace(), raising=False)
cSAXSDiagnostics(types.SimpleNamespace(gui=None)).show_plots()
assert "No BEC GUI" in capsys.readouterr().out
@@ -7,6 +7,7 @@ range, same mechanism as Flomni's.
"""
import builtins
import types
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
@@ -103,7 +104,7 @@ def _make_cli_lamni(monkeypatch, answers: dict):
def test_cli_rejects_negative_stitch(monkeypatch, capsys):
lamni = _make_cli_lamni(monkeypatch, {"<stitch X>": ["-1", "2"], "<stitch Y>": ["-3", "0"]})
lamni = _make_cli_lamni(monkeypatch, {"<stitch X> (n+1 tiles)": ["-1", "2"], "<stitch Y> (n+1 tiles)": ["-3", "0"]})
lamni.tomo_parameters_cli()
@@ -130,23 +131,23 @@ def test_cli_type1_rejects_fewer_than_8_projections(monkeypatch):
assert lamni._tomo_type1_actual_grid()[2] == 144
def test_cli_type2_enforces_bunch_size_of_at_least_20(monkeypatch, capsys):
def test_cli_type2_enforces_bunch_size_30_to_200(monkeypatch, capsys):
lamni = _make_cli_lamni(
monkeypatch,
{
"Tomography type": ["2"],
"sorted per bunch": ["5", "19", "150", "25"],
"sorted per bunch": ["5", "29", "250", "150"],
"Stop after": ["-1", "2000000", "800"],
},
)
lamni.tomo_parameters_cli()
assert lamni.golden_ratio_bunch_size == 25
assert lamni.golden_ratio_bunch_size == 150
assert lamni.golden_max_number_of_projections == 800
out = capsys.readouterr().out
assert "must be >= 20" in out
assert "must be <= 100" in out
assert "must be >= 30" in out
assert "must be <= 200" in out
assert "must be <= 1000000" in out
@@ -166,3 +167,22 @@ def test_cli_type3_rejects_zero_projections_per_subtomogram(monkeypatch):
lamni.tomo_parameters_cli()
assert lamni.tomo_angle_stepsize == 10.0 # 360 / 36
def test_cli_too_few_points_skips_confirm_and_reprompts_geometry(monkeypatch, capsys):
"""Too few Fermat points (e.g. a starved corner stitch tile): the
parameters cannot be confirmed as they are, and the scan-geometry prompts
repeat until the estimate reaches the minimum."""
lamni = _make_cli_lamni(monkeypatch, {"<piezo range X": ["30", "10"]})
asked = []
lamni.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: asked.append(a) or True)
# estimate follows the piezo range: >20 um -> too few points
lamni._expected_fermat_position_count = lambda: 5 if lamni.lamni_piezo_range_x > 20 else 50
lamni.lamni_piezo_range_x = 25.0
lamni.tomo_parameters_cli()
assert lamni.lamni_piezo_range_x == 10.0
assert not any("correctly set" in str(a) for a in asked)
out = capsys.readouterr().out
assert "Too few Fermat scan points" in out
@@ -421,13 +421,49 @@ def test_tomo_scan_projection_reconstructs_exactly_once(projection_env):
def test_tomo_scan_projection_reconstructs_once_per_projection_not_per_tile(projection_env):
"""With stitching several Fermat scans make up one projection; the
reconstruction entry is written once, after all tiles."""
projection_env.lamni_stitch_x = 1 # 3 x 1 tiles
projection_env.lamni_stitch_x = 1 # 2 x 1 tiles
projection_env.tomo_scan_projection(30.0, _internal=True)
assert len(projection_env.fermat_calls) == 3
assert len(projection_env.fermat_calls) == 2
assert projection_env.reconstruct_calls == [1]
@pytest.mark.parametrize(
"stitch_x, stitch_y, expected",
[
(0, 0, [(0, 0)]),
(1, 0, [(-0.5, 0), (0.5, 0)]),
(2, 0, [(-1, 0), (0, 0), (1, 0)]),
(1, 1, [(-0.5, -0.5), (-0.5, 0.5), (0.5, -0.5), (0.5, 0.5)]),
],
)
def test_tomo_scan_projection_stitch_n_gives_n_plus_1_centered_tiles(
projection_env, stitch_x, stitch_y, expected
):
"""stitch n -> n+1 tiles per direction, symmetric around the scan center."""
projection_env.lamni_stitch_x = stitch_x
projection_env.lamni_stitch_y = stitch_y
projection_env.tomo_scan_projection(30.0, _internal=True)
tiles = [(kw["stitch_x"], kw["stitch_y"]) for kw in projection_env.fermat_calls]
assert tiles == expected
def test_tomo_scan_projection_prints_base_shift(projection_env, capsys):
from csaxs_bec.scans.lamni_components import LamNIComponents
# stored in um (X-ray eye shift_xy); tomo_fovx/y_offset return mm
projection_env.client.set_global_var("tomo_fov_offset", [100.0, 200.0])
projection_env.tomo_scan_projection(30.0, _internal=True)
rot_x, rot_y = LamNIComponents.lamni_compute_scan_center(0.1, 0.2, 30.0)
out = capsys.readouterr().out
assert (
f"Base shift: x=0.100000 mm, y=0.200000 mm at 0 deg -> "
f"x={rot_x:.6f} mm, y={rot_y:.6f} mm @ angle=30.0"
) in out
def test_tomo_scan_projection_can_skip_reconstruction(projection_env):
projection_env.tomo_scan_projection(30.0, _internal=True, _reconstruct=False)
@@ -131,7 +131,7 @@ def test_golden_ratio_report_does_not_use_the_stale_type1_grid(report_lamni):
def test_golden_ratio_endless_scan_reports_manual_stop(report_lamni):
report_lamni.tomo_type = 2
report_lamni.golden_max_number_of_projections = 0
report_lamni.golden_ratio_bunch_size = 20
report_lamni.golden_ratio_bunch_size = 30
report = _report(report_lamni)
@@ -0,0 +1,55 @@
"""lamni.reset_correction() must clear both lookup-table iterations, so a
correction 2 from the previous sample is not applied to the next one."""
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
class FakeClient:
"""Minimal in-memory stand-in for BEC's global-var store."""
def __init__(self):
self._vars = {}
def get_global_var(self, key):
return self._vars.get(key)
def set_global_var(self, key, value):
self._vars[key] = value
def make_lamni():
obj = object.__new__(LamNI)
obj.client = FakeClient()
return obj
def _fill(lamni):
lamni.corr_pos_x, lamni.corr_pos_y, lamni.corr_angle = [0.1], [0.2], [0.0]
lamni.corr_pos_x_2, lamni.corr_pos_y_2, lamni.corr_angle_2 = [0.3], [0.4], [90.0]
def test_reset_correction_clears_both_iterations():
lamni = make_lamni()
_fill(lamni)
lamni.reset_correction()
assert lamni.corr_pos_x == lamni.corr_pos_y == lamni.corr_angle == []
assert lamni.corr_pos_x_2 == lamni.corr_pos_y_2 == lamni.corr_angle_2 == []
assert lamni.compute_additional_correction_2(90.0) == (0, 0)
def test_reset_correction_2_keeps_iteration_1():
lamni = make_lamni()
_fill(lamni)
lamni.reset_correction_2()
assert lamni.corr_pos_x == [0.1]
assert lamni.corr_pos_x_2 == []
def test_existing_alignment_reports_correction_2():
lamni = make_lamni()
lamni.corr_pos_x_2 = [0.3]
assert "fine-alignment correction 2" in lamni._existing_alignment()
@@ -93,7 +93,7 @@ def test_tomo_alignment_scan_aborts_without_xray_eye_fit(monkeypatch):
assert lamni._scilog_calls == []
def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch):
def test_tomo_alignment_scan_runs_13_projections_0_to_360(monkeypatch):
lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
calls = []
@@ -105,12 +105,12 @@ def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch):
lamni.tomo_alignment_scan()
expected_angles = list(np.linspace(0, 360, num=12, endpoint=False))
expected_angles = list(np.linspace(0, 360, num=13))
assert calls == expected_angles
assert len(lamni._scilog_calls) == 1
content, tags = lamni._scilog_calls[0]
assert tags == ["alignmentscan"]
assert "Number of alignment scans: 12" in content, content
assert "Number of alignment scans: 13" in content, content
def test_tomo_alignment_scan_rotates_back_to_zero(monkeypatch):
@@ -138,10 +138,10 @@ def test_write_alignment_scan_numbers_format(tmp_path, monkeypatch):
assert len(lines) == 4
scans = [int(s) for s in lines[0].split()]
angles = [float(a) for a in lines[1].split()]
assert scans == list(range(100, 112))
assert angles == list(np.linspace(0, 360, num=12, endpoint=False))
assert len(lines[2].split()) == 12
assert len(lines[3].split()) == 12
assert scans == list(range(100, 113))
assert angles == list(np.linspace(0, 360, num=13))
assert len(lines[2].split()) == 13
assert len(lines[3].split()) == 13
def _timeout_alarm():
@@ -176,8 +176,8 @@ def test_tomo_alignment_scan_retries_timed_out_angle_and_records_real_scan_numbe
lamni.tomo_alignment_scan()
expected_angles = list(np.linspace(0, 360, num=12, endpoint=False))
assert len(calls) == 13, "the timed-out angle must be re-acquired"
expected_angles = list(np.linspace(0, 360, num=13))
assert len(calls) == 14, "the timed-out angle must be re-acquired"
assert calls[2] == calls[3] == expected_angles[2]
log_file = tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt"
@@ -185,10 +185,10 @@ def test_tomo_alignment_scan_retries_timed_out_angle_and_records_real_scan_numbe
scans = [int(s) for s in lines[0].split()]
# scans 100, 101 = angles 0, 1; 102 = the failed attempt at angle 2 (must be
# skipped); 103.. = the retry and the remaining angles
assert scans == [100, 101] + list(range(103, 113))
assert scans == [100, 101] + list(range(103, 114))
assert [float(a) for a in lines[1].split()] == expected_angles
content, _tags = lamni._scilog_calls[0]
assert "Number of alignment scans: 12" in content
assert "Number of alignment scans: 13" in content
def test_tomo_alignment_scan_projections_are_not_queued_for_reconstruction(monkeypatch):
@@ -201,5 +201,96 @@ def test_tomo_alignment_scan_projections_are_not_queued_for_reconstruction(monke
lamni.tomo_alignment_scan()
assert len(kwargs_seen) == 12
assert len(kwargs_seen) == 13
assert all(kw.get("_reconstruct") is False for kw in kwargs_seen)
def test_write_alignment_scan_numbers_steps_by_stitch_tiles(tmp_path, monkeypatch):
"""With stitching each angle is (sx+1)*(sy+1) scans; the file must list
the first scan of each angle's stitch series."""
lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
lamni.client.set_global_var("lamni_stitch_x", 1)
lamni.client.set_global_var("lamni_stitch_y", 1)
monkeypatch.setattr(
"os.path.expanduser",
lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p,
)
log_file = tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt"
lamni.write_alignment_scan_numbers(100)
assert [int(s) for s in log_file.read_text().split("\n")[0].split()] == list(
range(100, 152, 4)
)
# partial actual list (angle 1 retried, +1 scan): rest predicted from it
lamni.write_alignment_scan_numbers(100, scans=[100, 110])
assert [int(s) for s in log_file.read_text().split("\n")[0].split()] == [100] + list(
range(110, 158, 4)
)
def test_tomo_alignment_scan_with_stitching_records_first_scan_per_angle(tmp_path, monkeypatch):
lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
lamni.client.set_global_var("lamni_stitch_x", 1)
lamni.client.set_global_var("lamni_stitch_y", 1)
monkeypatch.setattr(
"os.path.expanduser",
lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p,
)
def _projection(angle, _internal=False, **kw):
builtins.__dict__["bec"].queue.next_scan_number += 4
lamni.tomo_scan_projection = _projection
lamni.tomo_alignment_scan()
log_file = tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt"
scans = [int(s) for s in log_file.read_text().splitlines()[0].split()]
assert scans == list(range(100, 152, 4))
def test_write_alignment_scan_numbers_lines_3_4_are_x_and_y_in_um(tmp_path, monkeypatch):
"""BEC_ptycho_align reads line 3 as scan_xpos and line 4 as scan_ypos, in
microns; the X-ray eye fit is stored in microns ([[Ax,Bx,Cx],[Ay,By,Cy]])."""
lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 12.5], [0.0, 0.0, -3.25]])
monkeypatch.setattr(
"os.path.expanduser",
lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p,
)
lamni.write_alignment_scan_numbers(100)
lines = (tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt").read_text().splitlines()
assert [float(v) for v in lines[2].split()] == pytest.approx([12.5] * 13)
assert [float(v) for v in lines[3].split()] == pytest.approx([-3.25] * 13)
def test_tomo_alignment_scan_number_of_projections(tmp_path, monkeypatch):
lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
monkeypatch.setattr(
"os.path.expanduser",
lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p,
)
calls = []
def _fake_projection(angle, _internal=False, **kw):
calls.append(angle)
builtins.__dict__["bec"].queue.next_scan_number += 1
lamni.tomo_scan_projection = _fake_projection
lamni.tomo_alignment_scan(number_of_projections=5)
assert calls == [0.0, 90.0, 180.0, 270.0, 360.0]
lines = (tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt").read_text().splitlines()
assert [int(s) for s in lines[0].split()] == [100, 101, 102, 103, 104]
assert [float(a) for a in lines[1].split()] == [0.0, 90.0, 180.0, 270.0, 360.0]
@pytest.mark.parametrize("n", [0, 1, 2.5])
def test_tomo_alignment_scan_rejects_invalid_number_of_projections(monkeypatch, n):
lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
lamni.tomo_scan_projection = lambda angle, **kw: pytest.fail("must not scan")
with pytest.raises(ValueError):
lamni.tomo_alignment_scan(number_of_projections=n)
@@ -537,7 +537,7 @@ def _fake_golden(ii, bunch_size, maxangle=360, reverse=True):
def make_lamni_for_golden_resume(monkeypatch, tomo_type: int) -> LamNI:
lamni = make_lamni_for_tomo_scan(monkeypatch, 45.0, active_account="")
lamni.tomo_type = tomo_type
lamni.golden_ratio_bunch_size = 20
lamni.golden_ratio_bunch_size = 30
lamni.golden_projections_at_0_deg_for_damage_estimation = 1
lamni.golden_max_number_of_projections = 21
lamni.add_sample_database = lambda *a, **k: 0
@@ -219,10 +219,8 @@ def test_compute_fermat_positions_lamni_matches_scan_class_above_threshold():
def test_compute_fermat_positions_lamni_uses_live_stitch_tile():
"""Unlike the CLI's tomo_parameters() (which always estimates the
center tile as a quick summary), the GUI has the actual stitch_x/y
fields right there, so it should reflect whichever tile is currently
being edited, not always stitch=0."""
"""The estimate is the smallest stitch tile's count, so it depends on
the stitch_x/y being edited, not always the center tile."""
base_params = {
"lamni_piezo_range_x": 20.0,
"lamni_piezo_range_y": 20.0,
@@ -253,7 +251,7 @@ def test_compute_fermat_positions_lamni_zero_step_is_safe():
# ── golden bunch-size floor + manual-shift units ────────────────────────────
#
# lamni's golden_ratio_bunch_size setter/CLI reject < 20, but the shared GUI
# lamni's golden_ratio_bunch_size setter/CLI reject values outside 30..200 (was < 20), but the shared GUI
# spinbox allowed 1..100: a GUI-queued job with a smaller value made
# tomo_queue_execute()'s setattr() raise ValueError and pause the queue.
# The manual-shift fields were labelled um (+-1000) but lamni's manual shift is
@@ -272,12 +270,12 @@ def _bare_widget_for(setup: str):
return widget
def test_lamni_bunch_size_spinbox_floor_is_20():
def test_lamni_bunch_size_spinbox_range_is_30_to_200():
widget = _bare_widget_for("lamni")
box = widget._build_type23_section() # keep a reference: it owns the spinboxes
assert box is not None
assert widget._pw["golden_ratio_bunch_size"].minimum() == 20
assert widget._pw["golden_ratio_bunch_size"].maximum() == 100
assert widget._pw["golden_ratio_bunch_size"].minimum() == 30
assert widget._pw["golden_ratio_bunch_size"].maximum() == 200
def test_flomni_bunch_size_spinbox_is_unchanged():
@@ -285,6 +283,7 @@ def test_flomni_bunch_size_spinbox_is_unchanged():
box = widget._build_type23_section() # keep a reference: it owns the spinboxes
assert box is not None
assert widget._pw["golden_ratio_bunch_size"].minimum() == 1
assert widget._pw["golden_ratio_bunch_size"].maximum() == 100
def test_lamni_gui_bunch_size_floor_matches_the_setter():
@@ -304,9 +303,14 @@ def test_lamni_gui_bunch_size_floor_matches_the_setter():
lamni.client = FakeClient()
floor = SETUP_PROFILES["lamni"]["golden_bunch_size_min"]
ceiling = SETUP_PROFILES["lamni"]["golden_bunch_size_max"]
lamni.golden_ratio_bunch_size = floor # must not raise
lamni.golden_ratio_bunch_size = ceiling # must not raise
with pytest.raises(ValueError):
lamni.golden_ratio_bunch_size = floor - 1
with pytest.raises(ValueError):
lamni.golden_ratio_bunch_size = ceiling + 1
def test_lamni_manual_shift_fields_are_in_mm():
@@ -326,3 +330,25 @@ def test_flomni_manual_shift_fields_are_in_um():
assert "µm" in label
assert by_key["manual_shift_x"] == (-10.0, 10.0)
assert by_key["manual_shift_y"] == (-50.0, 50.0)
def test_compute_fermat_positions_lamni_blocks_on_starved_corner_tile():
"""Center tile fine, corner tiles below the minimum (3x3 tiles, 36 um
circular FOV): the estimate must be the corner count, so submit is
blocked as in the non-stitched case."""
params = {
"lamni_piezo_range_x": 20.0,
"lamni_piezo_range_y": 20.0,
"tomo_shellstep": 1.0,
"tomo_stitch_overlap": 0.2,
"tomo_circfov": 36.0,
"lamni_stitch_x": 2,
"lamni_stitch_y": 2,
}
count, min_positions = _compute_fermat_positions_lamni(params)
assert count < min_positions
count, min_positions = _compute_fermat_positions_lamni(
dict(params, lamni_piezo_range_x=10.0, lamni_piezo_range_y=10.0)
)
assert count >= min_positions
@@ -9,6 +9,8 @@ tomo-queue backend actually needs: self.client + _init_tomo_queue().
import types
import pytest
import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
import csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin as tomo_queue_mixin
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, LamNIError, _ProgressProxy
@@ -193,6 +195,85 @@ def test_tomo_queue_add_command_rejects_non_movable_device():
assert "not queue-movable" in str(exc)
class _FakeLoptzDev:
"""dev stand-in for move_loptz_with_shift: loptz with limits and the
OSA collision_offset, losaz at a given readback."""
def __init__(self, losaz_pos=0.0, collision_offset=87.87):
self.loptz = types.SimpleNamespace(
name="loptz",
limits=(70.0, 83.0),
user_parameter={"collision_offset": collision_offset},
)
self.losaz = types.SimpleNamespace(
name="losaz",
user_parameter={"in": 5.0},
readback=types.SimpleNamespace(get=lambda: losaz_pos),
)
def __getitem__(self, name):
return getattr(self, name)
def _setup_loptz_action(monkeypatch, fake_dev):
import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin as optics_module
lamni = make_lamni()
lamni.device_manager = types.SimpleNamespace(devices={"loptz": fake_dev.loptz})
moved = []
monkeypatch.setattr(lamni_module, "dev", fake_dev, raising=False)
monkeypatch.setattr(optics_module, "dev", fake_dev, raising=False)
monkeypatch.setattr(lamni_module, "umv", lambda *a: moved.append(a), raising=False)
return lamni, moved
def test_tomo_queue_move_loptz_with_shift_moves_and_sets_shift(monkeypatch):
fake_dev = _FakeLoptzDev(losaz_pos=0.0)
lamni, moved = _setup_loptz_action(monkeypatch, fake_dev)
lamni.tomo_queue_add_command(
{
"action": "move_loptz_with_shift",
"kwargs": {"loptz_target": 78.0, "loptz_shift_x": 0.002, "loptz_shift_y": -0.003},
}
)
lamni.tomo_queue_execute()
assert moved == [(fake_dev.loptz, 78.0)]
assert lamni.loptz_shift_x == 0.002
assert lamni.loptz_shift_y == -0.003
# manual shift untouched -- tomo jobs snapshot it, so the queue action must not use it
assert lamni.manual_shift_x == 0.0
assert lamni._tomo_queue_proxy.as_list()[0]["status"] == "done"
def test_tomo_queue_move_loptz_with_shift_rejects_target_outside_limits(monkeypatch):
lamni, _moved = _setup_loptz_action(monkeypatch, _FakeLoptzDev())
try:
lamni.tomo_queue_add_command(
{
"action": "move_loptz_with_shift",
"kwargs": {"loptz_target": 90.0, "loptz_shift_x": 0.0, "loptz_shift_y": 0.0},
}
)
assert False, "expected ValueError for a loptz target above the limit"
except ValueError as exc:
assert "above the maximum" in str(exc)
def test_queue_action_move_loptz_refuses_osa_collision(monkeypatch):
# losaz 10 + loptz 78 > collision_offset 87.87 -> collision
lamni, moved = _setup_loptz_action(monkeypatch, _FakeLoptzDev(losaz_pos=10.0))
lamni.loptz_shift_x = 0.5
try:
lamni._queue_action_move_loptz_with_shift(78.0, 0.0, 0.0)
assert False, "expected ValueError for an OSA collision"
except ValueError as exc:
assert "collision" in str(exc)
assert moved == []
assert lamni.loptz_shift_x == 0.5
def test_register_at_each_angle_hook_dispatches_from_at_each_angle():
lamni = make_lamni()
seen = []
@@ -343,3 +424,91 @@ def test_tomo_scan_resume_from_queue_never_prompts():
lamni, calls = _resumable_lamni(status="running", answer=False)
lamni.tomo_scan_resume(from_queue=True)
assert calls == [("scan", (), {"projection_number": 5})]
def test_format_command_step_shows_units():
lamni = make_lamni()
registry = lamni._describe_tomo_queue_actions()
shift_step = {
"action": "move_loptz_with_shift",
"kwargs": {"loptz_target": 78.0, "loptz_shift_x": 0.002, "loptz_shift_y": -0.003},
}
assert tomo_queue_mixin.format_command_step(shift_step, registry) == (
"move_loptz_with_shift(loptz_target=78.0 mm, loptz_shift_x=0.002 mm, "
"loptz_shift_y=-0.003 mm)"
)
move_step = {"action": "move", "kwargs": {"positions": {"ccm_energy": 6.2}}}
assert tomo_queue_mixin.format_command_step(move_step, registry) == "move(ccm_energy=6.2 keV)"
assert tomo_queue_mixin.format_command_step({"action": "noop", "kwargs": {}}) == "noop"
def _setup_keithley_action(monkeypatch):
fake_dev = _FakeDev()
fake_dev.keithley = types.SimpleNamespace(limits=(0.0, 3.0))
lamni = make_lamni()
lamni.device_manager = types.SimpleNamespace(devices={"keithley": fake_dev.keithley})
calls = []
monkeypatch.setattr(lamni_module, "dev", fake_dev, raising=False)
monkeypatch.setattr(lamni_module, "umv", lambda *a: calls.append(("umv", a)), raising=False)
monkeypatch.setattr(lamni_module.time, "sleep", lambda s: calls.append(("sleep", s)))
return lamni, fake_dev, calls
def test_tomo_queue_set_keithley_current_sets_then_sleeps(monkeypatch):
lamni, fake_dev, calls = _setup_keithley_action(monkeypatch)
lamni.tomo_queue_add_command(
{"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 30}}
)
lamni.tomo_queue_execute()
assert calls == [("umv", (fake_dev.keithley, 1.5)), ("sleep", 30)]
assert lamni._tomo_queue_proxy[0]["status"] == "done"
assert lamni._tomo_queue_proxy[0]["idempotent"] is True
def test_tomo_queue_set_keithley_current_without_sleep(monkeypatch):
lamni, fake_dev, calls = _setup_keithley_action(monkeypatch)
lamni._queue_action_set_keithley_current(0.0)
assert calls == [("umv", (fake_dev.keithley, 0.0))]
def test_tomo_queue_set_keithley_current_rejects_out_of_limit(monkeypatch):
lamni, _fake_dev, calls = _setup_keithley_action(monkeypatch)
with pytest.raises(ValueError, match="above the maximum"):
lamni.tomo_queue_add_command(
{"action": "set_keithley_current", "kwargs": {"current_mA": 3.5}}
)
with pytest.raises(ValueError, match="current limits"):
lamni._queue_action_set_keithley_current(3.5)
assert calls == []
def test_tomo_queue_set_keithley_current_without_device_pauses_the_queue(monkeypatch):
lamni, fake_dev, calls = _setup_keithley_action(monkeypatch)
del fake_dev.keithley
lamni.device_manager.devices.clear()
lamni.tomo_queue_add_command(
{"action": "set_keithley_current", "kwargs": {"current_mA": 1.0}}
)
with pytest.raises(ValueError, match="not loaded"):
lamni.tomo_queue_execute()
assert lamni._tomo_queue_proxy[0]["status"] == "incomplete"
assert calls == []
def test_tomo_queue_sleep_action(monkeypatch):
lamni, _fake_dev, calls = _setup_keithley_action(monkeypatch)
lamni.tomo_queue_add_command({"action": "sleep", "kwargs": {"seconds": 5}})
lamni.tomo_queue_execute()
assert calls == [("sleep", 5)]
with pytest.raises(ValueError, match="below the minimum"):
lamni.tomo_queue_add_command({"action": "sleep", "kwargs": {"seconds": -1}})
def test_format_command_step_keithley_units():
lamni = make_lamni()
registry = lamni._describe_tomo_queue_actions()
step = {"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 30}}
assert (
tomo_queue_mixin.format_command_step(step, registry)
== "set_keithley_current(current_mA=1.5 mA, sleep_s=30 s)"
)
@@ -0,0 +1,119 @@
"""ETA idle-time accounting: normal projections (minutes-long ptycho scans)
must not count as idle; real pauses must; a fresh tomo scan starts clean."""
import datetime
import pytest
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import _ProgressProxy as FlomniProgress
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import _ProgressProxy as LamniProgress
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.idle_time import account_projection_gap
T0 = datetime.datetime(2026, 10, 19, 5, 3)
class FakeClient:
def __init__(self):
self._vars = {}
def get_global_var(self, key):
return self._vars.get(key)
def set_global_var(self, key, value):
self._vars[key] = value
def _run(progress, gaps_s):
"""Feed projections separated by *gaps_s*; return the total idle added."""
t = T0
total = account_projection_gap(progress, t)
for gap in gaps_s:
t += datetime.timedelta(seconds=gap)
total += account_projection_gap(progress, t)
return total
def test_steady_minutes_long_projections_are_not_idle():
# the beamtime case: ~4 min per projection, 139 projections
progress = {"heartbeat": None, "accumulated_idle_time": 0.0}
assert _run(progress, [235, 240, 238, 245] * 35) == 0
assert progress["accumulated_idle_time"] == 0
def test_long_pause_is_idle():
progress = {"heartbeat": None, "accumulated_idle_time": 0.0}
_run(progress, [240] * 10)
gaps_before = list(progress["recent_projection_gaps"])
idle = _run_from_heartbeat(progress, [2 * 3600])
assert idle == pytest.approx(2 * 3600 - 240)
assert progress["accumulated_idle_time"] == pytest.approx(idle)
assert progress["recent_projection_gaps"] == gaps_before
def _run_from_heartbeat(progress, gaps_s):
t = datetime.datetime.fromisoformat(progress["heartbeat"])
total = 0.0
for gap in gaps_s:
t += datetime.timedelta(seconds=gap)
total += account_projection_gap(progress, t)
return total
def test_zero_deg_reference_and_repeats_are_not_idle():
progress = {"heartbeat": None, "accumulated_idle_time": 0.0}
assert _run(progress, [240] * 5 + [480, 240, 700, 240]) == 0
def test_startup_gaps_never_idle_and_startup_pause_not_sampled():
progress = {"heartbeat": None, "accumulated_idle_time": 0.0}
assert _run(progress, [45 * 60, 240, 240]) == 0
assert progress["recent_projection_gaps"] == [240, 240]
def test_no_heartbeat_sets_it():
progress = {"heartbeat": None}
assert account_projection_gap(progress, T0) == 0
assert progress["heartbeat"] == T0.isoformat()
def test_gap_samples_are_capped():
progress = {"heartbeat": None, "accumulated_idle_time": 0.0}
_run(progress, [240] * 30)
assert len(progress["recent_projection_gaps"]) == 10
# ── fresh tomo scan resets the bookkeeping ──────────────────────────────────
def test_lamni_new_scan_resets_idle_bookkeeping(monkeypatch):
lamni = object.__new__(LamNI)
lamni.client = FakeClient()
lamni._progress_proxy = LamniProgress(lamni.client)
lamni.progress["accumulated_idle_time"] = 24352.0
lamni.progress["recent_projection_gaps"] = [240.0] * 10
lamni.progress["heartbeat"] = T0.isoformat()
monkeypatch.setattr(LamNI, "tomo_type", 2, raising=False)
monkeypatch.setattr(LamNI, "golden_ratio_bunch_size", 100, raising=False)
monkeypatch.setattr(LamNI, "golden_max_number_of_projections", 300, raising=False)
monkeypatch.setattr(LamNI, "_lamnigui_update_progress", lambda self: None)
lamni._reset_progress_for_new_scan()
assert lamni.progress["accumulated_idle_time"] == 0.0
assert lamni.progress["recent_projection_gaps"] == []
assert lamni.progress["heartbeat"] is None
assert lamni.progress["tomo_start_time"] is not None
def test_flomni_progress_reset_clears_idle_bookkeeping():
progress = FlomniProgress(FakeClient())
progress["accumulated_idle_time"] = 24352.0
progress["recent_projection_gaps"] = [240.0] * 10
progress["heartbeat"] = T0.isoformat()
progress.reset()
assert progress["accumulated_idle_time"] == 0.0
assert progress["recent_projection_gaps"] == []
assert progress["heartbeat"] is None
@@ -0,0 +1,43 @@
"""stitch n -> n+1 tiles per direction, centered on the scan center
(shared by LamNI and flOMNI tomo_scan_projection)."""
import pytest
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import (
stitch_tile_offsets,
)
@pytest.mark.parametrize(
"n, expected",
[
(0, [0]),
(1, [-0.5, 0.5]),
(2, [-1, 0, 1]),
(3, [-1.5, -0.5, 0.5, 1.5]),
],
)
def test_stitch_tile_offsets(n, expected):
assert stitch_tile_offsets(n) == expected
def test_stitch_tile_offsets_negative_is_empty():
assert stitch_tile_offsets(-1) == []
def test_smallest_tile_count_finds_starved_corner_tile():
"""3x3 tiles, 20 um piezo range, 36 um circular FOV: the center tile is
full, but a corner tile holds only a few points (below the scan's
minimum of 20). A smaller piezo range pulls the corners into the circle."""
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import (
lamni_smallest_tile_position_count,
)
center, _ = lamni_smallest_tile_position_count(20, 20, 1.0, 0, 0, 0.2, 36)
corner, tile = lamni_smallest_tile_position_count(20, 20, 1.0, 2, 2, 0.2, 36)
smaller_piezo, _ = lamni_smallest_tile_position_count(10, 10, 1.0, 2, 2, 0.2, 36)
assert center > 100
assert corner < 20
assert abs(tile[0]) == 1 and abs(tile[1]) == 1
assert smaller_piezo >= 20
@@ -0,0 +1,32 @@
"""Full-size ptycho PNGs copied for upload are capped in size: the upload
server rejected large (stitched) probe/spectrum images with HTTP 413."""
import numpy as np
import pytest
PIL = pytest.importorskip("PIL.Image")
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base import (
WebpageGeneratorBase,
)
def test_large_png_is_downscaled_below_cap(tmp_path):
src, dst = tmp_path / "S00085-S00093_probe.png", tmp_path / "out.png"
PIL.fromarray((np.random.default_rng(0).random((800, 1200, 3)) * 255).astype("uint8")).save(src)
cap = 300_000
assert src.stat().st_size > cap
WebpageGeneratorBase._copy_full_capped(src, dst, max_bytes=cap)
assert dst.stat().st_size <= cap
assert PIL.open(dst).size[0] < 1200
def test_small_png_is_copied_unchanged(tmp_path):
src, dst = tmp_path / "S00001_phase.png", tmp_path / "out.png"
PIL.new("RGB", (50, 50)).save(src)
WebpageGeneratorBase._copy_full_capped(src, dst, max_bytes=300_000)
assert dst.read_bytes() == src.read_bytes()
@@ -484,7 +484,7 @@ def test_align_offers_free_ellipse_after_fzp_click_and_removes_it_on_abort(bec_c
# ----------------------------------------------------------------------
# EXPERIMENTAL: rotation-center smear mode (_smear_sweep)
# rotation-center smear mode (_smear_sweep)
# ----------------------------------------------------------------------
@@ -721,7 +721,7 @@ def test_smear_sweep_cancels_report_on_keyboard_interrupt(bec_client_mock):
align.gui.set_smear_active.assert_any_call(False)
def test_find_rotation_center_smear_experimental_switches_live_view_signal(bec_client_mock):
def test_find_rotation_center_smear_switches_live_view_signal(bec_client_mock):
"""Regression guard: the GUI must stay on the composite channel until
AFTER the operator's click is collected, then switch back -- switching
back inside _smear_sweep itself (before the click) would show the
@@ -740,14 +740,14 @@ def test_find_rotation_center_smear_experimental_switches_live_view_signal(bec_c
with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"):
with mock.patch(f"{OMNY_GENERAL_TOOLS}.input", side_effect=["y", "y"]):
with mock.patch.object(align, "_save_smear_calibration_data"):
align.find_rotation_center_smear_experimental()
align.find_rotation_center_smear()
calls = [c.args[0] for c in align.gui.set_live_view_signal.call_args_list]
assert calls == ["smear_preview", "image"]
dev_mock.cam_xeye.live_mode_enabled.put.assert_not_called()
def test_find_rotation_center_smear_experimental_saves_composites_and_clicks(bec_client_mock):
def test_find_rotation_center_smear_saves_composites_and_clicks(bec_client_mock):
"""Composites and clicked centres from every iteration must be archived
for offline analysis -- confirms the data reaching _save_smear_calibration_data,
without touching the real filesystem."""
@@ -766,7 +766,7 @@ def test_find_rotation_center_smear_experimental_saves_composites_and_clicks(bec
with mock.patch(f"{XRAY_EYE_ALIGN}.time.sleep"):
with mock.patch(f"{OMNY_GENERAL_TOOLS}.input", side_effect=["y", "y"]):
with mock.patch.object(align, "_save_smear_calibration_data") as save_mock:
align.find_rotation_center_smear_experimental(sweep_deg=45.0)
align.find_rotation_center_smear(sweep_deg=45.0)
assert len(align.smear_composites) == 1
assert np.array_equal(align.smear_composites[0], frame)
@@ -0,0 +1,62 @@
"""The LamNI keithley and sleep queue actions must be buildable in the command job dialog, which
is generated from the published action registry."""
import os
from unittest import mock
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qtpy.QtWidgets import QApplication, QDoubleSpinBox # noqa: E402
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI # noqa: E402
from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( # noqa: E402
CommandJobBuilderDialog,
)
@pytest.fixture(scope="module")
def qapp():
return QApplication.instance() or QApplication([])
@pytest.fixture
def dialog(qapp):
registry = {
"actions": {
name: {key: spec[key] for key in ("idempotent", "help", "params")}
for name, spec in LamNI._TOMO_QUEUE_ACTIONS.items()
},
"move_devices": {},
}
client = mock.MagicMock()
client.get_global_var.return_value = registry
client.device_manager.devices = {"keithley": mock.MagicMock(limits=(0.0, 3.0))}
return CommandJobBuilderDialog(client)
def _add_step(dialog, action, **values):
dialog._action_combo.setCurrentIndex(dialog._action_combo.findData(action))
for name, value in values.items():
widget, _field = dialog._step_field_widgets[name]
assert isinstance(widget, QDoubleSpinBox)
widget.setValue(value)
dialog._on_add_step()
def test_keithley_and_sleep_steps_build_a_queue_job(dialog):
_add_step(dialog, "set_keithley_current", current_mA=1.5, sleep_s=30)
_add_step(dialog, "sleep", seconds=60)
dialog._on_accept()
assert dialog.job["steps"] == [
{"action": "set_keithley_current", "kwargs": {"current_mA": 1.5, "sleep_s": 30}},
{"action": "sleep", "kwargs": {"seconds": 60}},
]
assert dialog.job["idempotent"] is True
def test_current_field_is_bounded_by_the_device_limits(dialog):
dialog._action_combo.setCurrentIndex(dialog._action_combo.findData("set_keithley_current"))
widget, _field = dialog._step_field_widgets["current_mA"]
assert (widget.minimum(), widget.maximum()) == (0.0, 3.0)
@@ -0,0 +1,90 @@
"""The mouse wheel must not change tomo_params values: users scroll the
panel and fields changed without anyone noticing. The full widget needs a
live BEC client, so this exercises _block_wheel_edits() on a plain scroll
area, the same setup as OMNY_TomoParams._build_ui()."""
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qtpy.QtCore import QPoint, QPointF, Qt # noqa: E402
from qtpy.QtGui import QWheelEvent # noqa: E402
from qtpy.QtWidgets import ( # noqa: E402
QApplication,
QComboBox,
QDoubleSpinBox,
QScrollArea,
QSpinBox,
QVBoxLayout,
QWidget,
)
from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( # noqa: E402
_block_wheel_edits,
)
@pytest.fixture(scope="module")
def qapp():
return QApplication.instance() or QApplication([])
def _wheel(widget, delta=120):
event = QWheelEvent(
QPointF(5, 5),
QPointF(widget.mapToGlobal(QPoint(5, 5))),
QPoint(0, 0),
QPoint(0, delta),
Qt.MouseButton.NoButton,
Qt.KeyboardModifier.NoModifier,
Qt.ScrollPhase.NoScrollPhase,
False,
)
QApplication.sendEvent(widget, event)
def _panel():
scroll = QScrollArea()
scroll.setWidgetResizable(True)
inner = QWidget()
layout = QVBoxLayout(inner)
dspin, ispin, combo = QDoubleSpinBox(), QSpinBox(), QComboBox()
dspin.setRange(0, 100)
dspin.setValue(50)
ispin.setRange(0, 100)
ispin.setValue(50)
combo.addItems(["1", "2", "3"])
combo.setCurrentIndex(1)
for w in (dspin, ispin, combo):
layout.addWidget(w)
scroll.setWidget(inner)
return scroll, inner, dspin, ispin, combo
def test_wheel_changes_values_without_filter(qapp):
# sanity check that the synthetic wheel event does reach the widgets
scroll, _, dspin, ispin, combo = _panel() # keep scroll alive
for w in (dspin, ispin, combo):
_wheel(w, delta=-120)
assert dspin.value() != 50 or ispin.value() != 50 or combo.currentIndex() != 1
def test_wheel_does_not_change_values(qapp):
scroll, inner, dspin, ispin, combo = _panel()
_block_wheel_edits(inner, scroll.viewport())
for w in (dspin, ispin, combo):
_wheel(w)
_wheel(w, delta=-120)
assert dspin.value() == 50
assert ispin.value() == 50
assert combo.currentIndex() == 1
assert dspin.focusPolicy() == Qt.FocusPolicy.StrongFocus
def test_keyboard_still_edits(qapp):
scroll, inner, dspin, _, _ = _panel()
_block_wheel_edits(inner, scroll.viewport())
dspin.stepUp()
assert dspin.value() == 51
@@ -0,0 +1,79 @@
"""Clicking into a tomo_params spin box and typing must replace the value.
Without select-on-focus the cursor sits at the end and digits are appended:
golden-ratio bunch size 100 (range 30..200) + "5" -> 1005, which the spin
box silently rejects, so only the arrows could change it. The full widget
needs a live BEC client, so this exercises _select_all_on_focus() on a plain
panel, the same setup as OMNY_TomoParams._build_ui()."""
import os
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qtpy.QtCore import QPoint, Qt # noqa: E402
from qtpy.QtTest import QTest # noqa: E402
from qtpy.QtWidgets import ( # noqa: E402
QApplication,
QDoubleSpinBox,
QLineEdit,
QSpinBox,
QVBoxLayout,
QWidget,
)
from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( # noqa: E402
_select_all_on_focus,
)
@pytest.fixture(scope="module")
def qapp():
return QApplication.instance() or QApplication([])
def _panel():
panel = QWidget()
layout = QVBoxLayout(panel)
# takes the initial focus, so clicking a spin box is a real focus change
layout.addWidget(QLineEdit())
bunch = QSpinBox()
bunch.setRange(30, 200)
bunch.setValue(100)
dspin = QDoubleSpinBox()
dspin.setRange(0, 1e6)
dspin.setDecimals(0)
dspin.setValue(1000)
layout.addWidget(bunch)
layout.addWidget(dspin)
return panel, bunch, dspin
def _show(qapp, panel):
panel.show()
panel.activateWindow()
qapp.processEvents()
def _click_and_type(qapp, spin, text):
le = spin.lineEdit()
QTest.mouseClick(le, Qt.MouseButton.LeftButton, pos=QPoint(le.width() - 3, le.height() // 2))
qapp.processEvents()
QTest.keyClicks(spin, text)
qapp.processEvents()
return spin.text()
def test_click_and_type_is_rejected_without_filter(qapp):
# documents the original problem: the appended digit exceeds the maximum
panel, bunch, _ = _panel()
_show(qapp, panel)
assert _click_and_type(qapp, bunch, "5") == "100"
def test_click_and_type_replaces_value(qapp):
panel, bunch, dspin = _panel()
_select_all_on_focus(panel)
_show(qapp, panel)
assert _click_and_type(qapp, bunch, "150") == "150"
assert _click_and_type(qapp, dspin, "42") == "42"
+191
View File
@@ -0,0 +1,191 @@
from unittest import mock
import pytest
from ophyd.utils import LimitError
from ophyd_devices.utils.controller import ControllerCommunicationError
from csaxs_bec.devices.keithley.keithley_2400 import (
Keithley2400,
Keithley2400Controller,
Keithley2400Error,
)
class FakeKeithleySocket:
"""Minimal 2400: replies CR LF terminated and split in two segments, as seen through the bridge."""
instances = []
output = False
level = 0.0
source_function = "VOLT"
compliance = 21.0
max_volts = 100.0 # load limit, above it the instrument sits in compliance
resistance = 1000.0
def __init__(self, host, port, socket_timeout=2):
self.is_open = False
self.commands = []
self.replies = []
type(self).instances.append(self)
def open(self, timeout=10):
self.is_open = True
def close(self):
self.is_open = False
def receive(self, buffer_length=1024):
return self.replies.pop(0) if self.replies else b""
def _measured(self):
volts = min(self.level * self.resistance, self.compliance, self.max_volts)
return volts, volts / self.resistance
def put(self, msg):
command = msg.decode().strip()
self.commands.append(command)
reply = {
"*IDN?": "KEITHLEY INSTRUMENTS INC.,MODEL 2400,4009094,C32",
":OUTP?": "1" if self.output else "0",
":SOUR:FUNC?": self.source_function,
":SOUR:CURR?": f"{self.level:E}",
":SENS:VOLT:PROT?": f"{self.compliance:E}",
":SYST:ERR?": '0,"No error"',
}.get(command)
if command == ":READ?":
assert self.output, "the 2400 refuses :READ? with the output off"
reply = "{:E},{:E}".format(*self._measured())
elif command == ":SENS:VOLT:PROT:TRIP?":
reply = "1" if self._measured()[0] < self.level * self.resistance else "0"
elif command in (":OUTP ON", ":OUTP OFF"):
type(self).output = command == ":OUTP ON"
elif command == ":SOUR:FUNC CURR":
type(self).source_function = "CURR"
elif command.startswith(":SOUR:CURR ") and "RANG" not in command:
type(self).level = float(command.split()[1])
elif command.startswith(":SENS:VOLT:PROT "):
type(self).compliance = float(command.split()[1])
if reply is not None:
data = f"{reply}\r\n".encode()
self.replies += [part for part in (data[:3], data[3:]) if part]
@pytest.fixture
def socket_cls():
"""A fresh socket class per test, the instrument state lives on the class."""
Keithley2400Controller._reset_controller()
return type("Socket", (FakeKeithleySocket,), {"instances": []})
@pytest.fixture
def keithley(socket_cls):
dev = Keithley2400(
name="keithley", socket_cls=socket_cls, host="fake", port=1, device_manager=mock.MagicMock()
)
yield dev
dev.controller.off(update_config=False)
def commands(socket_cls):
return socket_cls.instances[-1].commands
def test_connect_with_output_off_configures_and_keeps_it_off(keithley, socket_cls):
keithley.wait_for_connection()
assert socket_cls.source_function == "CURR"
assert socket_cls.compliance == 5.0
assert socket_cls.output is False
assert not any("OUTP ON" in cmd or "MEAS" in cmd or cmd == "*RST" for cmd in commands(socket_cls))
def test_connect_with_output_on_leaves_the_source_alone(keithley, socket_cls):
socket_cls.output, socket_cls.level, socket_cls.source_function = True, 2e-3, "CURR"
keithley.wait_for_connection()
assert (socket_cls.output, socket_cls.level, socket_cls.compliance) == (True, 2e-3, 21.0)
sent = commands(socket_cls)
assert not any(cmd.startswith((":SOUR:FUNC ", ":SOUR:CURR", ":OUTP ", "*RST")) for cmd in sent)
assert keithley.readback.get() == pytest.approx(2.0)
assert keithley.user_setpoint.get() == pytest.approx(2.0)
def test_connect_refuses_voltage_source_with_output_on(keithley, socket_cls):
socket_cls.output = True
with pytest.raises(Keithley2400Error, match="source function VOLT"):
keithley.wait_for_connection()
assert socket_cls.output is True
def test_connect_refuses_other_instrument(keithley, socket_cls, monkeypatch):
monkeypatch.setattr(keithley.controller, "socket_put_and_receive", lambda val: "SOMETHING ELSE")
keithley.controller.on()
with pytest.raises(Keithley2400Error, match="identification"):
keithley.controller.configure(5.0, 10.0, False)
def test_readbacks_with_output_off_do_not_trigger(keithley, socket_cls):
keithley.wait_for_connection()
assert keithley.readback.get() == 0.0
assert keithley.voltage.get() == 0.0
assert ":READ?" not in commands(socket_cls)
def test_move_programs_the_level_before_the_output(keithley, socket_cls):
keithley.wait_for_connection()
keithley.move(1.5).wait(timeout=5)
sent = commands(socket_cls)
assert sent.index(":SOUR:CURR 1.500000E-03") < sent.index(":OUTP ON")
assert keithley.readback.get() == pytest.approx(1.5)
assert keithley.voltage.get() == pytest.approx(1.5)
assert keithley.output_enabled.get() == 1
def test_move_outside_limits_changes_nothing(keithley, socket_cls):
keithley.wait_for_connection()
with pytest.raises(LimitError):
keithley.move(3.1)
with pytest.raises(LimitError):
keithley.user_setpoint.put(3.1)
assert socket_cls.level == 0.0
assert socket_cls.output is False
def test_move_in_compliance_fails_with_the_reason(keithley, socket_cls, monkeypatch):
monkeypatch.setattr(keithley, "SETTLE_TIMEOUT", 0.1)
keithley.wait_for_connection()
with pytest.raises(Keithley2400Error, match="compliance"):
socket_cls.max_volts = 1.0
keithley.move(2.0).wait(timeout=5)
def test_output_off_keeps_the_level(keithley, socket_cls):
keithley.wait_for_connection()
keithley.move(1.0).wait(timeout=5)
keithley.output_off()
assert socket_cls.output is False
assert socket_cls.level == pytest.approx(1e-3)
assert keithley.readback.get() == 0.0
def test_status_prints_the_state(keithley, socket_cls, capsys):
keithley.wait_for_connection()
keithley.status()
assert "Compliance voltage" in capsys.readouterr().out
def test_stop_and_destroy_leave_the_output_on(socket_cls):
dev = Keithley2400(
name="keithley", socket_cls=socket_cls, host="fake", port=1, device_manager=mock.MagicMock()
)
dev.wait_for_connection()
dev.move(1.0).wait(timeout=5)
dev.stop()
dev.destroy()
assert socket_cls.output is True
assert socket_cls.level == pytest.approx(1e-3)
def test_closed_connection_raises(keithley, socket_cls):
keithley.controller.on()
socket_cls.instances[-1].put = lambda msg: None
with pytest.raises(ControllerCommunicationError):
keithley.controller.socket_put_and_receive(":OUTP?")
+23 -2
View File
@@ -141,8 +141,9 @@ def patched_dm_bpm(dm_with_devices):
return dm
@pytest.fixture
def bpm(patched_dm_bpm):
@pytest.fixture(params=[False])
def bpm(request, patched_dm_bpm):
invert = request.param
name = "bpm"
bpm_config = Device(
name=name,
@@ -165,6 +166,7 @@ def bpm(patched_dm_bpm):
right_top_ref="right_top",
right_bot_ref="right_bot",
left_bot_ref="left_bot",
invert=invert,
device_manager=patched_dm_bpm,
)
patched_dm_bpm.devices._add_device(bpm.name, bpm)
@@ -239,3 +241,22 @@ def test_bpm_positions(bpm):
assert bpm.pos_x.get() == 0
assert bpm.pos_y.get() == 0
assert bpm.diagonal.get() == 1
@pytest.mark.parametrize("bpm", [True], indirect=True)
def test_bpm_invert_negates_blades_keeps_positions(bpm):
"""invert=True: negative amplifier currents read as positive blade
signals and intensity; the ratio-based positions are unchanged."""
devices = bpm.device_manager.devices
devices["left_top"].put(-1)
devices["right_top"].put(-3)
devices["right_bot"].put(-2)
devices["left_bot"].put(-2)
assert bpm.left_top.get() == 1
assert bpm.right_top.get() == 3
assert bpm.intensity.get() == 8
# same ratios as the non-inverted signals would give
assert bpm.pos_x.get() == 0.25
assert bpm.pos_y.get() == 0
assert bpm.diagonal.get() == 0.25
+22
View File
@@ -101,6 +101,28 @@ def test_feedback_enable_without_reset_raises(rt_flomni):
assert mock.call(b"l3\n") in rt_flomni.sock.put.mock_calls
@pytest.mark.parametrize(
"first,second,expected", [(10, 10, "toolow"), (10, 200, "ok")]
)
def test_laser_tracker_signalstrength_rereads_after_too_low(rt_flomni, first, second, expected):
"""A too-low signal gets a second chance after 1 s: the signal must be
re-read, not the stale first value compared again."""
rt_flomni.device_manager.devices.rtx.user_parameter.get.side_effect = {
"min_signal": 50,
"low_signal": 100,
}.get
with (
mock.patch.object(rt_flomni, "laser_tracker_check_enabled", return_value=True),
mock.patch.object(rt_flomni, "laser_tracker_wait_on_target"),
mock.patch.object(
rt_flomni, "read_ssi_interferometer", side_effect=[first, second]
) as read_ssi,
mock.patch("csaxs_bec.devices.omny.rt.rt_flomni_ophyd.time.sleep"),
):
assert rt_flomni.laser_tracker_check_signalstrength(verbose=False) == expected
assert read_ssi.call_count == 2
def test_flyer_reports_average_stdev_to_the_client(rt_flomni):
flyer = RtFlomniFlyer(name="rt_positions", socket_cls=SocketMock, device_manager=rt_flomni.device_manager)
flyer.controller = rt_flomni