Feat/laser delay #46

Closed
wyzula_j wants to merge 25 commits from feat/laser-delay into main
pull from: feat/laser-delay
14 changed files with 5190 additions and 78 deletions

No files matched your search

@@ -0,0 +1,27 @@
###################################
## Laser delay, simulated ##
###################################
# SimLaserDelay: same device as LaserDelay but with soft signals instead of the five EPICS PVs, for a
# local demo BEC. Add it to a running session:
# bec.config.add_to_session("<path to superxas_bec>/superxas_bec/device_configs/laser_delay_sim.yaml")
# Then: dev.laser_delay.calibrate(dev.laser_delay.sim_scope.get())
# scans.line_scan(dev.laser_delay, -5, 5, steps=21, exp_time=0.1, relative=False)
# sim_scope is a simulated oscilloscope (X-ray minus laser), sim_response a pump-probe step at 0 ns.
# step_sleep is shortened so that demo moves take seconds instead of tens of seconds; set it to 0.1
# to see the beamline timing. The calibration file is separate from the beamline one.
laser_delay:
readoutPriority: baseline
description: Simulated laser delay in ns (no EPICS)
deviceClass: superxas_bec.devices.laser_delay_sim.SimLaserDelay
deviceConfig:
step_sleep: 0.01
calibration_file: ~/.superxas_bec/laser_delay_sim_calibration.json
deviceTags:
- laser
- timing
- simulation
onFailure: retry
enabled: true
readOnly: false
softwareTrigger: false
@@ -136,4 +136,11 @@ es2_try:
prefix: X10DA-ES2-ET2:TRY
onFailure: retry
enabled: true
softwareTrigger: false
softwareTrigger: false
###################################
## Laser delay (pump-probe) ##
###################################
laser_delay_config:
- !include ./x10da_laser_delay.yaml
@@ -0,0 +1,42 @@
###################################
## Laser delay (pump-probe) ##
###################################
# Delay card X10DA-ES2-TIM:SET1 + RF phase shifter 2 (ES4) for the Duetto laser, external trigger.
# Usage and calibration: the LaserDelay docstrings in superxas_bec/devices/laser_delay.py; the user
# guide LASER_DELAY.md lives on the branch docs/laser-delay.
#
# readoutPriority baseline: every scan records the delay and the calibration once; a scan over
# laser_delay elevates it to monitored.
# onFailure buffer: if the timing or ES4 IOC is down, baseline readings fall back to the last value
# instead of aborting unrelated scans. If the PVs are unreachable at startup, BEC disables only this
# device (no I/O in __init__), the rest of the session loads.
# Calibration values are NOT config keys: run dev.laser_delay.calibrate(...) after the laser restart
# or the weekly scope check. They are kept across BEC restarts in a JSON file on the device server
# host (calibration_file). dev.laser_delay.get_calibration_info() shows where, when and from which
# scope value, and whether the restored calibration was verified.
laser_delay:
readoutPriority: baseline
description: Laser delay in ns (delay card + phase shifter 2, Duetto); calibrate after laser restart
deviceClass: superxas_bec.devices.laser_delay.LaserDelay
deviceConfig:
delay_coarse_pv: X10DA-ES2-TIM:SET1-DELAY
delay_fine_pv: X10DA-ES2-TIM:SET1-FINE
evr_period_pv: X10DA-ES2-TIM:EVR-FPS2
phase_v1_pv: X10DA-ES4:AO-02
phase_v2_pv: X10DA-ES4:AO-01
rf_period: 2.0
cavity_period: 12.01
vmax: 1.0
steps_per_rf_period: 36
step_sleep: 0.1
phase_path: script # "shortest" is opt-in, see the module docstring
fine_rollover: true # FINE 200 is written as the next coarse unit and 0 (open question)
calibration_file: ~/.superxas_bec/laser_delay_calibration.json
deviceTags:
- laser
- timing
onFailure: buffer
enabled: true
readOnly: false
softwareTrigger: false
@@ -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
File diff suppressed because it is too large. Load diff
+252
View File
@@ -0,0 +1,252 @@
"""
Simulated laser delay for SuperXAS: ``LaserDelay`` without EPICS, with a simulated laser and
oscilloscope. For a local demo BEC and tests only; the beamline device is
``superxas_bec.devices.laser_delay.LaserDelay``.
"""
from __future__ import annotations
import inspect
import math
import threading
from typing import Callable
import numpy as np
from bec_lib.logger import bec_logger
from ophyd import Component as Cpt
from ophyd import Kind, Signal
from superxas_bec.devices.laser_delay import (
DELAY_CARD_FINE_PER_COARSE,
PHASE_VOLTAGE_LIMIT,
CheckedSignal,
ComputedPhaseSignal,
LaserDelay,
_DeviceWritesOnly,
delay_card_time,
)
logger = bec_logger.logger
class _HardwareSoftSignal(_DeviceWritesOnly, CheckedSignal):
"""Soft stand-in for a hardware PV in the simulation (written only by the device)."""
def _sim_range(name: str, low: float, high: float) -> Callable[[object], None]:
def check(value):
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{name} must be a number, got {value!r}") from exc
if not low <= number <= high:
raise ValueError(f"{name} must be within [{low}, {high}], got {value!r}")
return check
class SimLaserDelay(LaserDelay):
"""LaserDelay without EPICS, with a simulated oscilloscope (local demo BEC, tests).
Soft signals replace the five PVs; the ``*_pv`` arguments are accepted and ignored. They refuse
what the IOCs are assumed to refuse: |V1|, |V2| above 5 V and FINE outside 0..199. Start values:
laser period 2.0002 µs, delay card 150 / 37 (300.37 ns), V1 = 1, V2 = 0.
The simulated laser emits a pulse train with the cavity period, locked to the RF: turning the
phase shifter by ``x`` ns (tracked continuously, also across whole RF periods) shifts the train
by ``x`` ns. The delay card triggers the laser, which fires the first pulse of the train at or
after the trigger. ``sim_scope`` is the X-ray minus laser time an oscilloscope would show; it
starts at -0.88 ns, so ``calibrate(-0.88)`` (or
``calibrate(dev.laser_delay.sim_scope.get())``) is a correct calibration. After any move
``sim_scope`` should equal the readback within 0.01 ns. ``sim_response`` is a pump-probe signal:
a step of width ``sim_rise`` ns at ``sim_time_zero`` (scope time), plus noise.
``sim_set_hardware(...)`` changes the simulated PVs as somebody outside BEC would (caput, an IOC
reboot), to try the safety checks.
Args:
sim_cavity_period (float | None): cavity period of the simulated laser in ns. None: the
device's ``cavity_period``. A different value shows what a wrong ``cavity_period``
does.
sim_time_zero (float): scope delay of the pump-probe step in ns.
sim_rise (float): width of the step in ns.
sim_noise (float): standard deviation of the noise on ``sim_response``.
"""
USER_ACCESS = LaserDelay.USER_ACCESS + ["sim_set_hardware"]
#: The simulation may ramp faster than the beamline (demo, tests).
MIN_STEP_SLEEP = 0.0
delay_coarse = Cpt(
_HardwareSoftSignal,
value=150,
kind=Kind.normal,
validator=_sim_range("delay_coarse", 0, 65535),
doc="Delay card, units of 2 ns",
)
delay_fine = Cpt(
_HardwareSoftSignal,
value=37,
kind=Kind.normal,
validator=_sim_range("delay_fine", 0, DELAY_CARD_FINE_PER_COARSE - 1),
doc="Delay card, units of 0.01 ns",
)
evr_period = Cpt(Signal, value=2.0002, kind=Kind.config, doc="EVR laser period in µs")
phase_v1 = Cpt(
_HardwareSoftSignal,
value=1.0,
kind=Kind.normal,
validator=_sim_range("phase_v1", -PHASE_VOLTAGE_LIMIT, PHASE_VOLTAGE_LIMIT),
doc="Phase shifter V1",
)
phase_v2 = Cpt(
_HardwareSoftSignal,
value=0.0,
kind=Kind.normal,
validator=_sim_range("phase_v2", -PHASE_VOLTAGE_LIMIT, PHASE_VOLTAGE_LIMIT),
doc="Phase shifter V2",
)
sim_scope = Cpt(
ComputedPhaseSignal,
compute="read_sim_scope",
kind=Kind.normal,
doc="Simulated oscilloscope: X-ray minus laser in ns",
)
sim_response = Cpt(
ComputedPhaseSignal,
compute="read_sim_response",
kind=Kind.hinted,
doc="Simulated pump-probe signal",
)
#: Simulated X-ray arrival and laser pulse train (ns, delay card time scale). With the start
#: values a pulse sits half a cavity period after the trigger, and the scope shows -0.88 ns.
_SIM_XRAY_TIME = 305.495
_SIM_TRAIN_OFFSET = 6.125
def __init__(
self,
prefix: str = "",
*,
name: str,
sim_cavity_period: float | None = None,
sim_time_zero: float = 0.0,
sim_rise: float = 0.3,
sim_noise: float = 0.01,
**kwargs,
):
super().__init__(prefix=prefix, name=name, **kwargs)
self._sim_cavity_period = sim_cavity_period
self._sim_time_zero = float(sim_time_zero)
self._sim_rise = float(sim_rise)
self._sim_noise = float(sim_noise)
self._sim_rng = np.random.default_rng()
self._sim_lock = threading.Lock()
self._sim_angle = math.atan2(self.phase_v2.get(), self.phase_v1.get())
self._sim_phase_turned = 0.0 # unwrapped phase shift in ns since the start
self.phase_v1.subscribe(self._sim_track_phase, run=False)
self.phase_v2.subscribe(self._sim_track_phase, run=False)
def sim_set_hardware(
self,
delay_coarse: int | None = None,
delay_fine: int | None = None,
phase_v1: float | None = None,
phase_v2: float | None = None,
) -> None:
"""Change the simulated PVs as somebody outside BEC would (caput, IOC reboot).
A voltage jump is followed by the simulated laser like any other phase change.
"""
with self._hardware_writes():
for key, value in (
("delay_coarse", delay_coarse),
("delay_fine", delay_fine),
("phase_v1", phase_v1),
("phase_v2", phase_v2),
):
if value is not None:
getattr(self, key).put(value)
def _sim_track_phase(self, *_, **__) -> None:
"""Follow the phase shifter continuously, counting whole RF periods (the laser lock)."""
with self._sim_lock:
v1, v2 = self.phase_v1.get(), self.phase_v2.get()
if math.hypot(v1, v2) == 0:
return # no phase defined; the lock keeps the last one
angle = math.atan2(v2, v1)
turned = (angle - self._sim_angle + math.pi) % (2 * math.pi) - math.pi
self._sim_angle = angle
self._sim_phase_turned += float(self.rf_period.get()) * turned / (2 * math.pi)
def on_connected(self) -> None:
"""
Called after the device is connected and its signals are connected.
Default values for signals should be set here.
"""
# Real IOCs and the laser keep their state across a BEC restart; soft signals do not. The
# calibration file therefore also keeps the simulated state; bring it back first, then
# restore the calibration like the real device.
try:
content = self._read_saved_calibration()
sim = (content or {}).get("sim")
if sim:
self.sim_set_hardware(**sim.get("hardware", {}))
with (
self._sim_lock
): # the puts above were tracked as turns; reset to the saved state
self._sim_angle = math.atan2(self.phase_v2.get(), self.phase_v1.get())
self._sim_phase_turned = float(sim["phase_turned"])
except Exception as exc: # pylint: disable=broad-except
logger.error(f"{self.name}: could not restore the simulated hardware: {exc}")
super().on_connected()
def _extra_saved_state(self) -> dict:
hardware = {
"delay_coarse": int(self.delay_coarse.get()),
"delay_fine": int(self.delay_fine.get()),
"phase_v1": float(self.phase_v1.get()),
"phase_v2": float(self.phase_v2.get()),
}
with self._sim_lock:
return {"sim": {"phase_turned": self._sim_phase_turned, "hardware": hardware}}
def read_sim_scope(self) -> float:
"""X-ray minus laser in ns, as the oscilloscope would show it."""
cavity = self._sim_cavity_period or float(self.cavity_period.get())
trigger = delay_card_time(self.delay_coarse.get(), self.delay_fine.get())
with self._sim_lock:
train = self._SIM_TRAIN_OFFSET + self._sim_phase_turned
laser = train + math.ceil((trigger - train) / cavity) * cavity
return self._SIM_XRAY_TIME - laser
def read_sim_response(self) -> float:
"""Pump-probe signal: step at ``sim_time_zero`` (scope time) plus noise."""
x = (self.read_sim_scope() - self._sim_time_zero) / (math.sqrt(2) * self._sim_rise)
noise = self._sim_rng.normal(0, self._sim_noise) if self._sim_noise > 0 else 0.0
return 0.5 * (1 + math.erf(x)) + noise
def _merged_signature(init: Callable, base_init: Callable) -> inspect.Signature:
"""``init``'s own parameters plus the keyword parameters of ``base_init``.
BEC's device server only passes ``deviceConfig`` keys (and ``device_manager``/``scan_info``)
that appear in the signature of the device class itself, so a subclass ``__init__`` that
forwards ``**kwargs`` has to show the base class parameters.
"""
own = [p for p in inspect.signature(init).parameters.values() if p.kind != p.VAR_KEYWORD]
names = {p.name for p in own}
base = [
p
for p in inspect.signature(base_init).parameters.values()
if p.kind == p.KEYWORD_ONLY and p.name not in names
]
var_kw = [
p for p in inspect.signature(base_init).parameters.values() if p.kind == p.VAR_KEYWORD
]
return inspect.signature(init).replace(parameters=own + base + var_kw)
SimLaserDelay.__init__.__signature__ = _merged_signature(
SimLaserDelay.__init__, LaserDelay.__init__
)
+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,
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,
)
@@ -0,0 +1,234 @@
#!/usr/bin/python
# Reference only: the beamline scientist's laser-delay script, attached to request 2.
# Kept verbatim (Python 2 era, CaChannel/epicsPV) as the behavioural spec for the BEC
# LaserDelay device. Do not import or run it from BEC code.
from CaChannel import ca, CaChannel
from epicsPV import epicsPV
from math import *
from numpy import *
import time
#Possible values of Laser: Duetto, KML, XITON, PI
Laser="Duetto"
Beamline="SuperXAS"
#LaserTriggerMode: "internal" or "external" External means from Event receiver.
LaserTriggerMode="external"
# Units of time are nanoseconds
#If internal triggering is used, set LaserPeriod (time between two laser pulses) as it is set at the laser
LaserPeriod=0
if (LaserTriggerMode=="internal"): LaserPeriod=2000.2
#Timing mode "DelayAndPhase" - use both Delay card and phase shifter, "Delay" - use only delay card, "Phase" - use only phase shifter
if (Laser=="Duetto"):
TimingMode="DelayAndPhase"
#Index Of delay channel (as in EPICS name) used for laser
DelayChIndex=1
EVRChIndex=2
#For SuperXAS 1 corresponds to the old (KML) phase shifter
#For SuperXAS 2 corresponds to ES4 phase shifter
PhaseShifterIndex=2
#change only for another synchrotron
RFPeriod=2.0
DelayCh=CaChannel()
DelayChFine=CaChannel()
EVRCh=CaChannel()
PhaseV1=CaChannel()
PhaseV2=CaChannel()
def ConnectEpics():
global CavityPeriod, Laser, Beamline
global DelayCh, DelayChFine, EVRCh, PhaseV1, PhaseV2
if (Laser=="Duetto"):
CavityPeriod=12.01
if (Beamline=="SuperXAS"):
DN="X10DA-ES2-TIM:SET"+str(DelayChIndex)
EVRChName="X10DA-ES2-TIM:EVR-FPS"+str(EVRChIndex)
if (PhaseShifterIndex==1):
PhaseV1Name="X10DA-LAS-PHSH:V1"
PhaseV2Name="X10DA-LAS-PHSH:V2"
if (PhaseShifterIndex==2):
PhaseV1Name="X10DA-ES4:AO-02"
PhaseV2Name="X10DA-ES4:AO-01"
if (Beamline=="Phoenix"):
DN="X07MB-ES-TIM:SET"+str(DelayChIndex)
EVRChName="X07MB-ES-TIM:EVR-FPS"+str(EVRChIndex)
PhaseV1Name="X07MB-ES1-PP2:VO2"
PhaseV2Name="X07MB-ES1-PP2:VO1"
DelayChName=DN+"-DELAY"
DelayChFineName=DN+"-FINE"
if ((TimingMode=="Delay") or (TimingMode=="DelayAndPhase")):
DelayCh.searchw(DelayChName)
DelayChFine.searchw(DelayChFineName)
if ((TimingMode=="Phase") or (TimingMode=="DelayAndPhase")):
PhaseV1.searchw(PhaseV1Name)
PhaseV2.searchw(PhaseV2Name)
if (LaserTriggerMode=="external"):
EVRCh.searchw(EVRChName)
#Maybe will be useful to automatically set pulse width for different lasers
#def SetPulseWidth():
def CurrentSituation(FromScopeXrayMinusLaser):
global LaserPeriod, OffsetT
if (TimingMode=="Delay"):
PhaseT=0
if (TimingMode=="Phase"):
DelayCardT=0
if (LaserTriggerMode=="external"):
LaserPeriod=1000*EVRCh.getw()
print ("LaserPeriod=", LaserPeriod)
if ((TimingMode=="Delay") or (TimingMode=="DelayAndPhase")):
dt1=DelayCh.getw()
dt2=DelayChFine.getw()
DelayCardT=2.0*dt1+0.01*dt2
if ((TimingMode=="Phase") or (TimingMode=="DelayAndPhase")):
PhaseT=GetPhaseT()
Ph=FromScopeXrayMinusLaser%CavityPeriod
OffsetPhaseT=-PhaseT-Ph
OffsetT=-DelayCardT-FromScopeXrayMinusLaser
print ("LaserPeriod=", LaserPeriod)
print ("OffsetT=", OffsetT)
print ("Current PhaseT", PhaseT)
print ("Offset Phse shifter T", OffsetPhaseT)
qwe=(LaserPeriod, OffsetT, OffsetPhaseT, PhaseT)
# print ("qwe=",qwe)
savetxt("Timing.offset", qwe)
def GetPhaseT():
v1=PhaseV1.getw()
v2=PhaseV2.getw()
phi=atan2(v2,v1)
T=RFPeriod*phi/(2*pi)
return T
def SetVoltages(phi):
#Vmax may be different for different lasers or phase shifters
#if (PhaseShifterIndex==1):
Vmax=1.0
V1=Vmax*cos(phi)
V2=Vmax*sin(phi)
PhaseV1.putw(V1)
PhaseV2.putw(V2)
#set values via epics
print (V1, V2)
time.sleep(0.1)
def MovePhaseShifter(RelativeDelay):
NumStepsPerPeriod=36
DelayStep=sign(RelativeDelay)*RFPeriod/NumStepsPerPeriod
#read current values of
CurT=GetPhaseT()
for t1 in arange(0,RelativeDelay, DelayStep):
phi1=2*pi*(CurT+t1)/RFPeriod
SetVoltages(phi1)
phi1=2*pi*(CurT+RelativeDelay)/RFPeriod
SetVoltages(phi1)
def MoveDelayCard(NewDelayTime):
print ("ndt,lp", NewDelayTime, LaserPeriod)
t1=NewDelayTime%LaserPeriod
t2=int(t1/2.0)
t3=int((t1%2.0)/0.009999)
print ("t1 t2 t3 ",t1,t2,t3)
DelayCh.putw(t2)
DelayChFine.putw(t3)
def SetDelay(t=0):
global LaserPeriod, OffsetT
a=loadtxt("Timing.offset")
LaserPeriod=a[0]
OffsetT=a[1]
print ("a1=",a[1])
OffsetPhaseT=a[2]
print ("OffsetPhaseT from File", OffsetPhaseT)
CurrentPhaseT=a[3]
print ("Current value of PhaseT", CurrentPhaseT)
TotalT=-OffsetT-t
ForPhaseShifter=-t%CavityPeriod-OffsetPhaseT
print ("-t/Cavity", -t%CavityPeriod, "-OffsetPhaseT", OffsetPhaseT, "-CurrentPhaseT", CurrentPhaseT)
MoveDelayCard(TotalT)
print ("Relative Phase shifter delay", ForPhaseShifter-CurrentPhaseT)
MovePhaseShifter(ForPhaseShifter-CurrentPhaseT)
print ("OffsetT before save=", OffsetT)
savetxt("Timing.offset",(LaserPeriod,OffsetT,OffsetPhaseT,ForPhaseShifter))
def main():
ConnectEpics()
# CurSit=-20.015+15.46
# #CurSit=-14.58+15.46
# CurSit=546.04-546.92
#CurSit=0.1
#CurrentSituation(FromScopeXrayMinusLaser=CurSit)
print ("------------------")
SetDelay(-4.9)
# SetDelay(0.5)
# time.sleep(5)
# SetDelay(.5)
if __name__=="__main__":
main()
#MovePhaseShifter(2.0)
#MoveDelayCard(25.)
#time.sleep(10)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,457 @@
"""Golden tests: the LaserDelay device must write exactly what the scientist's script writes.
The reference is the beamline scientist's ``DelayLaser.py`` (CaChannel, Python 2 era), kept
verbatim in ``reference/DelayLaser.py.txt``. It is executed here as a module with fake
``CaChannel``/``epicsPV`` modules that record every put. Its ``Timing.offset`` state file goes to
``tmp_path``. The device runs on mocked PVs (``patched_device``) and must produce the same PV write
sequence (delay card, then every V1/V2 pair of the phase ramp) and the same calibration state for
every case.
"""
# pylint: disable=redefined-outer-name,protected-access,no-member
from __future__ import annotations
import math
import sys
import types
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
import pytest
from ophyd_devices.tests.utils import patched_device
from superxas_bec.devices.laser_delay import (
LaserDelay,
LaserDelayHardwareError,
delay_card_setting,
phase_from_voltages,
phase_ramp_offsets,
voltages_for_phase,
wrap_phase,
)
REFERENCE_SCRIPT = Path(__file__).parent / "reference" / "DelayLaser.py.txt"
DELAY = "X10DA-ES2-TIM:SET1-DELAY"
FINE = "X10DA-ES2-TIM:SET1-FINE"
EVR = "X10DA-ES2-TIM:EVR-FPS2"
V1 = "X10DA-ES4:AO-02"
V2 = "X10DA-ES4:AO-01"
FLOAT_TOL = 1e-12
@pytest.fixture(autouse=True)
def fast_ramp(monkeypatch):
"""Tests ramp without sleeping; the beamline minimum is tested on its own."""
monkeypatch.setattr(LaserDelay, "MIN_STEP_SLEEP", 0.0)
# Two hardware states to start from (values as the IOCs would report them).
STATE_A = {EVR: 2.0002, DELAY: 150, FINE: 37, V1: 0.6, V2: -0.8}
STATE_B = {EVR: 1.0, DELAY: 10, FINE: 150, V1: -0.3, V2: 0.95}
@dataclass
class Scenario:
"""Calibrate with ``scope`` from ``initial``, then run ``steps``.
A step is a float (move to that delay) or a dict of PV values changed outside both programs
(somebody turning the phase shifter knobs).
"""
initial: dict
scope: float
steps: list = field(default_factory=list)
SCENARIOS = {
"scope_cal_and_basic_moves": Scenario(STATE_A, 546.04 - 546.92, [-4.9, 0.0, 0.5, 5.3]),
"cross_cavity_period": Scenario(STATE_A, 546.04 - 546.92, [11.9, 12.2, 11.9]),
"large_negative_delays": Scenario(STATE_A, 546.04 - 546.92, [-1500.3, -250.37, -24.02]),
"laser_period_wrap": Scenario(STATE_A, 546.04 - 546.92, [400.0, -1800.0, 299.49, 299.5]),
"large_phase_moves_both_directions": Scenario(
STATE_A, 546.04 - 546.92, [0.1, 6.0, 0.1, 11.95, 0.05]
),
"other_state_positive_scope": Scenario(STATE_B, 37.25, [37.25, 30.0, -3.33, 1000.0]),
"other_state_wrap_short_period": Scenario(STATE_B, -14.58 + 15.46, [-990.0, 2.5, 999.99]),
}
# --------------------------------------------------------------------------------------------- #
# Reference script with fake EPICS #
# --------------------------------------------------------------------------------------------- #
class FakeIOC:
"""PV values shared by all fake channels; records puts in order."""
def __init__(self, initial: dict):
self.values = dict(initial)
self.puts: list[tuple[str, object]] = []
def load_reference_script(ioc: FakeIOC, monkeypatch, tmp_path, sleeps: list) -> types.ModuleType:
"""Execute the reference script as a fresh module talking to ``ioc``."""
class CaChannel: # pylint: disable=invalid-name
"""Fake of CaChannel.CaChannel: searchw/getw/putw."""
def __init__(self):
self.pvname = None
def searchw(self, pvname):
self.pvname = pvname
def getw(self):
return ioc.values[self.pvname]
def putw(self, value):
ioc.puts.append((self.pvname, value))
ioc.values[self.pvname] = value
ca_module = types.ModuleType("CaChannel")
ca_module.CaChannel = CaChannel
ca_module.ca = types.SimpleNamespace()
epics_module = types.ModuleType("epicsPV")
epics_module.epicsPV = object
monkeypatch.setitem(sys.modules, "CaChannel", ca_module)
monkeypatch.setitem(sys.modules, "epicsPV", epics_module)
monkeypatch.chdir(tmp_path) # Timing.offset is written to the working directory
module = types.ModuleType("delay_laser_reference")
module.__file__ = str(REFERENCE_SCRIPT)
source = REFERENCE_SCRIPT.read_text(encoding="utf-8")
code = compile(source, str(REFERENCE_SCRIPT), "exec")
exec(code, module.__dict__) # pylint: disable=exec-used
module.time = types.SimpleNamespace(sleep=sleeps.append)
module.ConnectEpics()
return module
def run_reference(scenario: Scenario, monkeypatch, tmp_path) -> dict:
"""Run a scenario through the script. Returns calibration, puts and state per move."""
ioc = FakeIOC(scenario.initial)
sleeps: list = []
script = load_reference_script(ioc, monkeypatch, tmp_path, sleeps)
script.CurrentSituation(scenario.scope)
result = {"calibration": list(np.loadtxt("Timing.offset")), "moves": []}
for step in scenario.steps:
if isinstance(step, dict):
ioc.values.update(step)
continue
ioc.puts.clear()
sleeps.clear()
script.SetDelay(step)
result["moves"].append(
{
"target": step,
"puts": list(ioc.puts),
"sleeps": len(sleeps),
"state": list(np.loadtxt("Timing.offset")),
}
)
return result
# --------------------------------------------------------------------------------------------- #
# Device with recorded puts #
# --------------------------------------------------------------------------------------------- #
def record_puts(device: LaserDelay) -> list:
"""Record every put to the four written PVs of ``device``, in order."""
log: list[tuple[str, object]] = []
for signal in (device.delay_coarse, device.delay_fine, device.phase_v1, device.phase_v2):
pv = signal._write_pv
original = pv.put
def recorded(value, *args, _pv=pv, _original=original, **kwargs):
log.append((_pv.pvname, value))
return _original(value, *args, **kwargs)
pv.put = recorded
return log
def set_pv_values(device: LaserDelay, values: dict) -> None:
"""Set mocked PV values without recording them as puts."""
signals = {
DELAY: device.delay_coarse,
FINE: device.delay_fine,
EVR: device.evr_period,
V1: device.phase_v1,
V2: device.phase_v2,
}
for pvname, value in values.items():
signals[pvname]._read_pv.mock_data = value
def run_device(scenario: Scenario, **device_kwargs) -> dict:
"""Run a scenario through the device (default configuration unless ``device_kwargs``)."""
device_kwargs.setdefault("fine_rollover", False) # the script writes FINE 200
with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0, **device_kwargs) as device:
set_pv_values(device, scenario.initial)
log = record_puts(device)
calibration = device.calibrate(scenario.scope)
result = {
"calibration": [
calibration[key]
for key in ("laser_period", "offset_t", "offset_phase_t", "phase_t")
],
"moves": [],
}
for step in scenario.steps:
if isinstance(step, dict):
set_pv_values(device, step)
continue
log.clear()
status = device.set(step)
status.wait(timeout=30)
assert status.success
state = device.get_calibration()
result["moves"].append(
{
"target": step,
"puts": list(log),
"state": [
state[key]
for key in ("laser_period", "offset_t", "offset_phase_t", "phase_t")
],
"readback": device.read()["laser_delay"]["value"],
}
)
return result
# --------------------------------------------------------------------------------------------- #
# Comparison helpers #
# --------------------------------------------------------------------------------------------- #
def assert_same_value(expected, actual, context: str) -> None:
"""Integers must match exactly (and be integers); floats within FLOAT_TOL."""
if isinstance(expected, (int, np.integer)) and not isinstance(expected, bool):
assert isinstance(actual, (int, np.integer)), f"{context}: {actual!r} is not an integer"
assert int(actual) == int(expected), f"{context}: {actual} != {expected}"
return
assert math.isclose(
float(actual), float(expected), rel_tol=FLOAT_TOL, abs_tol=FLOAT_TOL
), f"{context}: {actual!r} != {expected!r} (diff {float(actual) - float(expected):.3e})"
def assert_same_puts(expected: list, actual: list, context: str) -> None:
"""Same PVs in the same order, same values."""
assert [pv for pv, _ in actual] == [
pv for pv, _ in expected
], f"{context}: PV sequence differs (expected {len(expected)} puts, got {len(actual)})"
for index, ((pv, expected_value), (_, actual_value)) in enumerate(zip(expected, actual)):
assert_same_value(expected_value, actual_value, f"{context}, put #{index} to {pv}")
# --------------------------------------------------------------------------------------------- #
# Tests #
# --------------------------------------------------------------------------------------------- #
@pytest.mark.parametrize("scenario_id", list(SCENARIOS))
def test_device_matches_reference_script(scenario_id, monkeypatch, tmp_path):
"""Calibration and every move write the same PV sequence as the script."""
scenario = SCENARIOS[scenario_id]
expected = run_reference(scenario, monkeypatch, tmp_path)
actual = run_device(scenario)
for key, exp, act in zip(
("laser_period", "offset_t", "offset_phase_t", "phase_t"),
expected["calibration"],
actual["calibration"],
):
assert_same_value(exp, act, f"{scenario_id}: calibration {key}")
assert len(actual["moves"]) == len(expected["moves"])
for exp_move, act_move in zip(expected["moves"], actual["moves"]):
context = f"{scenario_id}: move to {exp_move['target']}"
assert_same_puts(exp_move["puts"], act_move["puts"], context)
# delay card first, then pairs of V1/V2; one script sleep per pair
pvs = [pv for pv, _ in exp_move["puts"]]
assert pvs[:2] == [DELAY, FINE]
assert pvs[2::2] == [V1] * exp_move["sleeps"] and pvs[3::2] == [V2] * exp_move["sleeps"]
for key, exp, act in zip(
("laser_period", "offset_t", "offset_phase_t", "phase_t"),
exp_move["state"],
act_move["state"],
):
assert_same_value(exp, act, f"{context}: stored {key}")
assert act_move["readback"] == exp_move["target"]
@pytest.mark.parametrize("scenario_id", list(SCENARIOS))
def test_shortest_phase_path_against_reference_script(scenario_id, monkeypatch, tmp_path):
"""Opt-in phase_path="shortest" on the golden scenarios.
The delay card writes are the script's. Each ramp is at most half a cavity period and turns the
phase shifter by exactly the change of the stored phase, which stays the script's phase minus
whole cavity periods (k). Without hand-made voltage changes, every move therefore ends where
the script's move ends up to k x 12.01 ns of phase, i.e. -k x 0.01 ns modulo the 2 ns RF
period: the documented caveat of folding with RFPeriod 2.0 and CavityPeriod 12.01.
"""
scenario = SCENARIOS[scenario_id]
expected = run_reference(scenario, monkeypatch, tmp_path)
actual = run_device(scenario, phase_path="shortest")
for key, exp, act in zip(
("laser_period", "offset_t", "offset_phase_t", "phase_t"),
expected["calibration"],
actual["calibration"],
):
assert_same_value(exp, act, f"{scenario_id}: calibration {key}")
max_pairs = math.ceil(6.005 / (2.0 / 36)) + 1
touched = False
voltages = {"exp": (scenario.initial[V1], scenario.initial[V2])}
voltages["act"] = voltages["exp"]
stored = {"exp": expected["calibration"][3], "act": actual["calibration"][3]}
moves = iter(zip(expected["moves"], actual["moves"], strict=True))
for step in scenario.steps:
if isinstance(step, dict): # both programs see the same hand-made change
voltages = {
key: (step.get(V1, v[0]), step.get(V2, v[1])) for key, v in voltages.items()
}
touched = True
continue
exp_move, act_move = next(moves)
context = f"{scenario_id}: move to {exp_move['target']}"
assert_same_puts(exp_move["puts"][:2], act_move["puts"][:2], f"{context}: delay card")
assert 1 <= (len(act_move["puts"]) - 2) // 2 <= max_pairs, context
for index in range(3): # laser_period, offset_t, offset_phase_t
assert_same_value(exp_move["state"][index], act_move["state"][index], context)
assert act_move["readback"] == exp_move["target"]
wraps = (exp_move["state"][3] - act_move["state"][3]) / 12.01
assert wraps == pytest.approx(round(wraps), abs=1e-9), context
turned = act_move["state"][3] - stored["act"]
assert abs(turned) <= 6.005 + 1e-9, context
start = {key: phase_from_voltages(*v, 2.0) for key, v in voltages.items()}
voltages = {
"exp": (exp_move["puts"][-2][1], exp_move["puts"][-1][1]),
"act": (act_move["puts"][-2][1], act_move["puts"][-1][1]),
}
end = {key: phase_from_voltages(*v, 2.0) for key, v in voltages.items()}
assert wrap_phase(end["act"] - start["act"] - turned, 2.0) == pytest.approx(
0, abs=1e-9
), context
if not touched:
assert wrap_phase(end["act"] - end["exp"], 2.0) == pytest.approx(
wrap_phase(-0.01 * round(wraps), 2.0), abs=1e-9
), context
stored = {"exp": exp_move["state"][3], "act": act_move["state"][3]}
def test_scenario_table_covers_the_edge_cases(monkeypatch, tmp_path):
"""Guard the table itself: both laser-period wraps, a cavity-period crossing and long phase
ramps in both directions really occur."""
totals, phase_deltas, crossings = [], [], 0
for scenario in SCENARIOS.values():
expected = run_reference(scenario, monkeypatch, tmp_path)
laser_period, offset_t, _, phase_t = expected["calibration"]
targets = [move["target"] for move in expected["moves"]]
for previous, current in zip(targets, targets[1:]):
if math.floor(previous / 12.01) != math.floor(current / 12.01):
crossings += 1
for move in expected["moves"]:
totals.append((-offset_t - move["target"], laser_period))
phase_deltas.append(move["state"][3] - phase_t)
phase_t = move["state"][3]
assert any(total < 0 for total, _ in totals), "no case wraps below zero"
assert any(total >= period for total, period in totals), "no case wraps above the laser period"
assert crossings > 0, "no move crosses a cavity period"
assert min(phase_deltas) < -10 and max(phase_deltas) > 10, "no long ramps in both directions"
def test_zero_phase_move_script_fails_device_writes_final_step(monkeypatch, tmp_path):
"""Moving to the same delay twice: the script crashes in numpy.arange (step 0), the device
writes the same delay card values and only the final (unchanged) phase step."""
scenario = Scenario(STATE_A, 546.04 - 546.92, [0.5])
ioc = FakeIOC(scenario.initial)
script = load_reference_script(ioc, monkeypatch, tmp_path, [])
script.CurrentSituation(scenario.scope)
script.SetDelay(0.5)
ioc.puts.clear()
with pytest.raises((ValueError, ZeroDivisionError)):
script.SetDelay(0.5)
script_card_puts = list(ioc.puts)
assert [pv for pv, _ in script_card_puts] == [DELAY, FINE]
with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as device:
set_pv_values(device, scenario.initial)
device.calibrate(scenario.scope)
device.set(0.5).wait(timeout=30)
log = record_puts(device)
v1_before, v2_before = device.phase_v1.get(), device.phase_v2.get()
status = device.set(0.5)
status.wait(timeout=5)
assert status.success
assert_same_puts(script_card_puts, log[:2], "repeat move: delay card")
assert [pv for pv, _ in log[2:]] == [V1, V2]
assert_same_value(v1_before, log[2][1], "repeat move: V1")
assert_same_value(v2_before, log[3][1], "repeat move: V2")
def test_delay_card_conversion_matches_script_for_many_values(monkeypatch, tmp_path):
"""int() truncations of MoveDelayCard, checked on many delays incl. near-integer boundaries."""
ioc = FakeIOC(STATE_A)
script = load_reference_script(ioc, monkeypatch, tmp_path, [])
rng = np.random.default_rng(20261013)
totals = list(rng.uniform(-5000.0, 5000.0, 3000))
# values whose fine part sits on a 0.01 ns boundary, where truncation is fragile
totals += [round(k * 0.01, 2) for k in range(0, 2001)]
totals += [k * 0.01 for k in range(-400, 400)]
for laser_period in (2000.2, 1000.0, 1000 * 2.0002, 12.01 * 83.0):
script.LaserPeriod = laser_period
for total in totals:
ioc.puts.clear()
script.MoveDelayCard(total)
(_, coarse), (_, fine) = ioc.puts # pylint: disable=unbalanced-tuple-unpacking
assert delay_card_setting(total, laser_period) == (coarse, fine), (total, laser_period)
def test_phase_ramp_matches_script_for_many_moves(monkeypatch, tmp_path):
"""Ramp values (numpy.arange steps plus final step) of MovePhaseShifter for random moves."""
ioc = FakeIOC(STATE_A)
script = load_reference_script(ioc, monkeypatch, tmp_path, [])
rng = np.random.default_rng(42)
for _ in range(200):
angle = rng.uniform(-np.pi, np.pi)
ioc.values[V1], ioc.values[V2] = float(np.cos(angle)), float(np.sin(angle))
start = script.GetPhaseT()
relative = float(rng.uniform(-24.0, 24.0))
ioc.puts.clear()
script.MovePhaseShifter(relative)
expected = [value for _, value in ioc.puts]
actual = []
for offset in list(phase_ramp_offsets(relative, 2.0, 36)) + [relative]:
actual.extend(voltages_for_phase(start + offset, 2.0, 1.0))
assert len(actual) == len(expected)
for exp, act in zip(expected, actual):
assert_same_value(exp, act, f"phase ramp {relative}")
def test_device_refuses_where_the_script_follows_touched_voltages(monkeypatch, tmp_path):
"""Voltages turned by hand between moves: the script ramps from the touched phase and ends
off by that amount; the device refuses the move and writes nothing (calibrate again)."""
scenario = Scenario(STATE_A, 546.04 - 546.92, [1.0, {V1: 0.0, V2: 1.0}, 2.0])
expected = run_reference(scenario, monkeypatch, tmp_path)
assert len(expected["moves"]) == 2 # the script moves twice
with patched_device(
LaserDelay, name="laser_delay", step_sleep=0.0, fine_rollover=False
) as device:
set_pv_values(device, scenario.initial)
device.calibrate(scenario.scope)
device.set(1.0).wait(timeout=30)
set_pv_values(device, {V1: 0.0, V2: 1.0})
log = record_puts(device)
status = device.set(2.0)
with pytest.raises(LaserDelayHardwareError):
status.wait(timeout=30)
assert log == []
+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)