Fix/timepix scan hardening #45

Closed
wyzula_j wants to merge 11 commits from fix/timepix-scan-hardening into main
pull from: fix/timepix-scan-hardening
6 changed files with 1143 additions and 77 deletions

No files matched your search

@@ -67,6 +67,33 @@ nidaq:
enabled: true
softwareTrigger: false
### Timepix Detector
timepix:
readoutPriority: async
description: ASI Serval Timepix Detector
deviceClass: superxas_bec.devices.timepix.timepix.Timepix
deviceConfig:
prefix: "X10DA-ES-TPX1:"
backend_rest_url: "P6-0008.psi.ch:8452"
hostname: "x10da-bec-001.psi.ch"
enable_xes: false
onFailure: retry
enabled: true
readOnly: false
softwareTrigger: true
### Falcon
falcon:
readoutPriority: monitored
description: Falcon Sitoro detector
deviceClass: superxas_bec.devices.falcon.FalconSuperXAS
deviceConfig:
prefix: 'X10DA-SITORO:'
onFailure: raise
enabled: True
softwareTrigger: False
## Remaining experimental hutch
es_config:
- !include ./x10da_experimental_hutch.yaml
- !include ./x10da_experimental_hutch.yaml
+443 -66
View File
@@ -12,14 +12,20 @@ import os
import threading
import time
import traceback
from collections import deque
from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, Any, Literal
import numpy as np
from bec_lib.file_utils import get_full_path
from bec_lib.logger import bec_logger
from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo
from ophyd import ADBase
from ophyd import Component as Cpt
from ophyd import Signal
from ophyd import Staged
from ophyd.status import StatusTimeoutError, WaitTimeoutError
from ophyd.utils import InvalidState
from ophyd_devices import (
AsyncSignal,
CompareStatus,
@@ -44,6 +50,7 @@ from superxas_bec.devices.timepix.timepix_fly_client.timepix_fly_interface impor
OtherConfigModel,
PixelMap,
)
from superxas_bec.devices.utils.utils import fetch_scan_info
if TYPE_CHECKING:
from bec_lib.messages import ScanStatusMessage
@@ -165,6 +172,27 @@ def load_pixel_map_from_json(file_path: str) -> PixelMap:
return pixel_map
def window_fits(window: slice, length: int) -> bool:
"""True if [start, stop) is a non-empty index window inside range(length)."""
return 0 <= window.start < window.stop <= length
def window_mean(data: np.ndarray, window: slice) -> float:
"""
Mean of data over the half-open index window [start, stop).
Returns NaN if the window is empty or does not fit into data. numpy would instead average
over fewer points (stop past the end) or return NaN with a RuntimeWarning (empty window).
Args:
data (np.ndarray): 1D data, e.g. one time spectrum.
window (slice): window with integer start and stop.
"""
if not window_fits(window, len(data)):
return float("nan")
return float(np.mean(data[window]))
class ImagePlugin_Timepix(ImagePlugin_V35):
"""Custom Image Plugin for TimePix detector."""
@@ -192,6 +220,34 @@ DETECTOR_SHAPE = (512, 1024) # Shape of the TimePix detector
TIMEPIX_FLY_BACKEND_DEFAULT_REDIS_TOPIC = "public/timepix_fly_backend/raw_data"
# scan_name of superxas_bec.scans.timepix_scan.TimepixScan
TIMEPIX_SCAN_NAME = "timepix_scan"
# How long a repeated acquisition waits for the images of the lost one before it starts.
LOST_ACQUISITION_IMAGE_TIMEOUT = 2.0
@dataclass
class XesAcquisition:
"""
One camera acquisition for which TimePixFly was armed. TimePixFly sends exactly one
StartFrame ... EndFrame sequence per armed acquisition, in order, so msg_buffer_callback
matches each EndFrame to the oldest pending acquisition.
Attributes:
exposure (float): seconds the detector needs for the images of this trigger.
repeat_allowed (bool): the acquisition may be repeated if its raw data are lost.
acquire_time (float | None): time.monotonic() when Acquire was set, None before.
lost (bool): TimePixFly closed its frame before the exposure could end.
done (bool): the trigger status of this acquisition has completed.
"""
exposure: float
repeat_allowed: bool
acquire_time: float | None = None
lost: bool = False
done: bool = False
class Timepix(PSIDeviceBase, TimePixControl):
"""
@@ -221,9 +277,18 @@ class Timepix(PSIDeviceBase, TimePixControl):
static_spectra = Cpt(AsyncSignal, name="static_spectra", ndim=1, max_size=1000, acquisition_group="monitored", async_update={"type": "add", "max_shape": [None, DETECTOR_SHAPE[0]]}, doc="Spectra signal of the TimePix detector.")
# xes_data_accumulated_1 = Cpt(AsyncSignal, name="xes_accumulated_energy_1", ndim=1, max_size=1000, doc="1D time spectra for energy bin 2.")
# xes_data_accumulated_2 = Cpt(AsyncSignal, name="xes_accumulated_energy_2", ndim=1, max_size=1000, doc="1D time spectra for energy bin 2.")
xes_data_accumulated_1 = Cpt(AsyncSignal, name="xes_accumulated_energy_1", ndim=1, max_size=1000, doc="1D time spectra for energy bin 2.")
xes_data_accumulated_2 = Cpt(AsyncSignal, name="xes_accumulated_energy_2", ndim=1, max_size=1000, doc="1D time spectra for energy bin 2.")
file_event = Cpt(FileEventSignal, name="file_event", doc="File event signal for TimePix detector.")
### Additional timepix scan signals
xes_level_1_off = Cpt(AsyncSignal, name="xes_level_1_off", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level Off for energy bin 1.")
xes_level_1_on = Cpt(AsyncSignal, name="xes_level_1_on", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level On for energy bin 1.")
xes_diff_1_on_off = Cpt(AsyncSignal, name="xes_diff_1_on_off", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level Difference for energy bin 1.")
xes_level_2_off = Cpt(AsyncSignal, name="xes_level_2_off", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level Off for energy bin 2.")
xes_level_2_on = Cpt(AsyncSignal, name="xes_level_2_on", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level On for energy bin 2.")
xes_diff_2_on_off = Cpt(AsyncSignal, name="xes_diff_2_on_off", ndim=0, async_update={"type": "add", "max_shape": [None]}, max_size=1000, doc="XES Level Difference for energy bin 2.")
# fmt: on
def __init__(
@@ -259,6 +324,7 @@ class Timepix(PSIDeviceBase, TimePixControl):
super().__init__(
name=name, prefix=prefix, scan_info=scan_info, device_manager=device_manager, **kwargs
)
self.scan_parameters: ScanServerScanInfo | None = None
self._redis_raw_data_topic = TIMEPIX_FLY_BACKEND_DEFAULT_REDIS_TOPIC
self.backend = TimepixFlyBackend(
@@ -269,8 +335,8 @@ class Timepix(PSIDeviceBase, TimePixControl):
redis_connector=self.device_manager.connector,
)
self._pixel_map = None
self._troistep = 1
self._troin = 5000
self._troistep = 1 # Will be changed if timepix scan is used, see on_stage call
self._troin = 5000 # Will be changed if timepix scan is used, see on_stage call
self._poll_thread = threading.Thread(
target=self._poll_array_data, daemon=True, name=f"{self.name}_poll_thread"
@@ -288,13 +354,43 @@ class Timepix(PSIDeviceBase, TimePixControl):
self.accumulated_data_e1 = None
self.accumulated_data_e2 = None
# Settings that timepix_scan overrides in on_stage, saved for the restore in
# on_unstage / on_stop. None while no timepix_scan overrides are active.
self._settings_before_timepix_scan: dict[str, Any] | None = None
# Log the "on/off window does not fit" warning only once per scan.
self._on_off_window_warned = False
# Acquisitions armed in TimePixFly whose EndFrame has not arrived yet, oldest first.
self._xes_acquisitions: deque[XesAcquisition] = deque()
self._xes_acquisitions_lock = threading.Lock()
# Images of a lost acquisition still to arrive; they are not published.
self._images_to_skip = 0
self._exposure_per_trigger = 0.0
self._images_per_trigger = 1
def stage(self) -> list[object] | StatusBase: # type: ignore
"""Stage the device.
Super stage not safe to call.."""
Super stage not safe to call: ophyd's Device.stage would also stage the areaDetector
plugins and apply their stage_sigs. The device is still marked as staged, because the
BEC device server only calls unstage() (and with it on_unstage) on devices that report
Staged.yes.
"""
self.stopped = False
status = self.on_stage() # pylint: disable=assignment-from-no-return
try:
status = self.on_stage() # pylint: disable=assignment-from-no-return
except Exception:
# Do not leave timepix_scan overrides behind if staging fails half-way. If the
# restore fails too, only log it so that the stage error is the one raised; the
# saved settings are then put back by the next stage, unstage or stop.
try:
self._restore_settings_after_timepix_scan()
except Exception: # pylint: disable=broad-except
content = traceback.format_exc()
logger.error(
f"Failed to restore settings of {self.name} after a failed stage: {content}"
)
raise
self._staged = Staged.yes
if isinstance(status, StatusBase):
return status
return []
@@ -355,6 +451,8 @@ class Timepix(PSIDeviceBase, TimePixControl):
end_frame (dict): The EndFrame. Dictionary representation of detailed structure
described in model .timepix_fly_client.timepix_fly_interface.TimepixEndFrame
"""
if self._frame_of_lost_acquisition(end_frame):
return # on_trigger repeats the acquisition; publish nothing for this one
n_energy_points = start_frame.get("NumEnergyPoints", None)
troin = start_frame["TRoiN"]
if troin != self._troin:
@@ -407,27 +505,135 @@ class Timepix(PSIDeviceBase, TimePixControl):
data_2 = np.sum(xes_data[:, 4:8], axis=1)
self.xes_energy_1.put(data_1, async_update={"type": "add", "max_shape": [None, troin]})
self.xes_energy_2.put(data_2, async_update={"type": "add", "max_shape": [None, troin]})
# if self.accumulated_data_e1 is None:
# self.accumulated_data_e1 = data_1
# else:
# self.accumulated_data_e1 += data_1
# if self.accumulated_data_e2 is None:
# self.accumulated_data_e2 = data_2
# else:
# self.accumulated_data_e2 += data_2
# self.xes_data_accumulated_1.put(
# self.accumulated_data_e1,
# async_update={"type": "replace", "max_shape": [None, troin]},
# )
# self.xes_data_accumulated_2.put(
# self.accumulated_data_e2,
# async_update={"type": "replace", "max_shape": [None, troin]},
# )
if self.accumulated_data_e1 is None:
self.accumulated_data_e1 = data_1
else:
self.accumulated_data_e1 += data_1
if self.accumulated_data_e2 is None:
self.accumulated_data_e2 = data_2
else:
self.accumulated_data_e2 += data_2
self.xes_data_accumulated_1.put(
self.accumulated_data_e1,
async_update={"type": "replace", "max_shape": [None, troin]},
)
self.xes_data_accumulated_2.put(
self.accumulated_data_e2,
async_update={"type": "replace", "max_shape": [None, troin]},
)
### Additional signals for timepix scan
# Frames can arrive without a staged timepix_scan (e.g. before the first stage after
# a restart), so scan_parameters may still be None here.
if self._is_timepix_scan():
self._put_on_off_levels(data_1, data_2)
self.xes_spectra.put(
xes_data.sum(axis=1), async_update={"type": "add", "max_shape": [None, troin]}
)
logger.debug(f"Device data set for Timepix with {tds_period}, {tds_total_events}")
def _frame_of_lost_acquisition(self, end_frame: dict) -> bool:
"""
Match the EndFrame to its acquisition and check whether that acquisition was lost.
A frame without any TDC period holds no XES data. If it arrives before the exposure of
its acquisition can have ended, TimePixFly's raw-data connection closed before the
detector measured, and the raw data of that acquisition never reached TimePixFly
(scan S00437, point 1: the frame closed 1.2 s before the detector delivered the image).
Such an acquisition is marked lost and on_trigger repeats it once, if allowed. A frame
without TDC periods after the exposure means the detector recorded no TDC pulse; it is
published as it is.
Args:
end_frame (dict): EndFrame received from TimePixFly.
Returns:
bool: True if the frame belongs to a lost acquisition that will be repeated.
"""
with self._xes_acquisitions_lock:
if not self._xes_acquisitions:
return False # no acquisition armed by this device, e.g. after a stop
acquisition = self._xes_acquisitions.popleft()
if end_frame.get("periods", None) != 0:
return False
if acquisition.acquire_time is None:
timing = "before Acquire was set"
else:
elapsed = time.monotonic() - acquisition.acquire_time
timing = f"{elapsed:.3f} s after Acquire was set"
if elapsed >= acquisition.exposure:
logger.error(
f"{self.name}: TimePixFly frame without any TDC period, {timing} "
f"({acquisition.exposure:.3f} s exposure). The detector recorded no TDC "
"pulse: check the TDC input signal. This point has no XES data."
)
return False
if not acquisition.repeat_allowed or acquisition.done:
logger.error(
f"{self.name}: TimePixFly closed the frame {timing}, before the "
f"{acquisition.exposure:.3f} s exposure could end, without any TDC period. "
"The raw data of this point did not reach TimePixFly and the acquisition "
"cannot be repeated. This point has no XES data."
)
return False
acquisition.lost = True
self._images_to_skip += self._images_per_trigger
logger.warning(
f"{self.name}: TimePixFly closed the frame {timing}, before the "
f"{acquisition.exposure:.3f} s exposure could end, without any TDC period. The raw "
"data of this acquisition did not reach TimePixFly; repeating the acquisition."
)
return True
def _put_on_off_levels(self, data_1: np.ndarray, data_2: np.ndarray) -> None:
"""
Publish the laser on/off levels of a timepix_scan for both energy groups.
The off/on windows are the half-open time-bin ranges [idx_*_start, idx_*_stop) given as
timepix_scan arguments. A level is the mean of xes_energy_1/2 over its window, the
difference is on minus off. If a window is empty or does not fit into the received
time bins (TimePixFly sent fewer bins than time_roi_n), its levels and the differences
are published as NaN, so every frame still adds one value per signal and the signals stay
aligned with the scan points; a warning is logged once per scan.
Args:
data_1 (np.ndarray): xes_energy_1 of this frame, one value per time bin.
data_2 (np.ndarray): xes_energy_2 of this frame, one value per time bin.
"""
params = self.scan_parameters.additional_scan_parameters
off = slice(params["idx_off_start"], params["idx_off_stop"])
on = slice(params["idx_on_start"], params["idx_on_stop"])
bad_windows = [
f"{name} [{window.start}, {window.stop})"
for name, window in (("off", off), ("on", on))
if not window_fits(window, len(data_1))
]
if bad_windows and not self._on_off_window_warned:
self._on_off_window_warned = True
logger.warning(
f"{self.name}: laser {' and '.join(bad_windows)} window does not fit into the "
f"{len(data_1)} time bins received; publishing NaN for the affected levels."
)
level_1_off = window_mean(data_1, off)
level_1_on = window_mean(data_1, on)
level_2_off = window_mean(data_2, off)
level_2_on = window_mean(data_2, on)
self.xes_level_1_off.put(level_1_off)
self.xes_level_1_on.put(level_1_on)
self.xes_level_2_off.put(level_2_off)
self.xes_level_2_on.put(level_2_on)
self.xes_diff_1_on_off.put(level_1_on - level_1_off)
self.xes_diff_2_on_off.put(level_2_on - level_2_off)
def _is_timepix_scan(self) -> bool:
"""True if the device is staged for a timepix_scan (False before the first stage)."""
return (
self.scan_parameters is not None and self.scan_parameters.scan_name == TIMEPIX_SCAN_NAME
)
### User ACCESS methods
def get_pixel_map(self) -> dict:
@@ -603,6 +809,11 @@ class Timepix(PSIDeviceBase, TimePixControl):
"""Callback for image unique ID updates to trigger preview update."""
if value == old_value:
return # No new image, or counter reset
with self._xes_acquisitions_lock:
if self._images_to_skip > 0:
# Image of an acquisition whose XES data were lost; its repetition replaces it.
self._images_to_skip -= 1
return
try:
# Get new image data
width = self.image.array_size.width.get()
@@ -621,9 +832,17 @@ class Timepix(PSIDeviceBase, TimePixControl):
def on_stage(self) -> StatusBase | None:
"""Called while staging the device."""
###
self.scan_parameters = fetch_scan_info(self.scan_info)
if self._is_timepix_scan():
self._apply_timepix_scan_settings(self.scan_parameters.additional_scan_parameters)
else:
# Overrides of an earlier timepix_scan whose restore failed (no-op otherwise)
self._restore_settings_after_timepix_scan()
self.accumulated_data_e1 = None
self.accumulated_data_e2 = None
self._on_off_window_warned = False
scan_msg: ScanStatusMessage = self.scan_info.msg # type: ignore
exp_time = scan_msg.scan_parameters.get("exp_time", 0)
if exp_time - self._readout_time <= 0:
@@ -632,6 +851,9 @@ class Timepix(PSIDeviceBase, TimePixControl):
)
burst_images = scan_msg.scan_parameters.get("frames_per_trigger", 1)
self._n_images = scan_msg.num_points * burst_images
self._images_per_trigger = burst_images
self._exposure_per_trigger = burst_images * exp_time - self._readout_time
self._clear_xes_acquisitions()
# Camera has to be set to burst_images, each step will get an individual trigger
self.cam.acquire_time.set(exp_time - self._readout_time).wait(timeout=self._pv_timeout)
self.cam.acquire_period.set(exp_time).wait(timeout=self._pv_timeout)
@@ -680,12 +902,53 @@ class Timepix(PSIDeviceBase, TimePixControl):
timeout=self._pv_timeout
)
def _apply_timepix_scan_settings(self, params: dict) -> None:
"""
Apply the timepix_scan overrides: time binning from the scan arguments, HDF5 image
writing off and XES on. The previous values are saved and put back by
_restore_settings_after_timepix_scan (on_unstage, or on_stop for an aborted scan).
Args:
params (dict): additional_scan_parameters of the timepix_scan.
"""
if self._settings_before_timepix_scan is None:
# If an earlier restore failed, keep the values saved before that scan.
self._settings_before_timepix_scan = {
"troistep": self._troistep,
"troin": self._troin,
"hdf_enable": self.hdf.enable.get(),
"enable_xes": self.enable_xes,
}
self._troistep = params["time_roi_step"]
self._troin = params["time_roi_n"]
# Wait for the readback: the rest of on_stage checks hdf.enable to decide on capture.
self.set_enable_image_writing(False)
self.set_enable_xes(True)
def _restore_settings_after_timepix_scan(self) -> None:
"""
Put back the settings saved by _apply_timepix_scan_settings. Does nothing if no
timepix_scan overrides are active, so on_stop, on_unstage and on_stage can all call it.
"""
settings = self._settings_before_timepix_scan
if settings is None:
return
self._troistep = settings["troistep"]
self._troin = settings["troin"]
self.hdf.enable.set(settings["hdf_enable"]).wait(timeout=self._pv_timeout)
self.set_enable_xes(settings["enable_xes"])
# Clear only after success, so a failed restore in on_stop is retried in on_unstage.
self._settings_before_timepix_scan = None
def on_unstage(self) -> None:
"""Called while unstaging the device."""
if self.cam.acquire_busy.get() == ACQUIRESTATUS.ACQUIRING:
self.cam.acquire.put(0)
status_camera = CompareStatus(self.cam.acquire_busy, ACQUIRESTATUS.DONE)
status_camera.wait(timeout=self._pv_timeout)
try:
if self.cam.acquire_busy.get() == ACQUIRESTATUS.ACQUIRING:
self.cam.acquire.put(0)
status_camera = CompareStatus(self.cam.acquire_busy, ACQUIRESTATUS.DONE)
status_camera.wait(timeout=self._pv_timeout)
finally:
self._restore_settings_after_timepix_scan()
def on_pre_scan(self) -> StatusBase:
"""Called right before the scan starts on all devices automatically."""
@@ -705,51 +968,155 @@ class Timepix(PSIDeviceBase, TimePixControl):
return status
def on_trigger(self) -> DeviceStatus | StatusBase | None:
"""Called when the device is triggered."""
"""
Called when the device is triggered.
status_camera = TransitionStatus(
With XES enabled, TimePixFly is armed before the camera starts. If TimePixFly closes the
frame of the acquisition before its exposure could end (see _frame_of_lost_acquisition),
the acquisition is repeated once and the returned status resolves after the repetition.
This needs the HDF5 plugin to be off (as in timepix_scan): with image writing on, a
repetition would add an image to the file, so the point is kept without XES data.
"""
if self.enable_xes is not True:
status_camera = self._camera_acquisition_status()
self.cancel_on_stop(status_camera)
self.cam.acquire.put(1)
return status_camera
point_status = DeviceStatus(self)
self.cancel_on_stop(point_status)
repeat_allowed = self.hdf.enable.get() != "Enable"
self._start_xes_acquisition(point_status, repeat_allowed=repeat_allowed)
return point_status
def _camera_acquisition_status(self) -> TransitionStatus:
"""Status that resolves once the camera has acquired (AcquireBusy DONE -> ACQUIRING -> DONE)."""
return TransitionStatus(
self.cam.acquire_busy, [ACQUIRESTATUS.DONE, ACQUIRESTATUS.ACQUIRING, ACQUIRESTATUS.DONE]
)
status_backend = None
def _start_xes_acquisition(self, point_status: DeviceStatus, repeat_allowed: bool) -> None:
"""
Arm TimePixFly and start one camera acquisition for the trigger status point_status.
_on_xes_acquisition_done resolves point_status, or repeats a lost acquisition.
Args:
point_status (DeviceStatus): trigger status returned by on_trigger.
repeat_allowed (bool): the acquisition may be repeated if its raw data are lost.
Raises:
TimeoutError: if TimePixFly does not reach the 'config' or 'await_connection' state.
"""
status_camera = self._camera_acquisition_status()
# First we make sure that the backend reach 'config' state. This needs to happend before each trigger.
if self.enable_xes is True:
status_backend_config = DeviceStatus(self)
self.cancel_on_stop(status_backend_config)
self.backend.timepix_fly_client.add_status_callback(
status=status_backend_config,
success=[TimePixFlyStatus.CONFIG],
error=[TimePixFlyStatus.EXCEPT, TimePixFlyStatus.SHUTDOWN],
)
try:
status_backend_config.wait(timeout=5)
except TimeoutError:
# pylint:disable=raise-missing-from
raise TimeoutError(
f"TimePixFly backend of device {self.name} failed to reach 'config' state in trigger"
)
# Prepare backend to be ready to receive trigger
status = self.backend.on_trigger()
try:
status.wait(timeout=5) # Wait until backend trigger is done
status_backend_config = DeviceStatus(self)
self.cancel_on_stop(status_backend_config)
self.backend.timepix_fly_client.add_status_callback(
status=status_backend_config,
success=[TimePixFlyStatus.CONFIG],
error=[TimePixFlyStatus.EXCEPT, TimePixFlyStatus.SHUTDOWN],
)
try:
status_backend_config.wait(timeout=5)
except TimeoutError:
# pylint:disable=raise-missing-from
except Exception:
logger.error(
f"TimePixFly backend of device {self.name} failed to prepare for trigger"
)
raise TimeoutError(
f"TimePixFly backend of device {self.name} failed to reach 'trigger' state in trigger"
)
# Status that resolves once the trigger is done
status_backend = self.backend.on_trigger_finished()
raise TimeoutError(
f"TimePixFly backend of device {self.name} failed to reach 'config' state in trigger"
)
if status_backend is not None:
return_status = status_camera & status_backend
else:
return_status = status_camera
self.cancel_on_stop(return_status)
# Registered before arming: the EndFrame of a lost acquisition can arrive at once.
acquisition = XesAcquisition(
exposure=self._exposure_per_trigger, repeat_allowed=repeat_allowed
)
with self._xes_acquisitions_lock:
self._xes_acquisitions.append(acquisition)
# Prepare backend to be ready to receive trigger
status = self.backend.on_trigger()
try:
status.wait(timeout=5) # Wait until backend trigger is done
# pylint:disable=raise-missing-from
except Exception:
with self._xes_acquisitions_lock:
if acquisition in self._xes_acquisitions:
self._xes_acquisitions.remove(acquisition)
logger.error(f"TimePixFly backend of device {self.name} failed to prepare for trigger")
raise TimeoutError(
f"TimePixFly backend of device {self.name} failed to reach 'trigger' state in trigger"
)
# Status that resolves once the trigger is done
status_backend = self.backend.on_trigger_finished()
acquisition_status = status_camera & status_backend
self.cancel_on_stop(acquisition_status)
acquisition_status.add_callback(
partial(self._on_xes_acquisition_done, point_status, acquisition)
)
acquisition.acquire_time = time.monotonic()
self.cam.acquire.put(1)
return return_status
def _on_xes_acquisition_done(
self, point_status: DeviceStatus, acquisition: XesAcquisition, status: StatusBase
) -> None:
"""Resolve the trigger status, or repeat the acquisition if TimePixFly lost its data."""
with self._xes_acquisitions_lock:
acquisition.done = True
lost = acquisition.lost
if point_status.done:
return # cancelled, e.g. by a stop
if not status.success:
self._resolve_point_status(point_status, status.exception())
return
if lost:
threading.Thread(
target=self._repeat_xes_acquisition,
args=(point_status,),
daemon=True,
name=f"{self.name}_repeat_acquisition",
).start()
return
self._resolve_point_status(point_status)
def _repeat_xes_acquisition(self, point_status: DeviceStatus) -> None:
"""Repeat a lost acquisition once, after the images of the lost one have arrived."""
try:
deadline = time.monotonic() + LOST_ACQUISITION_IMAGE_TIMEOUT
while True:
with self._xes_acquisitions_lock:
if self._images_to_skip == 0:
break
if time.monotonic() > deadline:
logger.warning(
f"{self.name}: {self._images_to_skip} image(s) of the lost "
"acquisition did not arrive; repeating without waiting for them."
)
self._images_to_skip = 0
break
time.sleep(0.05)
if point_status.done or self.stopped:
return
self._start_xes_acquisition(point_status, repeat_allowed=False)
except Exception as exc: # pylint: disable=broad-except
content = traceback.format_exc()
logger.error(f"{self.name}: repeating the lost acquisition failed: {content}")
self._resolve_point_status(point_status, exc)
@staticmethod
def _resolve_point_status(point_status: DeviceStatus, exc: Exception | None = None) -> None:
"""Finish point_status (failed if exc is given), unless it is already done."""
if isinstance(exc, (StatusTimeoutError, WaitTimeoutError)):
exc = TimeoutError(str(exc)) # ophyd reserves these for its own timeouts
try:
if exc is None:
point_status.set_finished()
else:
point_status.set_exception(exc)
except InvalidState:
pass # resolved meanwhile, e.g. cancelled by a stop
def _clear_xes_acquisitions(self) -> None:
"""Forget pending acquisitions and images to skip (new scan, or stop)."""
with self._xes_acquisitions_lock:
self._xes_acquisitions.clear()
self._images_to_skip = 0
def on_complete(self) -> DeviceStatus | StatusBase | None:
"""Called to inquire if a device has completed a scans."""
@@ -813,6 +1180,16 @@ class Timepix(PSIDeviceBase, TimePixControl):
# Backend
if self.enable_xes is True:
self.backend.on_stop()
self._clear_xes_acquisitions()
# BEC does not unstage the devices of an aborted scan, so undo the timepix_scan
# overrides here. Runs after the backend stop, which depends on enable_xes.
try:
self._restore_settings_after_timepix_scan()
except Exception: # pylint: disable=broad-except
content = traceback.format_exc()
logger.error(
f"Failed to restore settings of {self.name} after timepix_scan, will retry on unstage: {content}"
)
def on_destroy(self):
"""Cleanup method to stop the device and clean up resources."""
+1
View File
@@ -3,3 +3,4 @@ from .mono_bragg_scans import ( # XASAdvancedScanWithXRD,; XASSimpleScanWithXRD
XASSimpleScan,
)
from .nidaq_cont_scan import NIDAQContinuousScan
from .timepix_scan import TimepixScan
+95
View File
@@ -0,0 +1,95 @@
"""
Timepix Scan (extension of line scan).
Scan procedure:
- prepare_scan
- open_scan
- stage
- pre_scan
- scan_core
- at_each_point (optionally called by scan_core)
- post_scan
- unstage
- close_scan
- on_exception (called if any exception is raised during the scan)
"""
from __future__ import annotations
from typing import Annotated
from bec_lib.scan_args import DefaultArgType, ScanArgument
from bec_server.scan_server.scans.line_scan import LineScan
class TimepixScan(LineScan):
"""
Timepix Scan
Extends Line Scan with additional parameters for the timepix detector.
"""
scan_name = "timepix_scan"
gui_config = {
"Movement Parameters": ["steps", "relative"],
"Acquisition Parameters": ["exp_time", "burst_at_each_point"],
"Binning Parameters": ["time_roi_step", "time_roi_n"],
"Laser On/Off Parameters": ["idx_off_start", "idx_off_stop", "idx_on_start", "idx_on_stop"],
}
def __init__(
self,
*args,
steps: Annotated[int, ScanArgument(display_name="Number of Steps", gt=0)],
relative: DefaultArgType.Relative,
exp_time: DefaultArgType.ExposureTime = 0,
burst_at_each_point: DefaultArgType.BurstAtEachPoint = 1,
# fmt: off
time_roi_step: Annotated[int, ScanArgument(display_name="Time ROI step", description="Bin size", gt=0)],
time_roi_n: Annotated[int, ScanArgument(display_name="Time ROI n", description="Number of time points", gt=0)],
idx_off_start: Annotated[int, ScanArgument(display_name="Index Off Start", description="Start Index for Off Level (first time bin)", ge=0)],
idx_off_stop: Annotated[int, ScanArgument(display_name="Index Off Stop", description="Stop Index for Off Level (exclusive)", ge=0)],
idx_on_start: Annotated[int, ScanArgument(display_name="Index On Start", description="Start Index for On Level (first time bin)", ge=0)],
idx_on_stop: Annotated[int, ScanArgument(display_name="Index On Stop", description="Stop Index for On Level (exclusive)", ge=0)],
# fmt: on
**kwargs,
):
# The on/off windows are half-open index ranges [start, stop) over the time_roi_n time
# bins; the Timepix device averages each window into xes_level_*. An empty window
# (start == stop) would only produce NaN, so it is rejected here as well.
if idx_off_start >= idx_off_stop:
raise ValueError(
f"idx_off_start ({idx_off_start}) must be smaller than idx_off_stop ({idx_off_stop})!"
)
if idx_on_start >= idx_on_stop:
raise ValueError(
f"idx_on_start ({idx_on_start}) must be smaller than idx_on_stop ({idx_on_stop})!"
)
for name, idx in (
("idx_off_start", idx_off_start),
("idx_off_stop", idx_off_stop),
("idx_on_start", idx_on_start),
("idx_on_stop", idx_on_stop),
):
if idx > time_roi_n:
raise ValueError(
f"{name} with value {idx} exceeds length of data array of time_roi_n of {time_roi_n}"
)
super().__init__(
*args,
steps=steps,
relative=relative,
exp_time=exp_time,
burst_at_each_point=burst_at_each_point,
**kwargs,
)
self.update_scan_info(
time_roi_step=time_roi_step,
Review

local refs are not needed

local refs are not needed
time_roi_n=time_roi_n,
idx_off_start=idx_off_start,
idx_off_stop=idx_off_stop,
idx_on_start=idx_on_start,
idx_on_stop=idx_on_stop,
)
+501 -10
View File
@@ -3,14 +3,19 @@
from __future__ import annotations
import threading
from types import SimpleNamespace
import time
from contextlib import ExitStack
from unittest import mock
import numpy as np
import ophyd
import pytest
from bec_lib.devicemanager import ScanInfo
from bec_lib.messages import ScanStatusMessage
from bec_server.device_server.tests.utils import DMMock
from ophyd import DeviceStatus, StatusBase
from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo
from ophyd import DeviceStatus, Staged, StatusBase
from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError
from ophyd_devices.tests.utils import MockPV, patch_dual_pvs
from superxas_bec.devices.timepix.timepix import (
@@ -23,12 +28,14 @@ from superxas_bec.devices.timepix.timepix import (
TDCEdge,
TDCOuput,
Timepix,
window_mean,
)
from superxas_bec.devices.timepix.timepix_fly_client.timepix_fly_client import TimePixFlyStatus
from superxas_bec.devices.timepix.timepix_fly_client.timepix_fly_interface import (
NetAddresses,
PixelMap,
)
from superxas_bec.devices.utils.utils import fetch_scan_info
# pylint: disable=protected-access
# pylint: disable=redefined-outer-name
@@ -124,21 +131,108 @@ def _message_value(signal):
return msg.signals[signal.name]["value"]
def _scan_status_msg(
scan_name: str = "line_scan",
additional_scan_parameters: dict | None = None,
exp_time: float = 0.1,
frames_per_trigger: int = 2,
num_points: int = 3,
) -> ScanStatusMessage:
"""Scan status message shaped like the one the v4 scan server publishes."""
v4_info = ScanServerScanInfo(
scan_name=scan_name,
scan_id="test",
scan_type="software_triggered",
num_points=num_points,
exp_time=exp_time,
frames_per_trigger=frames_per_trigger,
additional_scan_parameters=additional_scan_parameters or {},
)
scan_parameters = {"exp_time": exp_time, "frames_per_trigger": frames_per_trigger}
info = v4_info.model_dump(mode="python")
info["scan_parameters"] = scan_parameters
return ScanStatusMessage(
scan_id="test",
status="open",
scan_name=scan_name,
scan_type="software_triggered",
num_points=num_points,
scan_parameters=scan_parameters,
info=info,
)
TIMEPIX_SCAN_PARAMETERS = {
"time_roi_step": 1,
"time_roi_n": 4,
"idx_off_start": 0,
"idx_off_stop": 2,
"idx_on_start": 2,
"idx_on_stop": 4,
}
LEVEL_SIGNALS = (
"xes_level_1_off",
"xes_level_1_on",
"xes_level_2_off",
"xes_level_2_on",
"xes_diff_1_on_off",
"xes_diff_2_on_off",
)
def _set_scan_parameters(dev, scan_name: str, additional_scan_parameters: dict | None = None):
"""Set the normalized scan parameters the way on_stage does, without staging."""
dev.scan_info.msg = _scan_status_msg(scan_name, additional_scan_parameters)
dev.scan_parameters = fetch_scan_info(dev.scan_info)
def _xes_frames(spectra: np.ndarray):
"""StartFrame, one XesData frame and EndFrame for a (TRoiN, NumEnergyPoints) array."""
troin, n_energy_points = spectra.shape
start_frame = {
"type": "StartFrame",
"Mode": "TOA",
"TRoiStart": 0,
"TRoiStep": 1,
"TRoiN": troin,
"NumEnergyPoints": n_energy_points,
"save_interval": 1,
}
# TDSpectra is the flattened (time bin, energy point) array, energy points varying fastest
data_frames = [
{
"type": "XesData",
"period": 131000,
"totalEvents": int(spectra.sum()),
"TDSpectra": spectra.ravel().tolist(),
"beforeROI": 0,
"afterROI": 0,
}
]
end_frame = {"type": "EndFrame", "error": "", "periods": 4}
return start_frame, data_frames, end_frame
def _level_test_spectra() -> np.ndarray:
"""4 time bins x 8 energy points; energy group 1 sums to [4, 8, 12, 16], group 2 to [40, 80, 120, 160]."""
time_bin = np.arange(1, 5, dtype=np.float32)[:, None]
return np.hstack([np.repeat(time_bin, 4, axis=1), np.repeat(10 * time_bin, 4, axis=1)])
@pytest.fixture(scope="function")
def timepix():
"""Timepix device with mocked EPICS signals and a fully mocked backend."""
backend = FakeBackend()
scan_info = SimpleNamespace(
msg=SimpleNamespace(
scan_name="step_scan",
scan_parameters={"exp_time": 0.1, "frames_per_trigger": 2},
num_points=3,
scan_id="test",
)
)
scan_info = ScanInfo(msg=_scan_status_msg())
with (
mock.patch.object(ophyd, "cl") as mock_cl,
mock.patch("superxas_bec.devices.timepix.timepix.TimepixFlyBackend", return_value=backend),
# The full default pixel map takes seconds to build; no test here depends on it.
mock.patch(
"superxas_bec.devices.timepix.timepix.create_single_energy_per_chip_pixel_map",
return_value=PixelMap(chips=[[{"i": 0, "p": 0, "f": [1.0]}]]),
),
):
mock_cl.get_pv = MockPV
mock_cl.thread_class = threading.Thread
@@ -379,6 +473,75 @@ def test_timepix_msg_buffer_callback_updates_xes_signals(timepix):
assert _message_value(timepix.total_events) == 36
def test_timepix_msg_buffer_callback_accumulates_xes_energy_per_scan(timepix):
"""xes_accumulated_energy_1/2 sum the frames of one scan; staging starts a new sum."""
timepix.troin = 4
# one frame: xes_energy_1 = [4, 8, 12, 16], xes_energy_2 = [40, 80, 120, 160]
frames = _xes_frames(_level_test_spectra())
timepix.msg_buffer_callback(*frames)
timepix.msg_buffer_callback(*frames)
np.testing.assert_array_equal(
_message_value(timepix.xes_data_accumulated_1), np.array([8, 16, 24, 32])
)
np.testing.assert_array_equal(
_message_value(timepix.xes_data_accumulated_2), np.array([80, 160, 240, 320])
)
assert timepix.xes_data_accumulated_1.get().metadata["async_update"]["type"] == "replace"
# the per-frame signal still carries only the last frame
np.testing.assert_array_equal(_message_value(timepix.xes_energy_1), np.array([4, 8, 12, 16]))
_stage_for(timepix, "line_scan")
timepix.msg_buffer_callback(*frames)
np.testing.assert_array_equal(
_message_value(timepix.xes_data_accumulated_1), np.array([4, 8, 12, 16])
)
@pytest.mark.parametrize("scan_name", [None, "line_scan"])
def test_timepix_msg_buffer_callback_skips_levels_outside_timepix_scan(timepix, scan_name):
"""Frames before the first stage or during other scans must not touch the on/off levels."""
if scan_name is not None:
_set_scan_parameters(timepix, scan_name)
assert timepix._is_timepix_scan() is False
timepix.troin = 4
with ExitStack() as stack:
level_puts = [
stack.enter_context(mock.patch.object(getattr(timepix, name), "put"))
for name in LEVEL_SIGNALS
]
timepix.msg_buffer_callback(*_xes_frames(_level_test_spectra()))
for put in level_puts:
put.assert_not_called()
np.testing.assert_array_equal(
_message_value(timepix.xes_energy_1), np.array([4, 8, 12, 16], dtype=np.float32)
)
def test_timepix_msg_buffer_callback_publishes_on_off_levels(timepix):
"""During a timepix_scan the six level/diff signals are the means over the index windows."""
_set_scan_parameters(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS)
timepix.troin = 4
timepix.msg_buffer_callback(*_xes_frames(_level_test_spectra()))
# xes_energy_1 = [4, 8, 12, 16], xes_energy_2 = [40, 80, 120, 160]
# off window [0, 2), on window [2, 4)
expected = {
"xes_level_1_off": 6.0,
"xes_level_1_on": 14.0,
"xes_diff_1_on_off": 8.0,
"xes_level_2_off": 60.0,
"xes_level_2_on": 140.0,
"xes_diff_2_on_off": 80.0,
}
for name, value in expected.items():
assert _message_value(getattr(timepix, name)) == pytest.approx(value), name
def test_timepix_on_stop_stops_camera_writer_and_backend(timepix):
"""Stop should stop camera acquisition and delegate backend stop."""
timepix.cam.acquire.put(1)
@@ -391,6 +554,182 @@ def test_timepix_on_stop_stops_camera_writer_and_backend(timepix):
timepix.backend.on_stop.assert_called_once()
@pytest.mark.parametrize(
"window, expected",
[
(slice(0, 2), 1.5),
(slice(1, 4), 3.0),
(slice(2, 2), np.nan), # empty
(slice(3, 2), np.nan), # start after stop
(slice(2, 5), np.nan), # stop past the end
(slice(4, 5), np.nan), # start past the end
(slice(-1, 2), np.nan), # negative start
],
)
def test_window_mean(window, expected):
data = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float32)
np.testing.assert_equal(window_mean(data, window), expected)
def test_timepix_on_off_levels_are_nan_for_window_outside_received_bins(timepix):
"""If TimePixFly sends fewer time bins than requested, the affected levels become NaN."""
_set_scan_parameters(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS)
timepix.troin = 4
# only 3 time bins: xes_energy_1 = [4, 8, 12], xes_energy_2 = [40, 80, 120]
frames = _xes_frames(_level_test_spectra()[:3])
with mock.patch("superxas_bec.devices.timepix.timepix.logger") as logger:
timepix.msg_buffer_callback(*frames)
timepix.msg_buffer_callback(*frames)
assert _message_value(timepix.xes_level_1_off) == pytest.approx(6.0)
assert _message_value(timepix.xes_level_2_off) == pytest.approx(60.0)
for name in ("xes_level_1_on", "xes_level_2_on", "xes_diff_1_on_off", "xes_diff_2_on_off"):
assert np.isnan(_message_value(getattr(timepix, name))), name
# one warning per scan, not one per frame
assert logger.warning.call_count == 1
assert "on [2, 4)" in logger.warning.call_args.args[0]
def _stage_for(dev, scan_name: str, additional_scan_parameters: dict | None = None, **kwargs):
"""Stage the device for a scan, as the device server does (stage(), not on_stage())."""
dev.scan_info.msg = _scan_status_msg(scan_name, additional_scan_parameters, **kwargs)
with mock.patch(
"superxas_bec.devices.timepix.timepix.get_full_path",
return_value="/tmp/timepix/test_scan.h5",
):
return dev.stage()
def _prepare_settings_before_timepix_scan(dev):
"""Typical beamline state: image writing on, XES off, default time binning."""
_force_signal_value(dev.hdf.enable, "Enable")
dev.enable_xes = False
dev.troistep = 1
dev.troin = 5000
def _assert_settings_before_timepix_scan(dev):
assert dev.hdf.enable.get() == "Enable"
assert dev.enable_xes is False
assert dev.cam.tdc1_enable.get() == 0
assert dev.troistep == 1
assert dev.troin == 5000
assert dev._settings_before_timepix_scan is None
def test_timepix_scan_stage_overrides_settings_and_unstage_restores_them(timepix, pixel_map):
"""timepix_scan switches image writing off and XES on for the scan only."""
timepix._pixel_map = pixel_map
_prepare_settings_before_timepix_scan(timepix)
params = {**TIMEPIX_SCAN_PARAMETERS, "time_roi_step": 2}
with mock.patch.object(timepix.file_event, "put") as file_event_put:
_stage_for(timepix, "timepix_scan", params)
file_event_put.assert_not_called()
# The device server only unstages devices that report Staged.yes
assert timepix._staged == Staged.yes
assert timepix.hdf.enable.get() != "Enable"
assert timepix.hdf.capture.get() != 1
assert timepix.enable_xes is True
assert timepix.cam.tdc1_enable.get() == 1
other_config = timepix.backend.on_stage.call_args.kwargs["other_config"]
assert (other_config.TRoiStep, other_config.TRoiN) == (2, 4)
timepix.unstage()
assert timepix._staged == Staged.no
_assert_settings_before_timepix_scan(timepix)
def test_timepix_unstage_after_other_scan_keeps_settings(timepix):
"""Only timepix_scan touches image writing and XES."""
_prepare_settings_before_timepix_scan(timepix)
_stage_for(timepix, "line_scan")
timepix.unstage()
_assert_settings_before_timepix_scan(timepix)
def test_timepix_stop_restores_settings_of_aborted_timepix_scan(timepix, pixel_map):
"""An aborted scan is stopped but not unstaged; a later unstage must not restore again."""
timepix._pixel_map = pixel_map
_prepare_settings_before_timepix_scan(timepix)
_stage_for(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS)
timepix.stop()
# XES was on during the scan, so the backend collection is stopped as well
timepix.backend.on_stop.assert_called_once()
_assert_settings_before_timepix_scan(timepix)
# A user change after the abort survives the unstage the device server does before the
# next stage (no second restore).
timepix.set_enable_xes(True)
timepix.unstage()
assert timepix.enable_xes is True
def test_timepix_failed_restore_on_stop_is_retried_on_unstage(timepix, pixel_map):
timepix._pixel_map = pixel_map
_prepare_settings_before_timepix_scan(timepix)
_stage_for(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS)
working_set = timepix.hdf.enable.set
timepix.hdf.enable.set = mock.Mock(side_effect=TimeoutError("IOC did not answer"))
timepix.stop() # logs the error instead of raising
assert timepix._settings_before_timepix_scan is not None
timepix.hdf.enable.set = working_set
timepix.unstage()
_assert_settings_before_timepix_scan(timepix)
def test_timepix_failed_stage_restores_timepix_scan_settings(timepix, pixel_map):
"""If on_stage raises after the overrides were applied, they are undone right away."""
timepix._pixel_map = pixel_map
_prepare_settings_before_timepix_scan(timepix)
with pytest.raises(ValueError, match="Exposure time"):
_stage_for(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS, exp_time=0.001)
assert timepix._staged == Staged.no
_assert_settings_before_timepix_scan(timepix)
def test_timepix_failed_stage_and_failed_restore_keep_stage_error(timepix, pixel_map):
"""A failing restore must not hide why staging failed; the next stage restores instead."""
timepix._pixel_map = pixel_map
_prepare_settings_before_timepix_scan(timepix)
working_set = timepix.hdf.enable.set
def set_or_time_out(value, **kwargs):
if value == "Enable": # only the restore writes the saved readback value
raise TimeoutError("IOC did not answer")
return working_set(value, **kwargs)
timepix.hdf.enable.set = mock.Mock(side_effect=set_or_time_out)
with mock.patch("superxas_bec.devices.timepix.timepix.logger") as logger:
with pytest.raises(ValueError, match="Exposure time"):
_stage_for(timepix, "timepix_scan", TIMEPIX_SCAN_PARAMETERS, exp_time=0.001)
assert timepix._staged == Staged.no
assert timepix._settings_before_timepix_scan is not None
assert "IOC did not answer" in logger.error.call_args.args[0]
# Not staged, so the device server will not unstage it: the next stage puts the settings
# back before staging for a scan that is not a timepix_scan.
timepix.hdf.enable.set = working_set
_stage_for(timepix, "line_scan")
_assert_settings_before_timepix_scan(timepix)
# XES is off again, so TimePixFly is not configured for the line_scan
timepix.backend.on_stage.assert_not_called()
def test_timepix_on_destroy_cleans_up_backend(timepix):
"""Destroy should stop polling and forward cleanup to the backend."""
timepix.on_destroy()
@@ -425,3 +764,155 @@ def test_timepix_on_prescan_returns_correct_status(timepix):
status.wait(timeout=1)
assert status.done is True
assert status.success is True
# --- Lost acquisitions: TimePixFly closes the frame before the exposure ends (S00437 point 1) ---
def _wait_for(condition, timeout: float = 2.0):
"""Poll until condition() is true (the repetition runs in its own thread)."""
deadline = time.monotonic() + timeout
while not condition():
assert time.monotonic() < deadline, "condition not met in time"
time.sleep(0.01)
def _camera_acquires(dev):
"""The camera runs one acquisition: AcquireBusy DONE -> ACQUIRING -> DONE."""
_force_signal_value(dev.cam.acquire_busy, ACQUIRESTATUS.ACQUIRING)
_force_signal_value(dev.cam.acquire_busy, ACQUIRESTATUS.DONE)
def _empty_frames():
"""Frames of an acquisition in which TimePixFly saw no TDC pulse (as S00437, point 1)."""
start_frame, data_frames, end_frame = _xes_frames(np.zeros((4, 8), dtype=np.float32))
data_frames[0]["period"] = 0
return start_frame, data_frames, {**end_frame, "periods": 0}
@pytest.fixture
def xes_timepix(timepix):
"""Timepix armed for XES as in a timepix_scan: image writing off, 1 s exposure, 1 image."""
_force_signal_value(timepix.hdf.enable, "Disable")
timepix.troin = 4
timepix._exposure_per_trigger = 1.0
timepix._images_per_trigger = 1
backend_done = [StatusBase(), StatusBase()]
timepix.backend.on_trigger_finished = mock.Mock(side_effect=backend_done)
timepix.backend_done = backend_done
return timepix
def test_timepix_lost_acquisition_is_repeated_once(xes_timepix):
"""A frame closed before the exposure ends is dropped and the acquisition repeated."""
timepix = xes_timepix
with (
mock.patch.object(timepix.xes_data, "put") as xes_put,
mock.patch.object(timepix.static_spectra, "put") as spectra_put,
# a 2 x 2 detector image
mock.patch.object(timepix.image.array_size.height, "get", return_value=2),
mock.patch.object(timepix.image.array_size.width, "get", return_value=2),
mock.patch.object(timepix.image.array_data, "get", return_value=np.arange(4)),
):
status = timepix.on_trigger()
assert timepix.cam.acquire.get() == 1
# TimePixFly closes the frame at once, without TDC periods: nothing is published
timepix.msg_buffer_callback(*_empty_frames())
xes_put.assert_not_called()
# the image of the lost acquisition is not published either
timepix._on_new_image_received(value=1, old_value=0)
spectra_put.assert_not_called()
_camera_acquires(timepix)
timepix.backend_done[0].set_finished()
assert status.done is False
# the repetition arms TimePixFly again and restarts the camera
_wait_for(lambda: timepix.backend.on_trigger_finished.call_count == 2)
timepix.msg_buffer_callback(*_xes_frames(_level_test_spectra()))
timepix._on_new_image_received(value=2, old_value=1)
_camera_acquires(timepix)
timepix.backend_done[1].set_finished()
status.wait(timeout=2)
assert status.success is True
xes_put.assert_called_once()
np.testing.assert_array_equal(xes_put.call_args.args[0], _level_test_spectra())
spectra_put.assert_called_once()
assert not timepix._xes_acquisitions
def test_timepix_lost_repetition_is_published(xes_timepix):
"""If the repetition is lost too, its empty frame is published: the point keeps one row."""
timepix = xes_timepix
with mock.patch.object(timepix.xes_data, "put") as xes_put:
status = timepix.on_trigger()
timepix.msg_buffer_callback(*_empty_frames())
timepix._on_new_image_received(value=1, old_value=0)
_camera_acquires(timepix)
timepix.backend_done[0].set_finished()
_wait_for(lambda: timepix.backend.on_trigger_finished.call_count == 2)
with mock.patch("superxas_bec.devices.timepix.timepix.logger") as logger:
timepix.msg_buffer_callback(*_empty_frames())
_camera_acquires(timepix)
timepix.backend_done[1].set_finished()
status.wait(timeout=2)
assert status.success is True
xes_put.assert_called_once()
assert "cannot be repeated" in logger.error.call_args.args[0]
def test_timepix_frame_without_tdc_after_exposure_is_published(xes_timepix):
"""No TDC pulse during a whole exposure is a detector/TDC problem: publish, do not repeat."""
timepix = xes_timepix
timepix._exposure_per_trigger = 0.0 # the exposure has ended when the frame arrives
with (
mock.patch.object(timepix.xes_data, "put") as xes_put,
mock.patch("superxas_bec.devices.timepix.timepix.logger") as logger,
):
status = timepix.on_trigger()
timepix.msg_buffer_callback(*_empty_frames())
_camera_acquires(timepix)
timepix.backend_done[0].set_finished()
status.wait(timeout=1)
assert status.success is True
xes_put.assert_called_once()
assert timepix.backend.on_trigger_finished.call_count == 1
assert "no TDC pulse" in logger.error.call_args.args[0]
def test_timepix_lost_acquisition_not_repeated_while_writing_images(xes_timepix):
"""With HDF5 image writing on, a repetition would add an image: keep the point as it is."""
timepix = xes_timepix
_force_signal_value(timepix.hdf.enable, "Enable")
with mock.patch.object(timepix.xes_data, "put") as xes_put:
status = timepix.on_trigger()
timepix.msg_buffer_callback(*_empty_frames())
_camera_acquires(timepix)
timepix.backend_done[0].set_finished()
status.wait(timeout=1)
assert status.success is True
xes_put.assert_called_once()
assert timepix.backend.on_trigger_finished.call_count == 1
def test_timepix_stop_cancels_repetition_of_lost_acquisition(xes_timepix):
"""A stop while the lost acquisition is still running cancels the trigger, no repetition."""
timepix = xes_timepix
status = timepix.on_trigger()
timepix.msg_buffer_callback(*_empty_frames())
timepix.stop()
with pytest.raises(DeviceStoppedError):
status.wait(timeout=1)
_camera_acquires(timepix)
time.sleep(0.2)
assert timepix.backend.on_trigger_finished.call_count == 1
assert not timepix._xes_acquisitions
assert timepix._images_to_skip == 0
+75
View File
@@ -0,0 +1,75 @@
# pylint: skip-file
import pytest
from bec_lib.bec_errors import ScanInputValidationError
from bec_server.scan_server.tests.scan_fixtures import *
from superxas_bec.scans.timepix_scan import TimepixScan
TIMEPIX_PARAMETERS = {
"time_roi_step": 1,
"time_roi_n": 10,
"idx_off_start": 0,
"idx_off_stop": 4,
"idx_on_start": 5,
"idx_on_stop": 10,
}
def _assemble_timepix_scan(scan_assembler, **overrides):
params = {**TIMEPIX_PARAMETERS, **overrides}
return scan_assembler(
"timepix_scan", "samx", -1.0, 1.0, steps=3, relative=False, exp_time=0.1, **params
)
def test_timepix_scan_forwards_parameters_to_scan_info(scan_assembler):
"""The Timepix device reads the parameters from additional_scan_parameters."""
scan = _assemble_timepix_scan(scan_assembler)
assert isinstance(scan, TimepixScan)
assert scan.scan_info.scan_name == "timepix_scan"
for name, value in TIMEPIX_PARAMETERS.items():
assert scan.scan_info.additional_scan_parameters[name] == value
@pytest.mark.parametrize(
"overrides, match",
[
({"idx_off_start": 5, "idx_off_stop": 4}, "idx_off_start"),
({"idx_on_start": 9, "idx_on_stop": 6}, "idx_on_start"),
# start == stop would be an empty window
({"idx_off_start": 3, "idx_off_stop": 3}, "idx_off_start"),
({"idx_on_start": 7, "idx_on_stop": 7}, "idx_on_start"),
],
)
def test_timepix_scan_rejects_start_not_smaller_than_stop(scan_assembler, overrides, match):
with pytest.raises(ValueError, match=match):
_assemble_timepix_scan(scan_assembler, **overrides)
@pytest.mark.parametrize(
"overrides, name",
[
({"idx_off_stop": 11}, "idx_off_stop"),
({"idx_on_stop": 11}, "idx_on_stop"),
({"idx_on_start": 11, "idx_on_stop": 12}, "idx_on_start"),
],
)
def test_timepix_scan_rejects_index_above_time_roi_n(scan_assembler, overrides, name):
with pytest.raises(ValueError, match=f"{name} with value"):
_assemble_timepix_scan(scan_assembler, **overrides)
def test_timepix_scan_accepts_stop_equal_to_time_roi_n(scan_assembler):
"""stop is exclusive, so a window may end at time_roi_n."""
scan = _assemble_timepix_scan(scan_assembler, idx_on_start=9, idx_on_stop=10)
assert scan.scan_info.additional_scan_parameters["idx_on_stop"] == 10
@pytest.mark.parametrize(
"overrides", [{"idx_off_start": -1}, {"time_roi_n": 0}, {"time_roi_step": 0}]
)
def test_timepix_scan_argument_bounds_are_validated(scan_assembler, overrides):
with pytest.raises(ScanInputValidationError):
_assemble_timepix_scan(scan_assembler, **overrides)