Compare commits

..

4 Commits

Author SHA1 Message Date
7c89086ba2 fix(ddg): Fix tests
All checks were successful
CI for csaxs_bec / test (pull_request) Successful in 1m56s
CI for csaxs_bec / test (push) Successful in 1m59s
2026-03-13 14:19:41 +01:00
1eb2961b7f fix(flomni): Fix logic for flomni scan, avoid resetting positions.
Some checks failed
CI for csaxs_bec / test (push) Failing after 1m52s
CI for csaxs_bec / test (pull_request) Failing after 1m54s
2026-03-13 14:09:35 +01:00
9d58dcfb83 fix(mcs): Fix timing on mcs card to resolve during complete. 2026-03-13 14:06:54 +01:00
541813a02e fix(ddg): Fix timing and delays on ddg. 2026-03-13 14:06:54 +01:00
6 changed files with 99 additions and 31 deletions

View File

@@ -104,7 +104,7 @@ DEFAULT_REFERENCES: list[tuple[LiteralChannels, CHANNELREFERENCE]] = [
("B", CHANNELREFERENCE.A),
("C", CHANNELREFERENCE.T0), # T0
("D", CHANNELREFERENCE.C),
("E", CHANNELREFERENCE.D), # D One extra pulse once shutter closes for MCS
("E", CHANNELREFERENCE.B), # B One extra pulse once shutter closes for MCS
("F", CHANNELREFERENCE.E), # E + 1mu s
("G", CHANNELREFERENCE.T0),
("H", CHANNELREFERENCE.G),
@@ -213,8 +213,23 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
# NOTE Burst delay should be set to 0, don't remove as this will not be checked
# Also set the burst count to 1 to only have a single pulse for DDG1.
# As the IOC may be out of sync with the HW, we make sure that we set the default parameters
# in the IOC to the expected values. In the past, we've experienced that IOC and HW can go out
# of sync.
self.burst_delay.put(1)
time.sleep(0.02) # Give HW time to process
self.burst_delay.put(0)
time.sleep(0.02)
self.burst_count.put(2)
time.sleep(0.02)
self.burst_count.put(1)
time.sleep(0.02)
self.burst_mode.put(1)
time.sleep(0.02)
self.burst_mode.put(0)
time.sleep(0.02)
def keep_shutter_open_during_scan(self, open: True) -> None:
"""
@@ -291,17 +306,24 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
# Burst Period DDG1
# Set burst_period to shutter width
# c/t0 + self._shutter_to_open_delay + exp_time * burst_count
shutter_width = (
self._shutter_to_open_delay + exp_time * frames_per_trigger
) # Shutter starts closing at end of exposure
# SHUTTER WIDTH timing consists of the delay for the shutter to open
# + the exposure time * frames per trigger
shutter_width = self._shutter_to_open_delay + exp_time * frames_per_trigger
# TOTAL EXPOSURE accounts for the shutter to open AND close. In addition, we add
# a short additional delay of 3e-6 to allow for the extra trigger through 'ef'
# (delay of 1e-6, width of 1e-6)
total_exposure_time = 2 * self._shutter_to_open_delay + exp_time * frames_per_trigger + 3e-6
if self.burst_period.get() != shutter_width:
self.burst_period.put(shutter_width)
# The burst_period has to be slightly longer
self.burst_period.put(total_exposure_time)
# Trigger DDG2
# a = t0 + 2ms, b = a + 1us
# a has reference to t0, b has reference to a
# Add delay of self._shutter_to_open_delay to allow shutter to open
self.set_delay_pairs(channel="ab", delay=self._shutter_to_open_delay, width=1e-6)
# AB is delayed by the shutter opening time, and the falling edge indicates the shutter has
# fully closed, it has to be considered as the blocking signal for the next acquisition to start.
# PS: + 3e-6
self.set_delay_pairs(channel="ab", delay=self._shutter_to_open_delay, width=shutter_width)
# Trigger shutter
# d = c/t0 + self._shutter_to_open_delay + exp_time * burst_count + 1ms
@@ -321,7 +343,7 @@ class DDG1(PSIDeviceBase, DelayGeneratorCSAXS):
if self.scan_info.msg.scan_type == "fly":
self.set_delay_pairs(channel="ef", delay=0, width=0)
else:
self.set_delay_pairs(channel="ef", delay=0, width=1e-6)
self.set_delay_pairs(channel="ef", delay=1e-6, width=1e-6)
# NOTE Add additional sleep to make sure that the IOC and DDG HW process the values properly
# This value has been choosen empirically after testing with the HW. It's

View File

@@ -29,6 +29,7 @@ from ophyd_devices import DeviceStatus, StatusBase
from ophyd_devices.interfaces.base_classes.psi_device_base import PSIDeviceBase
from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import (
BURSTCONFIG,
CHANNELREFERENCE,
OUTPUTPOLARITY,
STATUSBITS,
@@ -37,7 +38,6 @@ from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import
ChannelConfig,
DelayGeneratorCSAXS,
LiteralChannels,
BURSTCONFIG,
)
logger = bec_logger.logger
@@ -138,6 +138,24 @@ class DDG2(PSIDeviceBase, DelayGeneratorCSAXS):
# Set burst config
self.burst_config.put(BURSTCONFIG.FIRST_CYCLE.value)
# TODO As the IOC may be out of sync with the HW, we make sure that we set the default parameters
# in the IOC to the expected values. In the past, we've experienced that IOC and HW can go out
# of sync.
self.burst_delay.put(1)
time.sleep(0.02) # Give HW time to process
self.burst_delay.put(0)
time.sleep(0.02)
self.burst_count.put(2)
time.sleep(0.02)
self.burst_count.put(1)
time.sleep(0.02)
self.burst_mode.put(1)
time.sleep(0.02)
self.burst_mode.put(0)
time.sleep(0.02)
def on_stage(self) -> DeviceStatus | StatusBase | None:
"""

View File

@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Callable, Literal
import numpy as np
from bec_lib.logger import bec_logger
from ophyd.utils.errors import WaitTimeoutError
from ophyd import Component as Cpt
from ophyd import EpicsSignalRO, Kind
from ophyd_devices import (
@@ -513,7 +514,22 @@ class MCSCardCSAXS(PSIDeviceBase, MCSCard):
# that the acquisition finishes on the card and that data is emitted to BEC. If the acquisition
# was already finished (i.e. normal step scan sends 1 extra pulse per burst cycle), this will
# not have any effect as the card will already be in DONE state and signal.
self.software_channel_advance.put(1)
if self.scan_info.msg.scan_type == "fly":
expected_points = int(
self.scan_info.msg.num_points
* self.scan_info.msg.scan_parameters.get("frames_per_trigger", 1)
)
status = CompareStatus(self.current_channel, expected_points-1, operation_success=">=")
try:
status.wait(timeout=5)
except WaitTimeoutError:
text = f"Device {self.name} received num points {self.current_channel.get()} / {expected_points}. Device timed out after 5s."
logger.error(text)
raise TimeoutError(text)
# Manually set the last advance
self.software_channel_advance.put(1)
# Prepare and register status callback for the async monitoring loop
status_async_data = StatusBase(obj=self)

View File

@@ -92,7 +92,8 @@ class RtFlomniController(Controller):
parent._min_scan_buffer_reached = False
start_time = time.time()
for pos_index, pos in enumerate(positions):
parent.socket_put_and_receive(f"s{pos[0]:.05f},{pos[1]:.05f},{pos[2]:.05f}")
cmd = f"s{pos[0]:.05f},{pos[1]:.05f},{pos[2]:.05f}"
parent.socket_put_and_receive(cmd)
if pos_index > 100:
parent._min_scan_buffer_reached = True
parent._min_scan_buffer_reached = True
@@ -174,11 +175,12 @@ class RtFlomniController(Controller):
self.set_device_read_write("foptx", False)
self.set_device_read_write("fopty", False)
def move_samx_to_scan_region(self, fovx: float, cenx: float):
#new routine not using fovx anymore
self.device_manager.devices.rtx.obj.move(cenx, wait=True)
def move_samx_to_scan_region(self, cenx: float, move_in_this_routine: bool = False):
# attention. a movement will clear all positions in the rt trajectory generator!
if move_in_this_routine == True:
self.device_manager.devices.rtx.obj.move(cenx, wait=True)
time.sleep(0.05)
#at cenx we expect the PID to be close to zero for a good fsamx position
# at cenx we expect the PID to be close to zero for a good fsamx position
if self.rt_pid_voltage is None:
rtx = self.device_manager.devices.rtx
self.rt_pid_voltage = rtx.user_parameter.get("rt_pid_voltage")
@@ -188,29 +190,31 @@ class RtFlomniController(Controller):
)
logger.info(f"Using PID voltage from rtx user parameter: {self.rt_pid_voltage}")
expected_voltage = self.rt_pid_voltage
#logger.info(f"Expected PID voltage: {expected_voltage}")
# logger.info(f"Expected PID voltage: {expected_voltage}")
logger.info(f"Current PID voltage: {self.get_pid_x()}")
wait_on_exit = False
#we allow 2V range from center, this corresponds to 30 microns
# we allow 2V range from center, this corresponds to 30 microns
if np.abs(self.get_pid_x() - expected_voltage) < 2:
logger.info("No correction of fsamx needed")
else:
fsamx = self.device_manager.devices.fsamx
fsamx.obj.controller.socket_put_confirmed("axspeed[4]=0.1*stppermm[4]")
while True:
#when we correct, then to 1 V, within 15 microns
# when we correct, then to 1 V, within 15 microns
if np.abs(self.get_pid_x() - expected_voltage) < 1:
logger.info("No further correction needed")
break
wait_on_exit = True
#disable FZP piezo feedback
# disable FZP piezo feedback
self.socket_put("v0")
fsamx.read_only = False
logger.info(f"Current PID voltage: {self.get_pid_x()}")
#here we accumulate the correction
# here we accumulate the correction
fsamx.obj.pid_x_correction -= (self.get_pid_x() - expected_voltage) * 0.006
fsamx_in = fsamx.user_parameter.get("in")
logger.info(f"Moving fsamx to {cenx / 1000 * 0.7 + fsamx.obj.pid_x_correction}, PID portion of that {fsamx.obj.pid_x_correction}")
logger.info(
f"Moving fsamx to {cenx / 1000 * 0.7 + fsamx.obj.pid_x_correction}, PID portion of that {fsamx.obj.pid_x_correction}"
)
fsamx.obj.move(fsamx_in + cenx / 1000 * 0.7 + fsamx.obj.pid_x_correction, wait=True)
fsamx.read_only = True
time.sleep(0.1)
@@ -219,7 +223,7 @@ class RtFlomniController(Controller):
if wait_on_exit:
time.sleep(1)
#enable fast FZP feedback again
# enable fast FZP feedback again
self.socket_put("v1")
@threadlocked
@@ -510,7 +514,7 @@ class RtFlomniController(Controller):
# while scan is running
while mode > 0:
#TODO here?: scan abortion if no progress in scan *raise error
# TODO here?: scan abortion if no progress in scan *raise error
# logger.info(f"Current scan position {current_position_in_scan} out of {number_of_positions_planned}")
mode, number_of_positions_planned, current_position_in_scan = self.get_scan_status()

View File

@@ -165,7 +165,8 @@ class FlomniFermatScan(SyncFlyScanBase):
if self.flomni_rotation_status:
self.flomni_rotation_status.wait()
rtx_status = yield from self.stubs.set(device="rtx", value=self.positions[0][0], wait=False)
# rtx_status = yield from self.stubs.set(device="rtx", value=self.positions[0][0], wait=False)
rtx_status = yield from self.stubs.set(device="rtx", value=self.cenx, wait=False)
rtz_status = yield from self.stubs.set(device="rtz", value=self.positions[0][2], wait=False)
yield from self.stubs.send_rpc_and_wait("rtx", "controller.laser_tracker_on")
@@ -173,13 +174,15 @@ class FlomniFermatScan(SyncFlyScanBase):
rtx_status.wait()
rtz_status.wait()
# status = yield from self.stubs.send_rpc("rtx", "move", self.cenx)
# status.wait()
yield from self._transfer_positions_to_flomni()
yield from self.stubs.send_rpc_and_wait(
"rtx", "controller.move_samx_to_scan_region", self.fovx, self.cenx
)
tracker_signal_status = yield from self.stubs.send_rpc_and_wait(
"rtx", "controller.laser_tracker_check_signalstrength"
)
yield from self.stubs.send_rpc_and_wait(
"rtx", "controller.move_samx_to_scan_region", self.cenx
)
# self.device_manager.connector.send_client_info(tracker_signal_status)
if tracker_signal_status == "low":
error_info = messages.ErrorInfo(
@@ -313,7 +316,11 @@ class FlomniFermatScan(SyncFlyScanBase):
# in flomni, we need to move to the start position of the next scan, which is the end position of the current scan
# this method is called in finalize and overwrites the default move_to_start()
if isinstance(self.positions, np.ndarray) and len(self.positions[-1]) == 3:
yield from self.stubs.set(device=["rtx", "rty", "rtz"], value=self.positions[-1])
# yield from self.stubs.set(device=["rtx", "rty", "rtz"], value=self.positions[-1])
# in x we move to cenx, then we avoid jumps in centering routine
value = self.positions[-1]
value[0] = self.cenx
yield from self.stubs.set(device=["rtx", "rty", "rtz"], value=value)
return
logger.warning("No positions found to return to start")

View File

@@ -287,19 +287,20 @@ def test_ddg1_stage(mock_ddg1: DDG1):
mock_ddg1.stage()
shutter_width = mock_ddg1._shutter_to_open_delay + exp_time * frames_per_trigger
total_exposure = 2 * mock_ddg1._shutter_to_open_delay + exp_time * frames_per_trigger + 3e-6
assert np.isclose(mock_ddg1.burst_mode.get(), 1) # burst mode is enabled
assert np.isclose(mock_ddg1.burst_delay.get(), 0)
assert np.isclose(mock_ddg1.burst_period.get(), shutter_width)
assert np.isclose(mock_ddg1.burst_period.get(), total_exposure)
# Trigger DDG2 through EXT/EN
assert np.isclose(mock_ddg1.ab.delay.get(), 2e-3)
assert np.isclose(mock_ddg1.ab.width.get(), 1e-6)
assert np.isclose(mock_ddg1.ab.width.get(), shutter_width)
# Shutter channel cd
assert np.isclose(mock_ddg1.cd.delay.get(), 0)
assert np.isclose(mock_ddg1.cd.width.get(), shutter_width)
# MCS channel ef or gate
assert np.isclose(mock_ddg1.ef.delay.get(), 0)
assert np.isclose(mock_ddg1.ef.delay.get(), 1e-6)
assert np.isclose(mock_ddg1.ef.width.get(), 1e-6)
assert mock_ddg1.staged == ophyd.Staged.yes