From 3b0017ca9988af0402d0dc929b13309eb61c68a5 Mon Sep 17 00:00:00 2001 From: x10da Date: Tue, 22 Sep 2026 09:35:02 +0200 Subject: [PATCH 01/25] Introduction of new timepix scan --- .../device_configs/x10da_standard_config.yaml | 29 ++++++- superxas_bec/devices/timepix/timepix.py | 80 +++++++++++++----- superxas_bec/scans/__init__.py | 1 + superxas_bec/scans/timepix_scan.py | 82 +++++++++++++++++++ 4 files changed, 171 insertions(+), 21 deletions(-) create mode 100644 superxas_bec/scans/timepix_scan.py diff --git a/superxas_bec/device_configs/x10da_standard_config.yaml b/superxas_bec/device_configs/x10da_standard_config.yaml index 04808aa..8588c7d 100644 --- a/superxas_bec/device_configs/x10da_standard_config.yaml +++ b/superxas_bec/device_configs/x10da_standard_config.yaml @@ -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 \ No newline at end of file + - !include ./x10da_experimental_hutch.yaml diff --git a/superxas_bec/devices/timepix/timepix.py b/superxas_bec/devices/timepix/timepix.py index 5ebe319..4fbef0f 100644 --- a/superxas_bec/devices/timepix/timepix.py +++ b/superxas_bec/devices/timepix/timepix.py @@ -17,9 +17,9 @@ 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_devices import ( AsyncSignal, CompareStatus, @@ -44,6 +44,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 @@ -221,9 +222,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 +269,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( @@ -407,22 +418,50 @@ 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 + if self.scan_parameters.scan_name == "timepix_scan": + idx_off_start = self.scan_parameters.additional_scan_parameters["idx_off_start"] + idx_off_stop = self.scan_parameters.additional_scan_parameters["idx_off_stop"] + idx_on_start = self.scan_parameters.additional_scan_parameters["idx_on_start"] + idx_on_stop = self.scan_parameters.additional_scan_parameters["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 > len(data_1): + raise ValueError( + f"{name} with value {idx} exceeds length of data array of {len(data_1)}" + ) + level_1_off = np.mean(data_1[idx_off_start:idx_off_stop]) + level_1_on = np.mean(data_1[idx_on_start:idx_on_stop]) + level_2_off = np.mean(data_2[idx_off_start:idx_off_stop]) + level_2_on = np.mean(data_2[idx_on_start:idx_on_stop]) + 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) + self.xes_spectra.put( xes_data.sum(axis=1), async_update={"type": "add", "max_shape": [None, troin]} ) @@ -621,7 +660,8 @@ class Timepix(PSIDeviceBase, TimePixControl): def on_stage(self) -> StatusBase | None: """Called while staging the device.""" - ### + self.scan_parameters = fetch_scan_info(self.scan_info) + self.accumulated_data_e1 = None self.accumulated_data_e2 = None scan_msg: ScanStatusMessage = self.scan_info.msg # type: ignore diff --git a/superxas_bec/scans/__init__.py b/superxas_bec/scans/__init__.py index 5f4a83b..078ccff 100644 --- a/superxas_bec/scans/__init__.py +++ b/superxas_bec/scans/__init__.py @@ -3,3 +3,4 @@ from .mono_bragg_scans import ( # XASAdvancedScanWithXRD,; XASSimpleScanWithXRD XASSimpleScan, ) from .nidaq_cont_scan import NIDAQContinuousScan +from .timepix_scan import TimepixScan diff --git a/superxas_bec/scans/timepix_scan.py b/superxas_bec/scans/timepix_scan.py new file mode 100644 index 0000000..99bdec2 --- /dev/null +++ b/superxas_bec/scans/timepix_scan.py @@ -0,0 +1,82 @@ +""" +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"], + "Timepix 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 + idx_off_start: Annotated[int, ScanArgument(display_name="Index Off Start", description="Start Index for Off Level", ge=0)], + idx_off_stop: Annotated[int, ScanArgument(display_name="Index Off Stop", description="Stop Index for Off Level", ge=0)], + idx_on_start: Annotated[int, ScanArgument(display_name="Index On Start", description="Start Index for On Level", ge=0)], + idx_on_stop: Annotated[int, ScanArgument(display_name="Index On Stop", description="Stop Index for On Level", ge=0)], + # fmt: on + **kwargs, + ): + if idx_off_start > idx_off_stop: + raise ValueError( + f"idx_off_start ({idx_off_start}) cannot be bigger than idx_off_stop ({idx_off_stop})!" + ) + if idx_on_start > idx_on_stop: + raise ValueError( + f"idx_on_start ({idx_on_start}) cannot be bigger than idx_on_stop ({idx_on_stop})!" + ) + + super().__init__( + *args, + steps=steps, + relative=relative, + exp_time=exp_time, + burst_at_each_point=burst_at_each_point, + **kwargs, + ) + + self.idx_off_start = idx_off_start + self.idx_off_stop = idx_off_stop + self.idx_on_start = idx_on_start + self.idx_on_stop = idx_on_stop + + self.update_scan_info( + idx_off_start=idx_off_start, + idx_off_stop=idx_off_stop, + idx_on_start=idx_on_start, + idx_on_stop=idx_on_stop, + ) -- 2.54.0 From 200c2cdfdb366d255a1ab595e6a5a5d6b62ad359 Mon Sep 17 00:00:00 2001 From: x10da Date: Tue, 22 Sep 2026 11:19:25 +0200 Subject: [PATCH 02/25] Added ROI parameters --- superxas_bec/devices/timepix/timepix.py | 23 +++++++++++------------ superxas_bec/scans/timepix_scan.py | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/superxas_bec/devices/timepix/timepix.py b/superxas_bec/devices/timepix/timepix.py index 4fbef0f..aa55a9f 100644 --- a/superxas_bec/devices/timepix/timepix.py +++ b/superxas_bec/devices/timepix/timepix.py @@ -280,8 +280,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" @@ -441,16 +441,6 @@ class Timepix(PSIDeviceBase, TimePixControl): idx_off_stop = self.scan_parameters.additional_scan_parameters["idx_off_stop"] idx_on_start = self.scan_parameters.additional_scan_parameters["idx_on_start"] idx_on_stop = self.scan_parameters.additional_scan_parameters["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 > len(data_1): - raise ValueError( - f"{name} with value {idx} exceeds length of data array of {len(data_1)}" - ) level_1_off = np.mean(data_1[idx_off_start:idx_off_stop]) level_1_on = np.mean(data_1[idx_on_start:idx_on_stop]) level_2_off = np.mean(data_2[idx_off_start:idx_off_stop]) @@ -662,6 +652,12 @@ class Timepix(PSIDeviceBase, TimePixControl): """Called while staging the device.""" self.scan_parameters = fetch_scan_info(self.scan_info) + if self.scan_parameters.scan_name == "timepix_scan": + self._troistep = self.scan_parameters.additional_scan_parameters["time_roi_step"] + self._troin = self.scan_parameters.additional_scan_parameters["time_roi_n"] + self.hdf.enable.put(0) + self.set_enable_xes(True) + self.accumulated_data_e1 = None self.accumulated_data_e2 = None scan_msg: ScanStatusMessage = self.scan_info.msg # type: ignore @@ -726,6 +722,9 @@ class Timepix(PSIDeviceBase, TimePixControl): self.cam.acquire.put(0) status_camera = CompareStatus(self.cam.acquire_busy, ACQUIRESTATUS.DONE) status_camera.wait(timeout=self._pv_timeout) + if self.scan_parameters.scan_name == "timepix_scan": + self.hdf.enable.put(1) + self.set_enable_xes(False) def on_pre_scan(self) -> StatusBase: """Called right before the scan starts on all devices automatically.""" diff --git a/superxas_bec/scans/timepix_scan.py b/superxas_bec/scans/timepix_scan.py index 99bdec2..802442f 100644 --- a/superxas_bec/scans/timepix_scan.py +++ b/superxas_bec/scans/timepix_scan.py @@ -33,7 +33,8 @@ class TimepixScan(LineScan): gui_config = { "Movement Parameters": ["steps", "relative"], "Acquisition Parameters": ["exp_time", "burst_at_each_point"], - "Timepix Parameters": ["idx_off_start", "idx_off_stop", "idx_on_start", "idx_on_stop"], + "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__( @@ -44,6 +45,8 @@ class TimepixScan(LineScan): 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", ge=0)], idx_off_stop: Annotated[int, ScanArgument(display_name="Index Off Stop", description="Stop Index for Off Level", ge=0)], idx_on_start: Annotated[int, ScanArgument(display_name="Index On Start", description="Start Index for On Level", ge=0)], @@ -59,6 +62,16 @@ class TimepixScan(LineScan): raise ValueError( f"idx_on_start ({idx_on_start}) cannot be bigger 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, @@ -69,12 +82,16 @@ class TimepixScan(LineScan): **kwargs, ) + self.time_roi_step = time_roi_step + self.time_roi_n = time_roi_n self.idx_off_start = idx_off_start self.idx_off_stop = idx_off_stop self.idx_on_start = idx_on_start self.idx_on_stop = idx_on_stop 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, -- 2.54.0 From 01e819c4902bc64591531561077f7c8517e8173f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 16:29:30 +0200 Subject: [PATCH 03/25] test(timepix): build fixture scan info from a real scan status message --- tests/tests_devices/test_timepix.py | 44 +++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/tests/tests_devices/test_timepix.py b/tests/tests_devices/test_timepix.py index 0010025..c4e210f 100644 --- a/tests/tests_devices/test_timepix.py +++ b/tests/tests_devices/test_timepix.py @@ -3,13 +3,15 @@ from __future__ import annotations import threading -from types import SimpleNamespace 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 bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo from ophyd import DeviceStatus, StatusBase from ophyd_devices.tests.utils import MockPV, patch_dual_pvs @@ -124,18 +126,42 @@ 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, + ) + + @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), -- 2.54.0 From 26e268aef120ee0c8c13641dcb01e9644bb9b5b3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 16:32:17 +0200 Subject: [PATCH 04/25] fix(timepix): skip on/off levels when no timepix_scan is staged --- superxas_bec/devices/timepix/timepix.py | 26 ++++-- tests/tests_devices/test_timepix.py | 107 ++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/superxas_bec/devices/timepix/timepix.py b/superxas_bec/devices/timepix/timepix.py index aa55a9f..a5680d3 100644 --- a/superxas_bec/devices/timepix/timepix.py +++ b/superxas_bec/devices/timepix/timepix.py @@ -193,6 +193,9 @@ 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" + class Timepix(PSIDeviceBase, TimePixControl): """ @@ -436,11 +439,14 @@ class Timepix(PSIDeviceBase, TimePixControl): ) ### Additional signals for timepix scan - if self.scan_parameters.scan_name == "timepix_scan": - idx_off_start = self.scan_parameters.additional_scan_parameters["idx_off_start"] - idx_off_stop = self.scan_parameters.additional_scan_parameters["idx_off_stop"] - idx_on_start = self.scan_parameters.additional_scan_parameters["idx_on_start"] - idx_on_stop = self.scan_parameters.additional_scan_parameters["idx_on_stop"] + # 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(): + params = self.scan_parameters.additional_scan_parameters + idx_off_start = params["idx_off_start"] + idx_off_stop = params["idx_off_stop"] + idx_on_start = params["idx_on_start"] + idx_on_stop = params["idx_on_stop"] level_1_off = np.mean(data_1[idx_off_start:idx_off_stop]) level_1_on = np.mean(data_1[idx_on_start:idx_on_stop]) level_2_off = np.mean(data_2[idx_off_start:idx_off_stop]) @@ -457,6 +463,12 @@ class Timepix(PSIDeviceBase, TimePixControl): ) logger.debug(f"Device data set for Timepix with {tds_period}, {tds_total_events}") + 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: @@ -652,7 +664,7 @@ class Timepix(PSIDeviceBase, TimePixControl): """Called while staging the device.""" self.scan_parameters = fetch_scan_info(self.scan_info) - if self.scan_parameters.scan_name == "timepix_scan": + if self._is_timepix_scan(): self._troistep = self.scan_parameters.additional_scan_parameters["time_roi_step"] self._troin = self.scan_parameters.additional_scan_parameters["time_roi_n"] self.hdf.enable.put(0) @@ -722,7 +734,7 @@ class Timepix(PSIDeviceBase, TimePixControl): self.cam.acquire.put(0) status_camera = CompareStatus(self.cam.acquire_busy, ACQUIRESTATUS.DONE) status_camera.wait(timeout=self._pv_timeout) - if self.scan_parameters.scan_name == "timepix_scan": + if self._is_timepix_scan(): self.hdf.enable.put(1) self.set_enable_xes(False) diff --git a/tests/tests_devices/test_timepix.py b/tests/tests_devices/test_timepix.py index c4e210f..29b4a50 100644 --- a/tests/tests_devices/test_timepix.py +++ b/tests/tests_devices/test_timepix.py @@ -3,6 +3,7 @@ from __future__ import annotations import threading +from contextlib import ExitStack from unittest import mock import numpy as np @@ -31,6 +32,7 @@ from superxas_bec.devices.timepix.timepix_fly_client.timepix_fly_interface impor NetAddresses, PixelMap, ) +from superxas_bec.devices.utils.utils import fetch_scan_info # pylint: disable=protected-access # pylint: disable=redefined-outer-name @@ -157,6 +159,64 @@ def _scan_status_msg( ) +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.""" @@ -165,6 +225,11 @@ def timepix(): 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 @@ -405,6 +470,48 @@ def test_timepix_msg_buffer_callback_updates_xes_signals(timepix): assert _message_value(timepix.total_events) == 36 +@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) -- 2.54.0 From ef8bf026f19a0bb78fa130fe1b47d31b0d731728 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 16:33:37 +0200 Subject: [PATCH 05/25] fix(timepix_scan): reject empty on/off windows and test argument validation --- superxas_bec/scans/timepix_scan.py | 19 ++++--- tests/tests_scans/test_timepix_scan.py | 75 ++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 tests/tests_scans/test_timepix_scan.py diff --git a/superxas_bec/scans/timepix_scan.py b/superxas_bec/scans/timepix_scan.py index 802442f..5e51e27 100644 --- a/superxas_bec/scans/timepix_scan.py +++ b/superxas_bec/scans/timepix_scan.py @@ -47,20 +47,23 @@ class TimepixScan(LineScan): # 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", ge=0)], - idx_off_stop: Annotated[int, ScanArgument(display_name="Index Off Stop", description="Stop Index for Off Level", ge=0)], - idx_on_start: Annotated[int, ScanArgument(display_name="Index On Start", description="Start Index for On Level", ge=0)], - idx_on_stop: Annotated[int, ScanArgument(display_name="Index On Stop", description="Stop Index for On Level", ge=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, ): - if idx_off_start > idx_off_stop: + # 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}) cannot be bigger than idx_off_stop ({idx_off_stop})!" + f"idx_off_start ({idx_off_start}) must be smaller than idx_off_stop ({idx_off_stop})!" ) - if idx_on_start > idx_on_stop: + if idx_on_start >= idx_on_stop: raise ValueError( - f"idx_on_start ({idx_on_start}) cannot be bigger than idx_on_stop ({idx_on_stop})!" + 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), diff --git a/tests/tests_scans/test_timepix_scan.py b/tests/tests_scans/test_timepix_scan.py new file mode 100644 index 0000000..87c4dce --- /dev/null +++ b/tests/tests_scans/test_timepix_scan.py @@ -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) -- 2.54.0 From 7598f459ce3e44e00b679f875f8cceab0a79793e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 16:37:49 +0200 Subject: [PATCH 06/25] fix(timepix): restore timepix_scan settings on unstage, stop and failed stage --- superxas_bec/devices/timepix/timepix.py | 84 +++++++++++++++--- tests/tests_devices/test_timepix.py | 110 +++++++++++++++++++++++- 2 files changed, 180 insertions(+), 14 deletions(-) diff --git a/superxas_bec/devices/timepix/timepix.py b/superxas_bec/devices/timepix/timepix.py index a5680d3..fdea1d6 100644 --- a/superxas_bec/devices/timepix/timepix.py +++ b/superxas_bec/devices/timepix/timepix.py @@ -20,6 +20,7 @@ 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 Staged from ophyd_devices import ( AsyncSignal, CompareStatus, @@ -302,13 +303,26 @@ 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 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. + self._restore_settings_after_timepix_scan() + raise + self._staged = Staged.yes if isinstance(status, StatusBase): return status return [] @@ -665,10 +679,7 @@ class Timepix(PSIDeviceBase, TimePixControl): self.scan_parameters = fetch_scan_info(self.scan_info) if self._is_timepix_scan(): - self._troistep = self.scan_parameters.additional_scan_parameters["time_roi_step"] - self._troin = self.scan_parameters.additional_scan_parameters["time_roi_n"] - self.hdf.enable.put(0) - self.set_enable_xes(True) + self._apply_timepix_scan_settings(self.scan_parameters.additional_scan_parameters) self.accumulated_data_e1 = None self.accumulated_data_e2 = None @@ -728,15 +739,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 and on_unstage can both 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) - if self._is_timepix_scan(): - self.hdf.enable.put(1) - self.set_enable_xes(False) + 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.""" @@ -864,6 +913,15 @@ class Timepix(PSIDeviceBase, TimePixControl): # Backend if self.enable_xes is True: self.backend.on_stop() + # 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.""" diff --git a/tests/tests_devices/test_timepix.py b/tests/tests_devices/test_timepix.py index 29b4a50..f7c943a 100644 --- a/tests/tests_devices/test_timepix.py +++ b/tests/tests_devices/test_timepix.py @@ -13,7 +13,7 @@ from bec_lib.devicemanager import ScanInfo from bec_lib.messages import ScanStatusMessage from bec_server.device_server.tests.utils import DMMock from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo -from ophyd import DeviceStatus, StatusBase +from ophyd import DeviceStatus, Staged, StatusBase from ophyd_devices.tests.utils import MockPV, patch_dual_pvs from superxas_bec.devices.timepix.timepix import ( @@ -524,6 +524,114 @@ def test_timepix_on_stop_stops_camera_writer_and_backend(timepix): timepix.backend.on_stop.assert_called_once() +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_on_destroy_cleans_up_backend(timepix): """Destroy should stop polling and forward cleanup to the backend.""" timepix.on_destroy() -- 2.54.0 From ddef0448bf3300d4d27eb5d94b5b5e7eb0213091 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 16:40:55 +0200 Subject: [PATCH 07/25] fix(timepix): publish NaN for on/off windows outside the received time bins --- superxas_bec/devices/timepix/timepix.py | 82 ++++++++++++++++++++----- tests/tests_devices/test_timepix.py | 38 ++++++++++++ 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/superxas_bec/devices/timepix/timepix.py b/superxas_bec/devices/timepix/timepix.py index fdea1d6..3481515 100644 --- a/superxas_bec/devices/timepix/timepix.py +++ b/superxas_bec/devices/timepix/timepix.py @@ -167,6 +167,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.""" @@ -306,6 +327,8 @@ class Timepix(PSIDeviceBase, TimePixControl): # 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 def stage(self) -> list[object] | StatusBase: # type: ignore """Stage the device. @@ -456,27 +479,55 @@ class Timepix(PSIDeviceBase, TimePixControl): # 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(): - params = self.scan_parameters.additional_scan_parameters - idx_off_start = params["idx_off_start"] - idx_off_stop = params["idx_off_stop"] - idx_on_start = params["idx_on_start"] - idx_on_stop = params["idx_on_stop"] - level_1_off = np.mean(data_1[idx_off_start:idx_off_stop]) - level_1_on = np.mean(data_1[idx_on_start:idx_on_stop]) - level_2_off = np.mean(data_2[idx_off_start:idx_off_stop]) - level_2_on = np.mean(data_2[idx_on_start:idx_on_stop]) - 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) + 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 _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 ( @@ -683,6 +734,7 @@ class Timepix(PSIDeviceBase, TimePixControl): 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: diff --git a/tests/tests_devices/test_timepix.py b/tests/tests_devices/test_timepix.py index f7c943a..86576a3 100644 --- a/tests/tests_devices/test_timepix.py +++ b/tests/tests_devices/test_timepix.py @@ -26,6 +26,7 @@ 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 ( @@ -524,6 +525,43 @@ 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) -- 2.54.0 From 092500a06b55d9472717b11022eb91dd333e037f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 18:25:09 +0200 Subject: [PATCH 08/25] fix(timepix): keep the stage error when the timepix_scan restore also fails --- superxas_bec/devices/timepix/timepix.py | 17 +++++++++++--- tests/tests_devices/test_timepix.py | 31 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/superxas_bec/devices/timepix/timepix.py b/superxas_bec/devices/timepix/timepix.py index 3481515..0b5ce5c 100644 --- a/superxas_bec/devices/timepix/timepix.py +++ b/superxas_bec/devices/timepix/timepix.py @@ -342,8 +342,16 @@ class Timepix(PSIDeviceBase, TimePixControl): 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. - self._restore_settings_after_timepix_scan() + # 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): @@ -731,6 +739,9 @@ class Timepix(PSIDeviceBase, TimePixControl): 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 @@ -817,7 +828,7 @@ class Timepix(PSIDeviceBase, TimePixControl): 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 and on_unstage can both call it. + 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: diff --git a/tests/tests_devices/test_timepix.py b/tests/tests_devices/test_timepix.py index 86576a3..9049450 100644 --- a/tests/tests_devices/test_timepix.py +++ b/tests/tests_devices/test_timepix.py @@ -670,6 +670,37 @@ def test_timepix_failed_stage_restores_timepix_scan_settings(timepix, pixel_map) _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() -- 2.54.0 From c7c70751b461228c8167ddd87006d6c14d103193 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 18:26:08 +0200 Subject: [PATCH 09/25] test(timepix): cover the re-enabled accumulated XES signals --- tests/tests_devices/test_timepix.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/tests_devices/test_timepix.py b/tests/tests_devices/test_timepix.py index 9049450..c041629 100644 --- a/tests/tests_devices/test_timepix.py +++ b/tests/tests_devices/test_timepix.py @@ -471,6 +471,33 @@ 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.""" -- 2.54.0 From 72258a670d2e6fdd28cf5f5bf037f5ba2b487923 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 8 Oct 2026 15:09:31 +0200 Subject: [PATCH 10/25] fix(timepix): repeat an acquisition whose TimePixFly frame closes before the exposure ends --- superxas_bec/devices/timepix/timepix.py | 281 ++++++++++++++++++++---- tests/tests_devices/test_timepix.py | 154 +++++++++++++ 2 files changed, 397 insertions(+), 38 deletions(-) diff --git a/superxas_bec/devices/timepix/timepix.py b/superxas_bec/devices/timepix/timepix.py index 0b5ce5c..d328c39 100644 --- a/superxas_bec/devices/timepix/timepix.py +++ b/superxas_bec/devices/timepix/timepix.py @@ -12,6 +12,9 @@ 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 @@ -21,6 +24,8 @@ from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInf from ophyd import ADBase from ophyd import Component as Cpt from ophyd import Staged +from ophyd.status import StatusTimeoutError, WaitTimeoutError +from ophyd.utils import InvalidState from ophyd_devices import ( AsyncSignal, CompareStatus, @@ -218,6 +223,31 @@ 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): """ @@ -329,6 +359,13 @@ class Timepix(PSIDeviceBase, TimePixControl): 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. @@ -414,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: @@ -494,6 +533,59 @@ class Timepix(PSIDeviceBase, TimePixControl): ) 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. @@ -717,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() @@ -754,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) @@ -868,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.""" @@ -976,6 +1180,7 @@ 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: diff --git a/tests/tests_devices/test_timepix.py b/tests/tests_devices/test_timepix.py index c041629..c7afa1f 100644 --- a/tests/tests_devices/test_timepix.py +++ b/tests/tests_devices/test_timepix.py @@ -3,6 +3,7 @@ from __future__ import annotations import threading +import time from contextlib import ExitStack from unittest import mock @@ -14,6 +15,7 @@ from bec_lib.messages import ScanStatusMessage from bec_server.device_server.tests.utils import DMMock 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 ( @@ -762,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 -- 2.54.0 From 43843b5164b3eeeea7ac182eb71f88d547422b69 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 12 Oct 2026 11:35:49 +0200 Subject: [PATCH 11/25] refactor(timepix_scan): drop unused scan argument attributes --- superxas_bec/scans/timepix_scan.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/superxas_bec/scans/timepix_scan.py b/superxas_bec/scans/timepix_scan.py index 5e51e27..f2bcced 100644 --- a/superxas_bec/scans/timepix_scan.py +++ b/superxas_bec/scans/timepix_scan.py @@ -85,13 +85,6 @@ class TimepixScan(LineScan): **kwargs, ) - self.time_roi_step = time_roi_step - self.time_roi_n = time_roi_n - self.idx_off_start = idx_off_start - self.idx_off_stop = idx_off_stop - self.idx_on_start = idx_on_start - self.idx_on_stop = idx_on_stop - self.update_scan_info( time_roi_step=time_roi_step, time_roi_n=time_roi_n, -- 2.54.0 From 2ad960624406ba509217202ebf958257f412944b Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 17:01:29 +0200 Subject: [PATCH 12/25] feat(devices): add LaserDelay positioner for the delay card and RF phase shifter --- superxas_bec/devices/laser_delay.py | 819 ++++++++++++++++++++++++++++ 1 file changed, 819 insertions(+) create mode 100644 superxas_bec/devices/laser_delay.py diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py new file mode 100644 index 0000000..adae6b2 --- /dev/null +++ b/superxas_bec/devices/laser_delay.py @@ -0,0 +1,819 @@ +""" +Laser delay positioner for SuperXAS (X10DA): delay card plus RF phase shifter. + +Port of the beamline scientist's ``DelayLaser.py`` (Duetto laser, timing mode ``DelayAndPhase``, +external trigger from the event receiver, phase shifter 2 in ES4). All times are in ns. + +How a delay ``t`` (X-ray minus laser, as read on the oscilloscope) is set: + +* **Delay card (coarse + fine).** ``t1 = (-offset_t - t) mod laser_period``; + ``DELAY = int(t1 / 2)`` (units of 2 ns) and ``FINE = int((t1 mod 2) / 0.009999)`` + (units of 0.01 ns). The divisor 0.009999 instead of 0.01 is taken over from the script on + purpose: it stops values such as ``0.5 / 0.01 = 49.99999...`` from being truncated one unit low. +* **Phase shifter (two voltages V1 = vmax cos(phi), V2 = vmax sin(phi)).** Target phase + ``P = (-t mod cavity_period) - offset_phase_t``. The move is relative: ``dP = P - phase_t`` + where ``phase_t`` is the phase stored by the previous move (``phase_reference="stored"``, the + script's behaviour) or the phase read back from V1/V2 (``phase_reference="readback"``). The ramp + always starts at the phase read back from V1/V2 and walks in steps of + ``sign(dP) * rf_period / steps_per_rf_period`` with ``step_sleep`` seconds between steps; the + last step lands exactly on ``readback + dP``. Afterwards ``P`` is stored as the new ``phase_t``. + +The stored phase is "unwrapped": the voltages only encode the phase modulo one RF period, while the +stored value keeps track of how many RF periods the shifter has been turned. That is why the script +(and the default ``phase_reference="stored"``) computes ``dP`` from the stored value. If somebody +changes V1/V2 outside this device, stored and read-back phase no longer agree modulo the RF +period; the ``phase_mismatch`` signal shows the difference and a warning is logged at the start of +every move. + +Calibration (the script's ``CurrentSituation``, run after every laser restart) reads the EVR laser +period, the delay card and the phase shifter and combines them with the oscilloscope reading +``s`` = X-ray minus laser: ``offset_t = -(2 DELAY + 0.01 FINE) - s`` and +``offset_phase_t = -phase_t - (s mod cavity_period)``. The script kept these numbers in a file +``Timing.offset``; here they are config signals on the device (see ``LASER_DELAY.md``). + +The readback of the positioner is the last commanded delay. After ``calibrate(s)`` it is ``s``, +because the model maps the current settings to ``t = s``. It is NaN before calibration and after an +interrupted move. +""" + +from __future__ import annotations + +import functools +import math +import threading +import time +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Callable + +import numpy as np +from bec_lib.logger import bec_logger +from ophyd import Component as Cpt +from ophyd import EpicsSignal, EpicsSignalRO +from ophyd import FormattedComponent as FCpt +from ophyd import Kind, Signal +from ophyd.positioner import PositionerBase +from ophyd.signal import InternalSignal, SignalRO +from ophyd.utils import InvalidState, LimitError +from ophyd_devices import DeviceStatus, PSIDeviceBase, StatusBase +from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError + +if TYPE_CHECKING: # pragma: no cover + from bec_lib.devicemanager import DeviceManagerBase, ScanInfo + +logger = bec_logger.logger + +#: Delay card coarse unit in ns (``X10DA-ES2-TIM:SET1-DELAY``). +DELAY_CARD_COARSE_UNIT = 2.0 +#: Delay card fine unit in ns (``X10DA-ES2-TIM:SET1-FINE``), used when reading the card. +DELAY_CARD_FINE_UNIT = 0.01 +#: Divisor used when *writing* the fine value. Kept from the script (see module docstring). +DELAY_CARD_FINE_DIVISOR = 0.009999 + +PHASE_REFERENCES = ("stored", "readback") + + +class LaserDelayNotCalibratedError(RuntimeError): + """Raised when a move is requested before the device has been calibrated.""" + + +class LaserDelayBusyError(RuntimeError): + """Raised when an action needs the device idle while a move is still running.""" + + +# --------------------------------------------------------------------------------------------- # +# Maths, kept as plain functions so that they can be checked against the reference script. # +# --------------------------------------------------------------------------------------------- # + + +def delay_card_setting(total_t: float, laser_period: float) -> tuple[int, int]: + """Coarse and fine delay card values for a total delay, exactly as the script's MoveDelayCard. + + Args: + total_t (float): ``-offset_t - t`` in ns. + laser_period (float): laser period in ns. + + Returns: + tuple[int, int]: ``(DELAY, FINE)``, in units of 2 ns and 0.01 ns. + """ + t1 = total_t % laser_period + coarse = int(t1 / DELAY_CARD_COARSE_UNIT) + fine = int((t1 % DELAY_CARD_COARSE_UNIT) / DELAY_CARD_FINE_DIVISOR) + return coarse, fine + + +def delay_card_time(coarse: float, fine: float) -> float: + """Delay in ns currently set on the delay card (script: ``DelayCardT``).""" + return DELAY_CARD_COARSE_UNIT * coarse + DELAY_CARD_FINE_UNIT * fine + + +def phase_from_voltages(v1: float, v2: float, rf_period: float) -> float: + """Phase shifter delay in ns from the two voltages (script: ``GetPhaseT``), in [-rf/2, rf/2].""" + phi = np.arctan2(v2, v1) + return rf_period * phi / (2 * np.pi) + + +def voltages_for_phase(phase_t: float, rf_period: float, vmax: float) -> tuple[float, float]: + """V1 and V2 for a phase shifter delay in ns (script: ``SetVoltages``).""" + phi = 2 * np.pi * phase_t / rf_period + return vmax * np.cos(phi), vmax * np.sin(phi) + + +def phase_ramp_offsets(delta: float, rf_period: float, steps_per_rf_period: int) -> np.ndarray: + """Intermediate phase offsets (relative to the ramp start) of a phase shifter move. + + Same values as the script's ``arange(0, RelativeDelay, DelayStep)``. The final step to exactly + ``delta`` is not included. The script fails on ``delta == 0`` (numpy cannot size the range); + here the ramp is simply empty and only the final step is written. + """ + if delta == 0: + return np.empty(0) + step = np.sign(delta) * rf_period / steps_per_rf_period + return np.arange(0, delta, step) + + +def wrap_phase(value: float, rf_period: float) -> float: + """Wrap a phase difference in ns into [-rf/2, rf/2).""" + return (value + rf_period / 2) % rf_period - rf_period / 2 + + +@dataclass(frozen=True) +class LaserDelayCalibration: + """Calibration of the delay, the four numbers of the script's ``Timing.offset`` file.""" + + laser_period: float + offset_t: float + offset_phase_t: float + phase_t: float + + def as_dict(self) -> dict[str, float]: + """Plain dictionary (RPC friendly).""" + return {key: float(value) for key, value in asdict(self).items()} + + +@dataclass(frozen=True) +class _Constants: + rf_period: float + cavity_period: float + vmax: float + steps_per_rf_period: int + step_sleep: float + phase_reference: str + + +# --------------------------------------------------------------------------------------------- # +# Signals # +# --------------------------------------------------------------------------------------------- # + + +class CheckedSignal(Signal): + """Soft signal that validates every write, used for tunable constants. + + Writes come from the constructor and from runtime ``deviceConfig`` updates + (``dev.laser_delay.set_device_config({...})``), so a bad value is rejected instead of being + used in the next move. + """ + + def __init__(self, *args, validator: Callable[[object], None] | None = None, **kwargs): + self._validator = validator + super().__init__(*args, **kwargs) + + def check_value(self, value): + super().check_value(value) + if self._validator is not None: + self._validator(value) + + +class ComputedPhaseSignal(SignalRO): + """Read-only signal whose value is computed by the parent device on every read. + + ``compute`` names a method of the parent that takes no arguments and returns a float. + """ + + def __init__(self, *args, compute: str, **kwargs): + self._compute = compute + super().__init__(*args, value=math.nan, **kwargs) + + def get(self, **kwargs): + value = float(getattr(self.parent, self._compute)()) + self._readback = value + self._metadata["timestamp"] = time.time() + return value + + +def _positive_number(name: str) -> 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 math.isfinite(number) or number <= 0: + raise ValueError(f"{name} must be a positive finite number, got {value!r}") + + return check + + +def _non_negative_number(name: str) -> 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 math.isfinite(number) or number < 0: + raise ValueError(f"{name} must be a non-negative finite number, got {value!r}") + + return check + + +def _positive_integer(name: str) -> Callable[[object], None]: + def check(value): + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be an integer, got {value!r}") from exc + if isinstance(value, bool) or not number.is_integer() or number < 1: + raise ValueError(f"{name} must be a positive integer, got {value!r}") + + return check + + +def _phase_reference(value): + if value not in PHASE_REFERENCES: + raise ValueError(f"phase_reference must be one of {PHASE_REFERENCES}, got {value!r}") + + +# --------------------------------------------------------------------------------------------- # +# Device # +# --------------------------------------------------------------------------------------------- # + + +class LaserDelay(PSIDeviceBase, PositionerBase): + """Scannable laser delay (ns) at SuperXAS: delay card plus slowly ramped RF phase shifter. + + Usage (BEC client):: + + dev.laser_delay.calibrate(546.04 - 546.92) # scope: X-ray minus laser, after laser restart + scans.mv(dev.laser_delay, 1.2) + scans.line_scan(dev.laser_delay, -5, 5, steps=21, exp_time=1, relative=False) + + A move writes the delay card, then ramps the phase shifter voltages (up to a few hundred steps + of ``step_sleep`` seconds). It runs in a background task; ``stop()`` interrupts the ramp between + two steps and leaves the voltages at the last written step. + + Args: + name (str): device name. + prefix (str): unused, kept for the common device signature. PV names are given in full. + delay_coarse_pv (str): delay card coarse PV (units of 2 ns). + delay_fine_pv (str): delay card fine PV (units of 0.01 ns). + evr_period_pv (str): event receiver laser trigger period PV (µs). + phase_v1_pv (str): phase shifter voltage V1 (cos) PV. + phase_v2_pv (str): phase shifter voltage V2 (sin) PV. + rf_period (float): RF period in ns (script ``RFPeriod``). + cavity_period (float): laser cavity period in ns (script ``CavityPeriod``, Duetto). + vmax (float): phase shifter voltage amplitude (script ``Vmax``). + steps_per_rf_period (int): phase ramp steps per RF period (script ``NumStepsPerPeriod``). + step_sleep (float): seconds between two phase ramp steps. + phase_reference (str): ``"stored"`` (script behaviour) or ``"readback"``; what the relative + phase move is computed from, see module docstring. + limits (list[float] | None): optional soft limits ``[low, high]`` in ns. + """ + + USER_ACCESS = ["calibrate", "set_calibration", "get_calibration", "plan_move"] + + # Readback: last commanded delay (named like the device, hinted) + readback = Cpt( + InternalSignal, value=math.nan, kind=Kind.hinted, doc="Last commanded delay in ns" + ) + motor_is_moving = Cpt(InternalSignal, value=0, kind=Kind.omitted, doc="1 while a move runs") + + # Hardware + delay_coarse = FCpt( + EpicsSignal, "{self._delay_coarse_pv}", kind=Kind.normal, doc="Delay card, units of 2 ns" + ) + delay_fine = FCpt( + EpicsSignal, "{self._delay_fine_pv}", kind=Kind.normal, doc="Delay card, units of 0.01 ns" + ) + evr_period = FCpt( + EpicsSignalRO, "{self._evr_period_pv}", kind=Kind.config, doc="EVR laser period in µs" + ) + phase_v1 = FCpt(EpicsSignal, "{self._phase_v1_pv}", kind=Kind.normal, doc="Phase shifter V1") + phase_v2 = FCpt(EpicsSignal, "{self._phase_v2_pv}", kind=Kind.normal, doc="Phase shifter V2") + + # Derived from the hardware on every read + phase_readback = Cpt( + ComputedPhaseSignal, + compute="read_phase", + kind=Kind.normal, + doc="Phase shifter delay in ns from V1/V2, wrapped to one RF period", + ) + phase_mismatch = Cpt( + ComputedPhaseSignal, + compute="read_phase_mismatch", + kind=Kind.normal, + doc="Stored minus read-back phase in ns, modulo the RF period (0 when consistent)", + ) + + # Calibration (script: Timing.offset). Written by calibrate()/set_calibration() and by moves. + laser_period = Cpt(InternalSignal, value=math.nan, kind=Kind.config, doc="Laser period in ns") + offset_t = Cpt(InternalSignal, value=math.nan, kind=Kind.config, doc="Delay card offset in ns") + offset_phase_t = Cpt( + InternalSignal, value=math.nan, kind=Kind.config, doc="Phase shifter offset in ns" + ) + phase_t = Cpt( + InternalSignal, + value=math.nan, + kind=Kind.config, + doc="Stored (unwrapped) phase shifter delay in ns", + ) + + # Tunable constants (deviceConfig; runtime changes via set_device_config apply to the next move) + rf_period = Cpt( + CheckedSignal, value=2.0, kind=Kind.config, validator=_positive_number("rf_period") + ) + cavity_period = Cpt( + CheckedSignal, value=12.01, kind=Kind.config, validator=_positive_number("cavity_period") + ) + vmax = Cpt(CheckedSignal, value=1.0, kind=Kind.config, validator=_positive_number("vmax")) + steps_per_rf_period = Cpt( + CheckedSignal, + value=36, + kind=Kind.config, + validator=_positive_integer("steps_per_rf_period"), + ) + step_sleep = Cpt( + CheckedSignal, value=0.1, kind=Kind.config, validator=_non_negative_number("step_sleep") + ) + phase_reference = Cpt( + CheckedSignal, value="stored", kind=Kind.config, validator=_phase_reference + ) + + # Soft limits (BEC publishes them as the device limits) + low_limit_travel = Cpt(Signal, value=0.0, kind=Kind.omitted) + high_limit_travel = Cpt(Signal, value=0.0, kind=Kind.omitted) + + # pylint: disable=too-many-arguments,too-many-locals + def __init__( + self, + prefix: str = "", + *, + name: str, + delay_coarse_pv: str = "X10DA-ES2-TIM:SET1-DELAY", + delay_fine_pv: str = "X10DA-ES2-TIM:SET1-FINE", + evr_period_pv: str = "X10DA-ES2-TIM:EVR-FPS2", + phase_v1_pv: str = "X10DA-ES4:AO-02", + phase_v2_pv: str = "X10DA-ES4:AO-01", + rf_period: float = 2.0, + cavity_period: float = 12.01, + vmax: float = 1.0, + steps_per_rf_period: int = 36, + step_sleep: float = 0.1, + phase_reference: str = "stored", + limits: list[float] | tuple[float, float] | None = None, + scan_info: ScanInfo | None = None, + device_manager: DeviceManagerBase | None = None, + **kwargs, + ): + self._delay_coarse_pv = delay_coarse_pv + self._delay_fine_pv = delay_fine_pv + self._evr_period_pv = evr_period_pv + self._phase_v1_pv = phase_v1_pv + self._phase_v2_pv = phase_v2_pv + self._move_lock = threading.RLock() # guards _move_status and _move_stop + self._hw_lock = threading.Lock() # held by the move task while it writes PVs + self._move_stop = threading.Event() # one event per move, set to interrupt that move + self._move_status: StatusBase | None = None + super().__init__( + prefix=prefix, name=name, scan_info=scan_info, device_manager=device_manager, **kwargs + ) + self.readback.name = self.name + self._position = math.nan + # Constants: validated by CheckedSignal.check_value, a bad value fails here. + self.rf_period.put(float(rf_period)) + self.cavity_period.put(float(cavity_period)) + self.vmax.put(float(vmax)) + self.steps_per_rf_period.put(steps_per_rf_period) + self.step_sleep.put(float(step_sleep)) + self.phase_reference.put(phase_reference) + if limits is not None: + if len(limits) != 2: + raise ValueError(f"limits must be [low, high], got {limits!r}") + self.low_limit_travel.put(float(limits[0])) + self.high_limit_travel.put(float(limits[1])) + + # ----------------------------------------------------------------------------------------- # + # Positioner interface # + # ----------------------------------------------------------------------------------------- # + + @property + def egu(self) -> str: + """Engineering unit of the position.""" + return "ns" + + @property + def limits(self) -> tuple[float, float]: + """Soft limits (low, high). Equal values mean no limits.""" + return (self.low_limit_travel.get(), self.high_limit_travel.get()) + + @property + def moving(self) -> bool: + """True while a move runs (also while a stopped move finishes its current step).""" + with self._move_lock: + pending = self._move_status is not None and not self._move_status.done + return pending or self._hw_lock.locked() + + def check_value(self, value) -> None: + """Check that the requested delay is a finite number within the soft limits.""" + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{self.name}: delay must be a number, got {value!r}") from exc + if not math.isfinite(number): + raise ValueError(f"{self.name}: delay must be finite, got {value!r}") + low, high = self.limits + if low < high and not low <= number <= high: + raise LimitError(f"{self.name}: {number} ns is outside the limits [{low}, {high}]") + + # pylint: disable=arguments-differ + def move(self, position, moved_cb=None, timeout: float | None = None, wait: bool = False): + """Move to a delay ``position`` (ns). Returns a status that finishes when the ramp is done. + + Raises: + LaserDelayNotCalibratedError: if the device was never calibrated. + LaserDelayBusyError: if a previous move is still running. + """ + target = float(position) + self.check_value(target) + self._require_calibration() # refuse early; the task takes its own snapshot later + with self._move_lock: + if self._move_status is not None and not self._move_status.done: + raise LaserDelayBusyError( + f"{self.name} is still moving; wait for the move to finish or stop it first." + ) + stop_event = threading.Event() + status = DeviceStatus( + self, timeout=timeout, description=f"{self.name}: move to {target} ns" + ) + # Any failure of the status (stop, timeout) also ends the ramp. + status.add_callback(functools.partial(self._on_move_status_done, stop_event)) + if moved_cb is not None: + status.add_callback(functools.partial(moved_cb, obj=self)) + self._move_status = status + self._move_stop = stop_event + self.motor_is_moving.put(1, internal=True) + self.task_handler.submit_task(self._move_task, task_args=(target, status, stop_event)) + if wait: + status.wait(timeout=timeout) + return status + + # ----------------------------------------------------------------------------------------- # + # User access # + # ----------------------------------------------------------------------------------------- # + + def calibrate(self, scope_xray_minus_laser: float) -> dict[str, float]: + """Calibrate from an oscilloscope reading (the script's ``CurrentSituation``). + + Run after every laser restart, with the laser and X-ray signals on the scope: + ``dev.laser_delay.calibrate(546.04 - 546.92)``. + + Args: + scope_xray_minus_laser (float): X-ray minus laser time on the scope, in ns. + + Returns: + dict: the new calibration (``laser_period``, ``offset_t``, ``offset_phase_t``, + ``phase_t``). + """ + s = float(scope_xray_minus_laser) + if not math.isfinite(s): + raise ValueError( + f"{self.name}: scope reading must be finite, got {scope_xray_minus_laser!r}" + ) + self._require_idle("calibrate") + constants = self._constants() + # Same reading order as the script: EVR, DELAY, FINE, V1, V2. + laser_period = 1000 * self.evr_period.get() + if not math.isfinite(laser_period) or laser_period <= 0: + raise ValueError( + f"{self.name}: laser period from {self._evr_period_pv} is {laser_period} ns; " + "is the laser triggered by the event receiver?" + ) + dt1 = self.delay_coarse.get() + dt2 = self.delay_fine.get() + delay_card_t = delay_card_time(dt1, dt2) + v1 = self.phase_v1.get() + v2 = self.phase_v2.get() + phase_t = phase_from_voltages(v1, v2, constants.rf_period) + ph = s % constants.cavity_period + offset_phase_t = -phase_t - ph + offset_t = -delay_card_t - s + if math.hypot(v1, v2) < 0.5 * constants.vmax: + logger.warning( + f"{self.name}: phase shifter amplitude {math.hypot(v1, v2):.3f} V is far below " + f"vmax={constants.vmax}; the read-back phase may be meaningless." + ) + calibration = LaserDelayCalibration( + laser_period=float(laser_period), + offset_t=float(offset_t), + offset_phase_t=float(offset_phase_t), + phase_t=float(phase_t), + ) + self._store_calibration(calibration, current_delay=s) + logger.info( + f"{self.name}: calibrated with scope X-ray minus laser = {s} ns: " + f"{calibration.as_dict()}" + ) + return calibration.as_dict() + + def set_calibration( + self, + laser_period: float, + offset_t: float, + offset_phase_t: float, + phase_t: float, + current_delay: float | None = None, + ) -> dict[str, float]: + """Set the calibration directly, e.g. after a BEC restart, from a previous calibration. + + The argument order is the order of the script's ``Timing.offset`` file, so + ``set_calibration(*[float(x) for x in numpy.loadtxt("Timing.offset")])`` takes over the + script's state. ``phase_t`` must be the stored phase of the last move (``get_calibration()`` + after that move), not a fresh read-back: the voltages only know the phase modulo one RF + period. + + Args: + laser_period (float): laser period in ns. + offset_t (float): delay card offset in ns. + offset_phase_t (float): phase shifter offset in ns. + phase_t (float): stored phase shifter delay in ns. + current_delay (float | None): the delay the hardware is at, shown as readback. + NaN if None. + """ + values = { + "laser_period": laser_period, + "offset_t": offset_t, + "offset_phase_t": offset_phase_t, + "phase_t": phase_t, + } + for key, value in values.items(): + if not math.isfinite(float(value)): + raise ValueError(f"{self.name}: {key} must be finite, got {value!r}") + if float(laser_period) <= 0: + raise ValueError(f"{self.name}: laser_period must be positive, got {laser_period!r}") + self._require_idle("set_calibration") + calibration = LaserDelayCalibration(**{key: float(val) for key, val in values.items()}) + delay = math.nan if current_delay is None else float(current_delay) + self._store_calibration(calibration, current_delay=delay) + logger.info(f"{self.name}: calibration set to {calibration.as_dict()}") + return calibration.as_dict() + + def get_calibration(self) -> dict[str, float]: + """Current calibration (NaN values when not calibrated).""" + return LaserDelayCalibration( + laser_period=float(self.laser_period.get()), + offset_t=float(self.offset_t.get()), + offset_phase_t=float(self.offset_phase_t.get()), + phase_t=float(self.phase_t.get()), + ).as_dict() + + def plan_move(self, position: float) -> dict: + """Dry run of a move: what would be written, without writing anything. + + Reads V1/V2 for the ramp start. Useful to compare with the script before the first move. + + Returns: + dict: ``delay_coarse``, ``delay_fine``, ``phase_target``, ``phase_start``, + ``phase_delta``, ``phase_steps`` (puts of V1/V2 incl. the last one) and + ``estimated_duration_s``. + """ + target = float(position) + self.check_value(target) + calibration = self._require_calibration() + constants = self._constants() + total_t = -calibration.offset_t - target + coarse, fine = delay_card_setting(total_t, calibration.laser_period) + phase_target = (-target) % constants.cavity_period - calibration.offset_phase_t + phase_start = self._read_phase(constants.rf_period) + reference = calibration.phase_t if constants.phase_reference == "stored" else phase_start + delta = phase_target - reference + offsets = phase_ramp_offsets(delta, constants.rf_period, constants.steps_per_rf_period) + n_puts = len(offsets) + 1 + return { + "target": target, + "delay_coarse": coarse, + "delay_fine": fine, + "phase_target": float(phase_target), + "phase_start": float(phase_start), + "phase_delta": float(delta), + "phase_steps": n_puts, + "estimated_duration_s": n_puts * constants.step_sleep, + } + + # ----------------------------------------------------------------------------------------- # + # Readbacks used by the computed signals # + # ----------------------------------------------------------------------------------------- # + + def read_phase(self) -> float: + """Phase shifter delay in ns read back from V1/V2 (wrapped to one RF period).""" + return self._read_phase(float(self.rf_period.get())) + + def _read_phase(self, rf_period: float) -> float: + v1 = self.phase_v1.get() + v2 = self.phase_v2.get() + return float(phase_from_voltages(v1, v2, rf_period)) + + def read_phase_mismatch(self) -> float: + """Stored minus read-back phase in ns, wrapped to [-rf/2, rf/2). NaN if uncalibrated.""" + stored = float(self.phase_t.get()) + if not math.isfinite(stored): + return math.nan + rf_period = float(self.rf_period.get()) + return float(wrap_phase(stored - self.read_phase(), rf_period)) + + # ----------------------------------------------------------------------------------------- # + # PSIDeviceBase hooks # + # ----------------------------------------------------------------------------------------- # + + def on_stop(self) -> None: + """Interrupt a running phase ramp between two steps and fail its status.""" + with self._move_lock: + self._move_stop.set() + status = self._move_status + if status is not None: + self._finish_status(status, DeviceStoppedError(f"Device {self.name} has been stopped")) + + def on_destroy(self) -> None: + """Interrupt a running move; the task handler is shut down by the base class.""" + self._move_stop.set() + + # ----------------------------------------------------------------------------------------- # + # Internals # + # ----------------------------------------------------------------------------------------- # + + def _constants(self) -> _Constants: + return _Constants( + rf_period=float(self.rf_period.get()), + cavity_period=float(self.cavity_period.get()), + vmax=float(self.vmax.get()), + steps_per_rf_period=int(self.steps_per_rf_period.get()), + step_sleep=float(self.step_sleep.get()), + phase_reference=str(self.phase_reference.get()), + ) + + def _calibration(self) -> LaserDelayCalibration: + return LaserDelayCalibration( + laser_period=float(self.laser_period.get()), + offset_t=float(self.offset_t.get()), + offset_phase_t=float(self.offset_phase_t.get()), + phase_t=float(self.phase_t.get()), + ) + + def _require_calibration(self) -> LaserDelayCalibration: + calibration = self._calibration() + values = calibration.as_dict().values() + if not all(math.isfinite(v) for v in values) or calibration.laser_period <= 0: + raise LaserDelayNotCalibratedError( + f"{self.name} is not calibrated. Run dev.{self.name}.calibrate() after the laser is on, or restore a previous calibration with " + f"dev.{self.name}.set_calibration(...). Current calibration: " + f"{calibration.as_dict()}" + ) + return calibration + + def _require_idle(self, action: str) -> None: + if self.moving: + raise LaserDelayBusyError(f"{self.name}: cannot {action} while a move is running.") + + def _store_calibration(self, calibration: LaserDelayCalibration, current_delay: float) -> None: + self.laser_period.put(calibration.laser_period, internal=True) + self.offset_t.put(calibration.offset_t, internal=True) + self.offset_phase_t.put(calibration.offset_phase_t, internal=True) + self.phase_t.put(calibration.phase_t, internal=True) + self._update_readback(current_delay) + + def _update_readback(self, value: float) -> None: + self.readback.put(float(value), internal=True) + self._set_position(float(value)) # runs the "readback" subscription (BEC update) + + @staticmethod + def _on_move_status_done(stop_event: threading.Event, status: StatusBase) -> None: + if not status.success: + stop_event.set() + + @staticmethod + def _finish_status(status: StatusBase, exc: Exception | None = None) -> None: + if status.done: + return + try: + if exc is None: + status.set_finished() + else: + status.set_exception(exc) + except InvalidState: + pass # resolved concurrently (stop vs. end of move) + + def _write_phase(self, phase_t: float, constants: _Constants) -> None: + v1, v2 = voltages_for_phase(phase_t, constants.rf_period, constants.vmax) + self.phase_v1.put(float(v1)) + self.phase_v2.put(float(v2)) + + def _move_task(self, target: float, status: StatusBase, stop_event: threading.Event) -> None: + """Body of the move, run by the task handler. Never raises; resolves ``status``. + + Readback, ``motor_is_moving`` and the hardware lock are all settled before the status + resolves, so whoever waits on the status sees the final state. Calibration and constants + are read only once the hardware lock is held: a move queued right behind a stopped one + must see the phase bookkeeping of that stopped move. + """ + error = None + with self._hw_lock: + try: + calibration = self._require_calibration() + constants = self._constants() + self._execute_move(target, calibration, constants, stop_event) + except Exception as exc: # pylint: disable=broad-except + error = exc + if not isinstance(exc, DeviceStoppedError): + logger.error(f"{self.name}: move to {target} ns failed: {exc}") + with self._move_lock: + if self._move_status is status: # no newer move queued behind this one + self.motor_is_moving.put(0, internal=True) + self._finish_status(status, error) + + def _execute_move( + self, + target: float, + calibration: LaserDelayCalibration, + constants: _Constants, + stop_event: threading.Event, + ) -> None: + """The script's SetDelay: delay card, then the phase shifter ramp, then bookkeeping.""" + if stop_event.is_set(): + raise DeviceStoppedError(f"Device {self.name} has been stopped") + total_t = -calibration.offset_t - target + phase_target = (-target) % constants.cavity_period - calibration.offset_phase_t + coarse, fine = delay_card_setting(total_t, calibration.laser_period) + + written_any = False + done_offset = None + completed = False + reference = calibration.phase_t + try: + self.delay_coarse.put(coarse) + written_any = True + self.delay_fine.put(fine) + + phase_start = self._read_phase(constants.rf_period) + if constants.phase_reference == "readback": + reference = phase_start + else: + mismatch = wrap_phase(calibration.phase_t - phase_start, constants.rf_period) + if abs(mismatch) > constants.rf_period / (2 * constants.steps_per_rf_period): + logger.warning( + f"{self.name}: stored phase and V1/V2 read-back differ by " + f"{mismatch:.4f} ns modulo the RF period (voltages changed outside BEC?). " + "The ramp starts at the read-back, so this move ends that far from the " + "intended phase." + ) + delta = phase_target - reference + offsets = phase_ramp_offsets(delta, constants.rf_period, constants.steps_per_rf_period) + logger.info( + f"{self.name}: move to {target} ns: DELAY={coarse}, FINE={fine}, phase " + f"{phase_start:.4f} -> +{delta:.4f} ns in {len(offsets) + 1} steps " + f"(~{(len(offsets) + 1) * constants.step_sleep:.1f} s)" + ) + for offset in offsets: + if stop_event.is_set(): + raise DeviceStoppedError(f"Device {self.name} has been stopped") + self._write_phase(phase_start + offset, constants) + done_offset = offset + if stop_event.wait(constants.step_sleep): + raise DeviceStoppedError(f"Device {self.name} has been stopped") + if stop_event.is_set(): + raise DeviceStoppedError(f"Device {self.name} has been stopped") + self._write_phase(phase_start + delta, constants) + done_offset = delta + completed = True + # The script also sleeps after the last step; a stop now no longer matters. + stop_event.wait(constants.step_sleep) + finally: + if completed: + self.phase_t.put(float(phase_target), internal=True) + self._update_readback(target) + else: + if done_offset is not None: + # Keep stored and real (unwrapped) phase consistent for the next move. + self.phase_t.put(float(reference + done_offset), internal=True) + if written_any: + self._update_readback(math.nan) + + +class SimLaserDelay(LaserDelay): + """LaserDelay without EPICS: soft signals replace the five PVs (local demo BEC, tests). + + The ``*_pv`` arguments are accepted and ignored. Start values: laser period 2.0002 µs, + delay card 150 / 37 (300.37 ns), V1 = 1, V2 = 0. + """ + + delay_coarse = Cpt(Signal, value=150, kind=Kind.normal, doc="Delay card, units of 2 ns") + delay_fine = Cpt(Signal, value=37, kind=Kind.normal, 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(Signal, value=1.0, kind=Kind.normal, doc="Phase shifter V1") + phase_v2 = Cpt(Signal, value=0.0, kind=Kind.normal, doc="Phase shifter V2") -- 2.54.0 From 1320585fc275a681f43a0c078397cd668bd3d22e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 17:01:29 +0200 Subject: [PATCH 13/25] test(devices): check LaserDelay PV writes against the scientist's delay script --- .../tests_devices/reference/DelayLaser.py.txt | 234 +++++++++++ .../tests_devices/test_laser_delay_golden.py | 368 ++++++++++++++++++ 2 files changed, 602 insertions(+) create mode 100644 tests/tests_devices/reference/DelayLaser.py.txt create mode 100644 tests/tests_devices/test_laser_delay_golden.py diff --git a/tests/tests_devices/reference/DelayLaser.py.txt b/tests/tests_devices/reference/DelayLaser.py.txt new file mode 100644 index 0000000..49362df --- /dev/null +++ b/tests/tests_devices/reference/DelayLaser.py.txt @@ -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) diff --git a/tests/tests_devices/test_laser_delay_golden.py b/tests/tests_devices/test_laser_delay_golden.py new file mode 100644 index 0000000..98c2881 --- /dev/null +++ b/tests/tests_devices/test_laser_delay_golden.py @@ -0,0 +1,368 @@ +"""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, + delay_card_setting, + phase_ramp_offsets, + voltages_for_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 + +# 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]), + "voltages_touched_between_moves": Scenario( + STATE_A, 546.04 - 546.92, [1.0, {V1: 0.0, V2: 1.0}, 2.0, {V1: -0.7, V2: -0.7}, -3.0] + ), +} + + +# --------------------------------------------------------------------------------------------- # +# 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) -> dict: + """Run a scenario through the device.""" + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) 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"] + + +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}") -- 2.54.0 From 413dc7eb35e97b2df395962ca8702995672954f3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 17:01:29 +0200 Subject: [PATCH 14/25] feat(configs): add laser_delay to the experimental hutch config and a sim config --- .../device_configs/laser_delay_sim.yaml | 23 ++++++++++++ .../x10da_experimental_hutch.yaml | 9 ++++- .../device_configs/x10da_laser_delay.yaml | 37 +++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 superxas_bec/device_configs/laser_delay_sim.yaml create mode 100644 superxas_bec/device_configs/x10da_laser_delay.yaml diff --git a/superxas_bec/device_configs/laser_delay_sim.yaml b/superxas_bec/device_configs/laser_delay_sim.yaml new file mode 100644 index 0000000..463bae2 --- /dev/null +++ b/superxas_bec/device_configs/laser_delay_sim.yaml @@ -0,0 +1,23 @@ +################################### +## 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("/superxas_bec/device_configs/laser_delay_sim.yaml") +# Then: dev.laser_delay.calibrate(-0.88); scans.line_scan(dev.laser_delay, -5, 5, steps=21, exp_time=0.1, relative=False) +# step_sleep is shortened so that demo moves take seconds instead of tens of seconds. + +laser_delay: + readoutPriority: baseline + description: Simulated laser delay in ns (no EPICS) + deviceClass: superxas_bec.devices.laser_delay.SimLaserDelay + deviceConfig: + step_sleep: 0.01 + deviceTags: + - laser + - timing + - simulation + onFailure: retry + enabled: true + readOnly: false + softwareTrigger: false diff --git a/superxas_bec/device_configs/x10da_experimental_hutch.yaml b/superxas_bec/device_configs/x10da_experimental_hutch.yaml index 71a085d..915ab12 100644 --- a/superxas_bec/device_configs/x10da_experimental_hutch.yaml +++ b/superxas_bec/device_configs/x10da_experimental_hutch.yaml @@ -136,4 +136,11 @@ es2_try: prefix: X10DA-ES2-ET2:TRY onFailure: retry enabled: true - softwareTrigger: false \ No newline at end of file + softwareTrigger: false + +################################### +## Laser delay (pump-probe) ## +################################### + +laser_delay_config: + - !include ./x10da_laser_delay.yaml diff --git a/superxas_bec/device_configs/x10da_laser_delay.yaml b/superxas_bec/device_configs/x10da_laser_delay.yaml new file mode 100644 index 0000000..08a5ca3 --- /dev/null +++ b/superxas_bec/device_configs/x10da_laser_delay.yaml @@ -0,0 +1,37 @@ +################################### +## Laser delay (pump-probe) ## +################################### +# Delay card X10DA-ES2-TIM:SET1 + RF phase shifter 2 (ES4) for the Duetto laser, external trigger. +# Usage and calibration: superxas_bec/devices/LASER_DELAY.md +# +# 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 every laser +# restart, or dev.laser_delay.set_calibration(...) to restore a previous one. + +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_reference: stored + deviceTags: + - laser + - timing + onFailure: buffer + enabled: true + readOnly: false + softwareTrigger: false -- 2.54.0 From 3d8b8c97cb63a4a2922322364fecf016f88267a9 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 17:01:29 +0200 Subject: [PATCH 15/25] test(devices): add LaserDelay unit tests for calibration, stop and config --- tests/tests_devices/test_laser_delay.py | 530 ++++++++++++++++++++++++ 1 file changed, 530 insertions(+) create mode 100644 tests/tests_devices/test_laser_delay.py diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py new file mode 100644 index 0000000..d0f3411 --- /dev/null +++ b/tests/tests_devices/test_laser_delay.py @@ -0,0 +1,530 @@ +"""Unit tests for the LaserDelay positioner (mocked EPICS).""" + +# pylint: disable=redefined-outer-name,protected-access + +from __future__ import annotations + +import math +import threading +import time +from pathlib import Path +from unittest import mock + +import numpy as np +import ophyd +import pytest +from bec_lib.bec_yaml_loader import yaml_load +from bec_server.device_server.devices.device_serializer import get_device_base_class +from bec_server.device_server.devices.devicemanager import DeviceManagerDS +from ophyd.signal import InternalSignalError +from ophyd.utils import LimitError +from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError +from ophyd_devices.tests.utils import MockPV, patched_device + +from superxas_bec.devices.laser_delay import ( + LaserDelay, + LaserDelayBusyError, + LaserDelayNotCalibratedError, + SimLaserDelay, + phase_from_voltages, + phase_ramp_offsets, + voltages_for_phase, + wrap_phase, +) + +CONFIG_DIR = Path(__file__).resolve().parents[2] / "superxas_bec" / "device_configs" +SCOPE = 546.04 - 546.92 + + +def record_puts(device: LaserDelay) -> list: + """Record every put to the four written PVs, in order (thread safe enough for list.append).""" + 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_hardware(device: LaserDelay, evr=2.0002, coarse=150, fine=37, v1=0.6, v2=-0.8) -> None: + """Set the mocked PV values.""" + device.evr_period._read_pv.mock_data = evr + device.delay_coarse._read_pv.mock_data = coarse + device.delay_fine._read_pv.mock_data = fine + device.phase_v1._read_pv.mock_data = v1 + device.phase_v2._read_pv.mock_data = v2 + + +@pytest.fixture +def laser_delay(): + """Uncalibrated device with mocked PVs and no sleep between ramp steps.""" + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as device: + set_hardware(device) + yield device + device.stop() + + +@pytest.fixture +def calibrated(laser_delay): + """Calibrated device.""" + laser_delay.calibrate(SCOPE) + return laser_delay + + +def wait_until(condition, timeout=5.0): + """Poll ``condition`` until it is true.""" + start = time.monotonic() + while time.monotonic() - start < timeout: + if condition(): + return True + time.sleep(0.005) + return False + + +# --------------------------------------------------------------------------------------------- # +# Construction and configuration # +# --------------------------------------------------------------------------------------------- # + + +def test_default_pvs_constants_and_positioner_type(laser_delay): + assert laser_delay.delay_coarse.pvname == "X10DA-ES2-TIM:SET1-DELAY" + assert laser_delay.delay_fine.pvname == "X10DA-ES2-TIM:SET1-FINE" + assert laser_delay.evr_period.pvname == "X10DA-ES2-TIM:EVR-FPS2" + assert laser_delay.phase_v1.pvname == "X10DA-ES4:AO-02" + assert laser_delay.phase_v2.pvname == "X10DA-ES4:AO-01" + config = laser_delay.read_configuration() + assert config["laser_delay_rf_period"]["value"] == 2.0 + assert config["laser_delay_cavity_period"]["value"] == 12.01 + assert config["laser_delay_vmax"]["value"] == 1.0 + assert config["laser_delay_steps_per_rf_period"]["value"] == 36 + assert config["laser_delay_step_sleep"]["value"] == 0.0 + assert config["laser_delay_phase_reference"]["value"] == "stored" + assert get_device_base_class(laser_delay) == "positioner" + assert laser_delay.hints == {"fields": ["laser_delay"]} + assert laser_delay.egu == "ns" + assert laser_delay.limits == (0.0, 0.0) + assert math.isnan(laser_delay.read()["laser_delay"]["value"]) + assert set(LaserDelay.USER_ACCESS) == { + "calibrate", + "set_calibration", + "get_calibration", + "plan_move", + } + + +def test_config_overrides_pvs_and_constants(): + with patched_device( + LaserDelay, + name="ld", + delay_coarse_pv="X99:DELAY", + delay_fine_pv="X99:FINE", + evr_period_pv="X99:EVR", + phase_v1_pv="X99:V1", + phase_v2_pv="X99:V2", + rf_period=2.0014, + cavity_period=12.0084, + vmax=0.8, + steps_per_rf_period=72, + step_sleep=0.0, + phase_reference="readback", + limits=[-20, 20], + ) as device: + assert [s.pvname for s in (device.delay_coarse, device.delay_fine, device.evr_period)] == [ + "X99:DELAY", + "X99:FINE", + "X99:EVR", + ] + assert (device.phase_v1.pvname, device.phase_v2.pvname) == ("X99:V1", "X99:V2") + assert device.limits == (-20.0, 20.0) + set_hardware(device, v1=0.8, v2=0.0) + log = record_puts(device) + device.calibrate(1.5) + plan = device.plan_move(3.0) + device.set(3.0).wait(timeout=10) + phase_puts = log[2:] + # readback reference: the ramp goes from the read-back phase straight to the target + assert plan["phase_delta"] == pytest.approx(plan["phase_target"] - plan["phase_start"]) + expected_steps = len(phase_ramp_offsets(plan["phase_delta"], 2.0014, 72)) + 1 + assert len(phase_puts) == 2 * expected_steps == 2 * plan["phase_steps"] + for (_, v1), (_, v2) in zip(phase_puts[::2], phase_puts[1::2]): + assert math.hypot(v1, v2) == pytest.approx(0.8) + # step size is rf/72 + phases = [ + phase_from_voltages(v1, v2, 2.0014) for (_, v1), (_, v2) in zip(log[2::2], log[3::2]) + ] + steps = np.abs(np.diff(np.unwrap(np.asarray(phases) * 2 * np.pi / 2.0014))) + assert np.max(steps[:-1]) == pytest.approx(2 * np.pi / 72) + with pytest.raises(LimitError): + device.set(25.0) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"phase_reference": "both"}, + {"steps_per_rf_period": 0}, + {"steps_per_rf_period": 2.5}, + {"rf_period": -2.0}, + {"cavity_period": 0}, + {"vmax": float("nan")}, + {"step_sleep": -0.1}, + {"limits": [1.0]}, + ], +) +def test_invalid_config_is_rejected(kwargs): + with pytest.raises(ValueError): + with patched_device(LaserDelay, name="laser_delay", **kwargs): + pass + + +def test_runtime_constant_update_is_validated(calibrated): + """deviceConfig updates at runtime (BEC calls .set on the signal) are validated.""" + calibrated.step_sleep.set(0.2).wait(timeout=2) + assert calibrated._constants().step_sleep == 0.2 + with pytest.raises(ValueError): + calibrated.step_sleep.set(-1).wait(timeout=2) + with pytest.raises(ValueError): + calibrated.phase_reference.set("sometimes").wait(timeout=2) + assert calibrated._constants().phase_reference == "stored" + + +def test_calibration_signals_are_internal(calibrated): + """Calibration can only change through calibrate()/set_calibration()/moves.""" + with pytest.raises(InternalSignalError): + calibrated.offset_t.put(1.0) + with pytest.raises(InternalSignalError): + calibrated.readback.put(1.0) + + +def test_yaml_entries_map_onto_constructor_arguments(): + """Every deviceConfig key of both YAML files is a constructor argument (no leftovers), and the + standard config pulls laser_delay in through the experimental hutch include.""" + standard = yaml_load(str(CONFIG_DIR / "x10da_standard_config.yaml")) + hutch = yaml_load(str(CONFIG_DIR / "x10da_experimental_hutch.yaml")) + sim = yaml_load(str(CONFIG_DIR / "laser_delay_sim.yaml")) + assert standard["laser_delay"] == hutch["laser_delay"] + entry = standard["laser_delay"] + assert entry["deviceClass"] == "superxas_bec.devices.laser_delay.LaserDelay" + assert entry["onFailure"] == "buffer" + assert sim["laser_delay"]["deviceClass"] == "superxas_bec.devices.laser_delay.SimLaserDelay" + for config in (entry, sim["laser_delay"]): + with mock.patch.object(ophyd, "cl") as mock_cl: + mock_cl.get_pv = MockPV + mock_cl.thread_class = threading.Thread + obj, leftover = DeviceManagerDS.construct_device_obj( + {"name": "laser_delay", **config}, device_manager=None + ) + assert leftover == {} + assert isinstance(obj, LaserDelay) + obj.destroy() + + +# --------------------------------------------------------------------------------------------- # +# Calibration # +# --------------------------------------------------------------------------------------------- # + + +def test_refuses_to_move_before_calibration(laser_delay): + log = record_puts(laser_delay) + with pytest.raises(LaserDelayNotCalibratedError, match="calibrate"): + laser_delay.set(1.0) + with pytest.raises(LaserDelayNotCalibratedError): + laser_delay.plan_move(1.0) + assert log == [] + assert not laser_delay.moving + + +def test_calibrate_stores_config_signals_and_sets_readback(laser_delay): + result = laser_delay.calibrate(SCOPE) + delay_card_t = 2.0 * 150 + 0.01 * 37 + phase_t = 2.0 * math.atan2(-0.8, 0.6) / (2 * math.pi) + assert result["laser_period"] == pytest.approx(2000.2) + assert result["offset_t"] == pytest.approx(-delay_card_t - SCOPE) + assert result["phase_t"] == pytest.approx(phase_t) + assert result["offset_phase_t"] == pytest.approx(-phase_t - SCOPE % 12.01) + config = laser_delay.read_configuration() + for key, value in result.items(): + assert config[f"laser_delay_{key}"]["value"] == value + assert laser_delay.get_calibration() == result + reading = laser_delay.read() + assert reading["laser_delay"]["value"] == SCOPE + assert reading["laser_delay_phase_mismatch"]["value"] == pytest.approx(0.0, abs=1e-12) + assert laser_delay.position == SCOPE + + +def test_calibrate_rejects_bad_input(laser_delay): + with pytest.raises(ValueError): + laser_delay.calibrate(float("nan")) + laser_delay.evr_period._read_pv.mock_data = 0.0 + with pytest.raises(ValueError, match="laser period"): + laser_delay.calibrate(SCOPE) + assert math.isnan(laser_delay.get_calibration()["offset_t"]) + + +def test_calibration_publishes_readback_subscription(laser_delay): + """BEC listens to the 'readback' event; calibrate() and moves must fire it.""" + values = [] + laser_delay.subscribe(lambda value, **_: values.append(value), event_type="readback", run=False) + laser_delay.calibrate(SCOPE) + laser_delay.set(1.0).wait(timeout=10) + assert values == [SCOPE, 1.0] + + +def test_set_calibration_restores_previous_state(laser_delay): + laser_delay.calibrate(SCOPE) + laser_delay.set(2.5).wait(timeout=10) + saved = laser_delay.get_calibration() + v1, v2 = laser_delay.phase_v1.get(), laser_delay.phase_v2.get() + + # BEC restart: a fresh device, hardware untouched + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as fresh: + set_hardware( + fresh, + coarse=laser_delay.delay_coarse.get(), + fine=laser_delay.delay_fine.get(), + v1=v1, + v2=v2, + ) + with pytest.raises(LaserDelayNotCalibratedError): + fresh.set(1.0) + # Timing.offset order: laser_period, offset_t, offset_phase_t, phase_t + fresh.set_calibration(*saved.values(), current_delay=2.5) + assert fresh.get_calibration() == saved + assert fresh.read()["laser_delay"]["value"] == 2.5 + log_fresh = record_puts(fresh) + fresh.set(-1.0).wait(timeout=10) + log = record_puts(laser_delay) + laser_delay.set(-1.0).wait(timeout=10) + assert log_fresh == log + + +@pytest.mark.parametrize( + "values", [(0.0, 1.0, 1.0, 1.0), (-5.0, 1.0, 1.0, 1.0), (2000.0, float("nan"), 1.0, 1.0)] +) +def test_set_calibration_validates(laser_delay, values): + with pytest.raises(ValueError): + laser_delay.set_calibration(*values) + + +# --------------------------------------------------------------------------------------------- # +# Moves # +# --------------------------------------------------------------------------------------------- # + + +def test_move_sets_delay_card_and_ramps_phase(calibrated): + log = record_puts(calibrated) + plan = calibrated.plan_move(-4.9) + status = calibrated.set(-4.9) + status.wait(timeout=10) + assert status.success + assert log[0] == ("X10DA-ES2-TIM:SET1-DELAY", plan["delay_coarse"]) + assert log[1] == ("X10DA-ES2-TIM:SET1-FINE", plan["delay_fine"]) + assert len(log) == 2 + 2 * plan["phase_steps"] + v1, v2 = voltages_for_phase(plan["phase_start"] + plan["phase_delta"], 2.0, 1.0) + assert log[-2][1] == v1 and log[-1][1] == v2 + assert calibrated.phase_t.get() == plan["phase_target"] + reading = calibrated.read() + assert reading["laser_delay"]["value"] == -4.9 + assert reading["laser_delay_delay_coarse"]["value"] == plan["delay_coarse"] + assert reading["laser_delay_phase_mismatch"]["value"] == pytest.approx(0.0, abs=1e-9) + assert calibrated.motor_is_moving.get() == 0 + assert not calibrated.moving + + +def test_move_does_not_block_and_reports_moving(calibrated): + calibrated.step_sleep.put(0.01) + moving = [] + calibrated.motor_is_moving.subscribe(lambda value, **_: moving.append(value), run=False) + start = time.monotonic() + status = calibrated.set(-4.9) # 290 steps, ~3 s + assert time.monotonic() - start < 0.5 + assert not status.done + assert calibrated.moving + assert calibrated.motor_is_moving.get() == 1 + with pytest.raises(LaserDelayBusyError): + calibrated.set(0.0) + with pytest.raises(LaserDelayBusyError): + calibrated.calibrate(SCOPE) + calibrated.stop() + with pytest.raises(DeviceStoppedError): + status.wait(timeout=2) + assert wait_until(lambda: calibrated.motor_is_moving.get() == 0) + assert moving[:1] == [1] and moving[-1] == 0 + + +def test_stop_mid_ramp_keeps_last_step_and_fails_status(calibrated): + calibrated.step_sleep.put(0.05) + log = record_puts(calibrated) + old_phase_t = calibrated.phase_t.get() + plan = calibrated.plan_move(-4.9) + status = calibrated.set(-4.9) + assert wait_until(lambda: len(log) >= 2 + 2 * 5) + start = time.monotonic() + calibrated.stop() + with pytest.raises(DeviceStoppedError): + status.wait(timeout=1) + assert time.monotonic() - start < 0.5 + assert wait_until(lambda: not calibrated._hw_lock.locked()) + n_puts = len(log) + time.sleep(0.2) + assert len(log) == n_puts, "puts continued after stop" + assert n_puts % 2 == 0, "a step was left half written" + steps_written = (n_puts - 2) // 2 + assert 5 <= steps_written < plan["phase_steps"] + # voltages stay at the last written step + assert calibrated.phase_v1.get() == log[-2][1] + assert calibrated.phase_v2.get() == log[-1][1] + # bookkeeping: stored phase advanced by what was actually ramped + last_offset = phase_ramp_offsets(plan["phase_delta"], 2.0, 36)[steps_written - 1] + assert calibrated.phase_t.get() == pytest.approx(old_phase_t + last_offset) + assert calibrated.read()["laser_delay_phase_mismatch"]["value"] == pytest.approx(0, abs=1e-9) + assert math.isnan(calibrated.read()["laser_delay"]["value"]) + assert calibrated.motor_is_moving.get() == 0 + + # the next move works and ends where an uninterrupted move would have ended + calibrated.step_sleep.put(0.0) + calibrated.set(-4.9).wait(timeout=10) + assert calibrated.phase_t.get() == plan["phase_target"] + v1, v2 = voltages_for_phase(plan["phase_target"], 2.0, 1.0) + assert calibrated.phase_v1.get() == pytest.approx(v1, abs=1e-12) + assert calibrated.phase_v2.get() == pytest.approx(v2, abs=1e-12) + assert calibrated.read()["laser_delay"]["value"] == -4.9 + + +def test_new_move_right_after_stop_does_not_revive_the_old_ramp(calibrated): + """The scan server may send the next move as soon as the stopped status fails. The old ramp must + not continue, and the new move must start only after the old task has let go of the PVs.""" + calibrated.step_sleep.put(0.02) + log = record_puts(calibrated) + old = calibrated.set(-4.9) + assert wait_until(lambda: len(log) >= 2 + 2 * 5) + calibrated.stop() + with pytest.raises(DeviceStoppedError): + old.wait(timeout=1) + calibrated.step_sleep.put(0.0) + new = calibrated.set(0.0) + new.wait(timeout=10) + assert new.success + delay_puts = [i for i, (pv, _) in enumerate(log) if pv == "X10DA-ES2-TIM:SET1-DELAY"] + assert len(delay_puts) == 2 + assert delay_puts[1] % 2 == 0 # the old move ended on a complete V1/V2 pair + expected_phase = (-0.0) % 12.01 - calibrated.offset_phase_t.get() + assert calibrated.phase_t.get() == expected_phase + assert calibrated.read()["laser_delay_phase_mismatch"]["value"] == pytest.approx(0, abs=1e-9) + assert calibrated.read()["laser_delay"]["value"] == 0.0 + + +def test_stop_is_idempotent_without_move(calibrated): + calibrated.stop() + calibrated.stop() + calibrated.set(0.5).wait(timeout=10) + assert calibrated.read()["laser_delay"]["value"] == 0.5 + + +def test_status_timeout_also_stops_the_ramp(calibrated): + calibrated.step_sleep.put(0.05) + log = record_puts(calibrated) + status = calibrated.move(-4.9, timeout=0.3) + with pytest.raises(Exception): + status.wait(timeout=2) + assert wait_until(lambda: not calibrated._hw_lock.locked()) + n_puts = len(log) + time.sleep(0.2) + assert len(log) == n_puts < 2 + 2 * 290 + + +def test_destroy_ends_a_running_move(): + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.05) as device: + set_hardware(device) + device.calibrate(SCOPE) + status = device.set(-4.9) + device.destroy() + assert wait_until(lambda: status.done) + assert not status.success + + +def test_stored_vs_readback_reference_when_voltages_were_touched(calibrated): + calibrated.set(1.0).wait(timeout=10) + # somebody turns the phase shifter by hand + calibrated.phase_v1._read_pv.mock_data = 0.0 + calibrated.phase_v2._read_pv.mock_data = 1.0 + assert calibrated.read()["laser_delay_phase_mismatch"]["value"] != pytest.approx(0, abs=1e-3) + plan_stored = calibrated.plan_move(2.0) + calibrated.set(2.0).wait(timeout=10) + end_stored = calibrated.read_phase() + # stored: relative move from the stored phase, starting at the read-back -> offset persists + assert wrap_phase(end_stored - (0.5 + plan_stored["phase_delta"]), 2.0) == pytest.approx( + 0, abs=1e-9 + ) + + calibrated.phase_v1._read_pv.mock_data = 0.0 + calibrated.phase_v2._read_pv.mock_data = 1.0 + calibrated.phase_reference.put("readback") + plan_readback = calibrated.plan_move(3.0) + calibrated.set(3.0).wait(timeout=10) + # readback: lands on the target phase whatever the voltages were + assert plan_readback["phase_start"] == pytest.approx(0.5) + assert wrap_phase( + calibrated.read_phase() - plan_readback["phase_target"], 2.0 + ) == pytest.approx(0, abs=1e-9) + assert calibrated.phase_t.get() == plan_readback["phase_target"] + + +def test_plan_move_writes_nothing(calibrated): + log = record_puts(calibrated) + plan = calibrated.plan_move(5.3) + assert log == [] + assert plan["estimated_duration_s"] == 0.0 # step_sleep 0 in the fixture + assert plan["phase_steps"] == len(phase_ramp_offsets(plan["phase_delta"], 2.0, 36)) + 1 + + +def test_first_move_after_calibration_can_ramp_two_cavity_periods(laser_delay): + """Documented in LASER_DELAY.md: dP = (-t mod cav) + (s mod cav) right after calibration.""" + laser_delay.calibrate(SCOPE) + plan = laser_delay.plan_move(-4.9) + assert plan["phase_delta"] == pytest.approx(4.9 + SCOPE % 12.01) + assert plan["phase_delta"] > 12.01 + laser_delay.set(-4.9).wait(timeout=10) + # later moves stay within one cavity period + for target in (0.0, 5.3, -4.9, 11.9, 12.2): + assert abs(laser_delay.plan_move(target)["phase_delta"]) < 12.01 + laser_delay.set(target).wait(timeout=10) + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), "abc"]) +def test_rejects_non_finite_targets(calibrated, value): + with pytest.raises(ValueError): + calibrated.set(value) + + +# --------------------------------------------------------------------------------------------- # +# Simulation # +# --------------------------------------------------------------------------------------------- # + + +def test_sim_device_moves_without_epics(): + device = SimLaserDelay(name="laser_delay", step_sleep=0.0) + try: + device.wait_for_connection() + assert not any(hasattr(sig.item, "pvname") for sig in device.walk_signals()) + result = device.calibrate(-0.88) + assert result["laser_period"] == pytest.approx(2000.2) + assert result["offset_t"] == pytest.approx(-300.37 + 0.88) + device.set(0.5).wait(timeout=30) + reading = device.read() + assert reading["laser_delay"]["value"] == 0.5 + assert reading["laser_delay_phase_mismatch"]["value"] == pytest.approx(0, abs=1e-9) + coarse, fine = ( + reading["laser_delay_delay_coarse"]["value"], + reading["laser_delay_delay_fine"]["value"], + ) + assert 2 * coarse + 0.01 * fine == pytest.approx( + (-result["offset_t"] - 0.5) % 2000.2, abs=0.011 + ) + finally: + device.destroy() -- 2.54.0 From f6887e757ce52917627bf0bd09d602c5f1d5e296 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 18:33:50 +0200 Subject: [PATCH 16/25] fix(devices): hold the laser delay hardware lock while calibrating --- superxas_bec/devices/laser_delay.py | 29 ++++++++++--- tests/tests_devices/test_laser_delay.py | 56 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index adae6b2..c1b1fbc 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -38,6 +38,7 @@ interrupted move. from __future__ import annotations +import contextlib import functools import math import threading @@ -486,7 +487,10 @@ class LaserDelay(PSIDeviceBase, PositionerBase): raise ValueError( f"{self.name}: scope reading must be finite, got {scope_xray_minus_laser!r}" ) - self._require_idle("calibrate") + with self._exclusive_hardware("calibrate"): + return self._calibrate(s) + + def _calibrate(self, s: float) -> dict[str, float]: constants = self._constants() # Same reading order as the script: EVR, DELAY, FINE, V1, V2. laser_period = 1000 * self.evr_period.get() @@ -557,10 +561,10 @@ class LaserDelay(PSIDeviceBase, PositionerBase): raise ValueError(f"{self.name}: {key} must be finite, got {value!r}") if float(laser_period) <= 0: raise ValueError(f"{self.name}: laser_period must be positive, got {laser_period!r}") - self._require_idle("set_calibration") calibration = LaserDelayCalibration(**{key: float(val) for key, val in values.items()}) delay = math.nan if current_delay is None else float(current_delay) - self._store_calibration(calibration, current_delay=delay) + with self._exclusive_hardware("set_calibration"): + self._store_calibration(calibration, current_delay=delay) logger.info(f"{self.name}: calibration set to {calibration.as_dict()}") return calibration.as_dict() @@ -677,9 +681,24 @@ class LaserDelay(PSIDeviceBase, PositionerBase): ) return calibration - def _require_idle(self, action: str) -> None: - if self.moving: + @contextlib.contextmanager + def _exclusive_hardware(self, action: str): + """Hold the hardware lock for a calibration, refusing if a move runs or is queued. + + BEC's device server handles instructions on a thread pool, so a ``set`` can arrive while + ``calibrate`` reads the PVs. Holding the lock keeps a move from writing the PVs that are + being read, and makes a move that arrives meanwhile run after the calibration, with it. + """ + if not self._hw_lock.acquire(blocking=False): raise LaserDelayBusyError(f"{self.name}: cannot {action} while a move is running.") + try: + with self._move_lock: + pending = self._move_status is not None and not self._move_status.done + if pending: + raise LaserDelayBusyError(f"{self.name}: cannot {action} while a move is running.") + yield + finally: + self._hw_lock.release() def _store_calibration(self, calibration: LaserDelayCalibration, current_delay: float) -> None: self.laser_period.put(calibration.laser_period, internal=True) diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py index d0f3411..a27b730 100644 --- a/tests/tests_devices/test_laser_delay.py +++ b/tests/tests_devices/test_laser_delay.py @@ -26,6 +26,7 @@ from superxas_bec.devices.laser_delay import ( LaserDelayBusyError, LaserDelayNotCalibratedError, SimLaserDelay, + delay_card_setting, phase_from_voltages, phase_ramp_offsets, voltages_for_phase, @@ -357,6 +358,61 @@ def test_move_does_not_block_and_reports_moving(calibrated): assert moving[:1] == [1] and moving[-1] == 0 +def test_move_requested_during_calibration_runs_after_it(calibrated): + """BEC's device server runs instructions on a thread pool, so a set can arrive while calibrate() + reads the PVs. The move must not write while the calibration reads, and must then use the new + calibration.""" + reading, release = threading.Event(), threading.Event() + original_get = calibrated.delay_fine.get + + def slow_get(*args, **kwargs): + reading.set() + release.wait(timeout=5) + return original_get(*args, **kwargs) + + set_hardware(calibrated, coarse=160, fine=12) # the hardware was moved by hand since + log = record_puts(calibrated) + result = {} + with mock.patch.object(calibrated.delay_fine, "get", side_effect=slow_get): + thread = threading.Thread(target=lambda: result.update(cal=calibrated.calibrate(2.5))) + thread.start() + assert reading.wait(timeout=5) + status = calibrated.set(1.0) + time.sleep(0.1) + assert log == [], "the move wrote PVs while calibrate() was reading them" + release.set() + thread.join(timeout=5) + status.wait(timeout=10) + assert status.success + cal = result["cal"] + assert cal["offset_t"] == pytest.approx(-(2.0 * 160 + 0.01 * 12) - 2.5) + assert (log[0][1], log[1][1]) == delay_card_setting(-cal["offset_t"] - 1.0, cal["laser_period"]) + assert calibrated.read()["laser_delay"]["value"] == 1.0 + + +def test_calibration_refused_while_a_stopped_move_finishes_its_step(calibrated): + calibrated.step_sleep.put(0.05) + blocker = threading.Event() + original_put = calibrated.phase_v1._write_pv.put + + def blocking_put(value, *args, **kwargs): + blocker.wait(timeout=5) # a slow CA put + return original_put(value, *args, **kwargs) + + calibrated.phase_v1._write_pv.put = blocking_put + status = calibrated.set(-4.9) + assert wait_until(calibrated._hw_lock.locked) + calibrated.stop() + assert status.done and not status.success + with pytest.raises(LaserDelayBusyError): + calibrated.calibrate(SCOPE) + with pytest.raises(LaserDelayBusyError): + calibrated.set_calibration(2000.2, 1.0, 1.0, 1.0) + blocker.set() + assert wait_until(lambda: not calibrated.moving) + calibrated.calibrate(SCOPE) + + def test_stop_mid_ramp_keeps_last_step_and_fails_status(calibrated): calibrated.step_sleep.put(0.05) log = record_puts(calibrated) -- 2.54.0 From 1da2ab89984163ed343b2904acd555fb1c4b09d9 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 18:35:33 +0200 Subject: [PATCH 17/25] fix(devices): fail a running laser delay move when the device is destroyed --- superxas_bec/devices/laser_delay.py | 18 +++++++++++++----- tests/tests_devices/test_laser_delay.py | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index c1b1fbc..76c6741 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -637,15 +637,23 @@ class LaserDelay(PSIDeviceBase, PositionerBase): def on_stop(self) -> None: """Interrupt a running phase ramp between two steps and fail its status.""" + self._interrupt_move(DeviceStoppedError(f"Device {self.name} has been stopped")) + + def on_destroy(self) -> None: + """Interrupt a running move and fail its status before the base class kills the task. + + The base class shuts the task handler down by raising an exception in the task thread. That + can land anywhere in the task, including after the ramp but before the status is resolved, + so the status is failed here first; whoever waits on it (a scan, ``umv``) is released. + """ + self._interrupt_move(DeviceStoppedError(f"Device {self.name} has been destroyed")) + + def _interrupt_move(self, exc: Exception) -> None: with self._move_lock: self._move_stop.set() status = self._move_status if status is not None: - self._finish_status(status, DeviceStoppedError(f"Device {self.name} has been stopped")) - - def on_destroy(self) -> None: - """Interrupt a running move; the task handler is shut down by the base class.""" - self._move_stop.set() + self._finish_status(status, exc) # ----------------------------------------------------------------------------------------- # # Internals # diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py index a27b730..49c2669 100644 --- a/tests/tests_devices/test_laser_delay.py +++ b/tests/tests_devices/test_laser_delay.py @@ -504,6 +504,31 @@ def test_destroy_ends_a_running_move(): assert not status.success +def test_destroy_fails_the_move_status_even_if_the_task_hangs(): + """destroy() (config reload, device server shutdown) must release whoever waits on the move, + also when the move task is stuck in a slow CA put and the thread kill does not reach it.""" + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as device: + set_hardware(device) + device.calibrate(SCOPE) + blocker = threading.Event() + original_put = device.phase_v1._write_pv.put + + def hung_put(value, *args, **kwargs): + blocker.wait(timeout=5) + return original_put(value, *args, **kwargs) + + device.phase_v1._write_pv.put = hung_put + status = device.set(-4.9) + assert wait_until(device._hw_lock.locked) + with mock.patch.object(device.task_handler, "shutdown"): + device.destroy() + assert status.done and not status.success + with pytest.raises(DeviceStoppedError, match="destroyed"): + status.wait(timeout=0) + blocker.set() + assert wait_until(lambda: not device._hw_lock.locked()) + + def test_stored_vs_readback_reference_when_voltages_were_touched(calibrated): calibrated.set(1.0).wait(timeout=10) # somebody turns the phase shifter by hand -- 2.54.0 From d324c63e2442d51cbb6e5ff02804c36ec4380253 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 18:38:32 +0200 Subject: [PATCH 18/25] fix(devices): validate laser delay limits and keep config rollbacks working --- superxas_bec/devices/laser_delay.py | 78 +++++++++++++++++++++---- tests/tests_devices/test_laser_delay.py | 60 +++++++++++++++++++ 2 files changed, 128 insertions(+), 10 deletions(-) diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index 76c6741..6bc714d 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -167,11 +167,12 @@ class _Constants: class CheckedSignal(Signal): - """Soft signal that validates every write, used for tunable constants. + """Soft signal that validates every write, used for tunable constants and the soft limits. - Writes come from the constructor and from runtime ``deviceConfig`` updates - (``dev.laser_delay.set_device_config({...})``), so a bad value is rejected instead of being - used in the next move. + Writes come from the constructor, from runtime changes in the client + (``dev.laser_delay.step_sleep.set(0.05).wait()``, ``dev.laser_delay.limits = [-20, 20]``) and + from runtime ``deviceConfig`` updates, so a bad value is rejected instead of being used in the + next move. """ def __init__(self, *args, validator: Callable[[object], None] | None = None, **kwargs): @@ -201,6 +202,44 @@ class ComputedPhaseSignal(SignalRO): return value +class _ConstructorOnly: + """Read-only attribute for a constructor-only ``deviceConfig`` key (the PV names). + + When a runtime ``deviceConfig`` update fails, BEC's device server re-applies the whole previous + ``deviceConfig`` with ``setattr``/``Signal.set`` per key. Without an attribute of that name the + rollback itself fails ("Unknown config parameter delay_coarse_pv") and hides the real error. + Writing the current value is a no-op; a new value needs a config reload. + """ + + def __set_name__(self, owner, name): + self._key = name + self._attr = f"_{name}" + + def __get__(self, obj, objtype=None): + if obj is None: + return self + return getattr(obj, self._attr) + + def __set__(self, obj, value): + if value != getattr(obj, self._attr): + raise ValueError( + f"{obj.name}: {self._key} can only be changed by reloading the device config " + f"(current {getattr(obj, self._attr)!r}, requested {value!r})" + ) + + +def _finite_number(name: str) -> 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 math.isfinite(number): + raise ValueError(f"{name} must be a finite number, got {value!r}") + + return check + + def _positive_number(name: str) -> Callable[[object], None]: def check(value): try: @@ -347,9 +386,20 @@ class LaserDelay(PSIDeviceBase, PositionerBase): CheckedSignal, value="stored", kind=Kind.config, validator=_phase_reference ) - # Soft limits (BEC publishes them as the device limits) - low_limit_travel = Cpt(Signal, value=0.0, kind=Kind.omitted) - high_limit_travel = Cpt(Signal, value=0.0, kind=Kind.omitted) + # Soft limits (BEC publishes them as the device limits). Equal values mean no limits. + low_limit_travel = Cpt( + CheckedSignal, value=0.0, kind=Kind.omitted, validator=_finite_number("low limit") + ) + high_limit_travel = Cpt( + CheckedSignal, value=0.0, kind=Kind.omitted, validator=_finite_number("high limit") + ) + + # PV names: constructor-only deviceConfig keys, see _ConstructorOnly + delay_coarse_pv = _ConstructorOnly() + delay_fine_pv = _ConstructorOnly() + evr_period_pv = _ConstructorOnly() + phase_v1_pv = _ConstructorOnly() + phase_v2_pv = _ConstructorOnly() # pylint: disable=too-many-arguments,too-many-locals def __init__( @@ -397,8 +447,11 @@ class LaserDelay(PSIDeviceBase, PositionerBase): if limits is not None: if len(limits) != 2: raise ValueError(f"limits must be [low, high], got {limits!r}") - self.low_limit_travel.put(float(limits[0])) - self.high_limit_travel.put(float(limits[1])) + low, high = float(limits[0]), float(limits[1]) + if low > high: + raise ValueError(f"limits must be [low, high] with low <= high, got {limits!r}") + self.low_limit_travel.put(low) + self.high_limit_travel.put(high) # ----------------------------------------------------------------------------------------- # # Positioner interface # @@ -412,7 +465,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): @property def limits(self) -> tuple[float, float]: """Soft limits (low, high). Equal values mean no limits.""" - return (self.low_limit_travel.get(), self.high_limit_travel.get()) + return (float(self.low_limit_travel.get()), float(self.high_limit_travel.get())) @property def moving(self) -> bool: @@ -430,6 +483,11 @@ class LaserDelay(PSIDeviceBase, PositionerBase): if not math.isfinite(number): raise ValueError(f"{self.name}: delay must be finite, got {value!r}") low, high = self.limits + if low > high: + raise LimitError( + f"{self.name}: the limits [{low}, {high}] are inverted; set them again with " + f"dev.{self.name}.limits = [low, high] (equal values mean no limits)" + ) if low < high and not low <= number <= high: raise LimitError(f"{self.name}: {number} ns is outside the limits [{low}, {high}]") diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py index 49c2669..b4a4a27 100644 --- a/tests/tests_devices/test_laser_delay.py +++ b/tests/tests_devices/test_laser_delay.py @@ -4,6 +4,7 @@ from __future__ import annotations +import functools import math import threading import time @@ -13,7 +14,10 @@ from unittest import mock import numpy as np import ophyd import pytest +from bec_lib import messages from bec_lib.bec_yaml_loader import yaml_load +from bec_lib.devicemanager import DeviceConfigError +from bec_server.device_server.devices.config_update_handler import ConfigUpdateHandler from bec_server.device_server.devices.device_serializer import get_device_base_class from bec_server.device_server.devices.devicemanager import DeviceManagerDS from ophyd.signal import InternalSignalError @@ -175,6 +179,8 @@ def test_config_overrides_pvs_and_constants(): {"vmax": float("nan")}, {"step_sleep": -0.1}, {"limits": [1.0]}, + {"limits": [5.0, -5.0]}, + {"limits": [-5.0, float("inf")]}, ], ) def test_invalid_config_is_rejected(kwargs): @@ -194,6 +200,60 @@ def test_runtime_constant_update_is_validated(calibrated): assert calibrated._constants().phase_reference == "stored" +def test_runtime_limits_are_validated(calibrated): + """BEC writes runtime limits (dev.laser_delay.limits = [...]) one signal at a time.""" + with pytest.raises(ValueError): + calibrated.low_limit_travel.set(float("nan")).wait(timeout=2) + calibrated.low_limit_travel.set(5.0).wait(timeout=2) + calibrated.high_limit_travel.set(-5.0).wait(timeout=2) + with pytest.raises(LimitError, match="inverted"): + calibrated.set(0.0) + calibrated.high_limit_travel.set(10.0).wait(timeout=2) + with pytest.raises(LimitError, match="outside"): + calibrated.set(0.0) + calibrated.set(7.0).wait(timeout=10) + assert calibrated.read()["laser_delay"]["value"] == 7.0 + + +def config_update_handler(device: LaserDelay, device_config: dict) -> ConfigUpdateHandler: + """BEC's ConfigUpdateHandler with just enough device manager for runtime deviceConfig updates.""" + entry = mock.MagicMock() + entry.name, entry.enabled, entry.obj = device.name, True, device + entry._config = {"deviceConfig": dict(device_config)} + device_manager = mock.MagicMock() + device_manager.devices = {device.name: entry} + device_manager.update_config = functools.partial(DeviceManagerDS.update_config, device_manager) + handler = ConfigUpdateHandler.__new__(ConfigUpdateHandler) + handler.device_manager = device_manager + return handler + + +def test_runtime_config_update_errors_name_the_real_problem(): + """A failed runtime deviceConfig update makes BEC re-apply the whole previous deviceConfig, + including the PV names. That rollback must succeed so the user sees the actual error.""" + entry = yaml_load(str(CONFIG_DIR / "x10da_laser_delay.yaml"))["laser_delay"] + with patched_device(LaserDelay, name="laser_delay", **entry["deviceConfig"]) as device: + handler = config_update_handler(device, entry["deviceConfig"]) + + def update(config): + msg = messages.DeviceConfigMessage( + action="update", config={"laser_delay": {"deviceConfig": config}} + ) + handler._update_config(msg, threading.Event()) + + with pytest.raises(DeviceConfigError, match="phase_reference must be one of"): + update({"phase_reference": "readbak"}) + assert device.phase_reference.get() == "stored" + with pytest.raises(DeviceConfigError, match="reloading the device config"): + update({"phase_v1_pv": "X10DA-ES4:AO-03"}) + assert device.phase_v1.pvname == "X10DA-ES4:AO-02" + with pytest.raises(DeviceConfigError, match="finite"): + update({"limits": [float("nan"), 1.0]}) + update({"step_sleep": 0.05, "phase_v1_pv": "X10DA-ES4:AO-02", "limits": [-20, 20]}) + assert device.step_sleep.get() == 0.05 + assert device.limits == (-20.0, 20.0) + + def test_calibration_signals_are_internal(calibrated): """Calibration can only change through calibrate()/set_calibration()/moves.""" with pytest.raises(InternalSignalError): -- 2.54.0 From 3914acce47a6e7b1c4e36025bc6c4323fdb9e1af Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 18:40:11 +0200 Subject: [PATCH 19/25] test(devices): cover laser delay PV failures in the middle of a move --- tests/tests_devices/test_laser_delay.py | 72 ++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py index b4a4a27..7e5ba9b 100644 --- a/tests/tests_devices/test_laser_delay.py +++ b/tests/tests_devices/test_laser_delay.py @@ -20,7 +20,7 @@ from bec_lib.devicemanager import DeviceConfigError from bec_server.device_server.devices.config_update_handler import ConfigUpdateHandler from bec_server.device_server.devices.device_serializer import get_device_base_class from bec_server.device_server.devices.devicemanager import DeviceManagerDS -from ophyd.signal import InternalSignalError +from ophyd.signal import ConnectionTimeoutError, InternalSignalError from ophyd.utils import LimitError from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError from ophyd_devices.tests.utils import MockPV, patched_device @@ -589,6 +589,76 @@ def test_destroy_fails_the_move_status_even_if_the_task_hangs(): assert wait_until(lambda: not device._hw_lock.locked()) +def fail_on_put(signal, call: int, exc: Exception) -> None: + """Make the ``call``-th put to ``signal`` raise ``exc`` (the IOC went away).""" + pv = signal._write_pv + original, count = pv.put, {"n": 0} + + def put(value, *args, **kwargs): + count["n"] += 1 + if count["n"] == call: + raise exc + return original(value, *args, **kwargs) + + pv.put = put + + +def test_pv_lost_mid_ramp_fails_the_move_and_keeps_the_phase_bookkeeping(calibrated): + """V2 stops answering in the middle of the ramp: the move fails with the EPICS error, the device + is idle again, the readback is NaN, the stored phase follows the last complete V1/V2 pair and + the next move (IOC back) works.""" + old_phase_t = calibrated.phase_t.get() + plan = calibrated.plan_move(-4.9) + log = record_puts(calibrated) + error = ConnectionTimeoutError("X10DA-ES4:AO-01 could not connect within 1.0 s") + fail_on_put(calibrated.phase_v2, 6, error) + status = calibrated.set(-4.9) + with pytest.raises(ConnectionTimeoutError): + status.wait(timeout=5) + assert wait_until(lambda: not calibrated.moving) + assert calibrated.motor_is_moving.get() == 0 + assert math.isnan(calibrated.readback.get()) + # DELAY, FINE, 5 complete pairs, then V1 of the 6th step (V2 failed) + assert [pv for pv, _ in log[:2]] == ["X10DA-ES2-TIM:SET1-DELAY", "X10DA-ES2-TIM:SET1-FINE"] + assert len(log) == 2 + 2 * 5 + 1 + offsets = phase_ramp_offsets(plan["phase_delta"], 2.0, 36) + assert calibrated.phase_t.get() == pytest.approx(old_phase_t + offsets[4]) + # the half-written pair leaves less than one ramp step of mismatch; phase_mismatch shows it + assert abs(calibrated.read_phase_mismatch()) < 2.0 / 36 + + calibrated.set(-4.9).wait(timeout=10) + assert calibrated.read()["laser_delay"]["value"] == -4.9 + assert calibrated.phase_t.get() == plan["phase_target"] + + +def test_delay_card_write_failure_leaves_the_phase_shifter_alone(calibrated): + old_phase_t = calibrated.phase_t.get() + log = record_puts(calibrated) + fail_on_put(calibrated.delay_fine, 1, ConnectionTimeoutError("SET1-FINE not connected")) + status = calibrated.set(-4.9) + with pytest.raises(ConnectionTimeoutError): + status.wait(timeout=5) + assert [pv for pv, _ in log] == ["X10DA-ES2-TIM:SET1-DELAY"] + assert calibrated.phase_t.get() == old_phase_t + assert math.isnan(calibrated.readback.get()) + assert calibrated.motor_is_moving.get() == 0 + + +def test_phase_read_failure_after_the_delay_card_fails_the_move(calibrated): + old_phase_t = calibrated.phase_t.get() + log = record_puts(calibrated) + with mock.patch.object( + calibrated.phase_v1, "get", side_effect=ConnectionTimeoutError("AO-02 not connected") + ): + status = calibrated.set(-4.9) + with pytest.raises(ConnectionTimeoutError): + status.wait(timeout=5) + assert [pv for pv, _ in log] == ["X10DA-ES2-TIM:SET1-DELAY", "X10DA-ES2-TIM:SET1-FINE"] + assert calibrated.phase_t.get() == old_phase_t + assert math.isnan(calibrated.readback.get()) + assert not calibrated.moving + + def test_stored_vs_readback_reference_when_voltages_were_touched(calibrated): calibrated.set(1.0).wait(timeout=10) # somebody turns the phase shifter by hand -- 2.54.0 From b6121cc3f045f833608c0c2244d2a2f4affd14df Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 18:47:34 +0200 Subject: [PATCH 20/25] feat(devices): add opt-in shortest phase path to the laser delay --- .../device_configs/x10da_laser_delay.yaml | 1 + superxas_bec/devices/laser_delay.py | 175 +++++++++++++----- tests/tests_devices/test_laser_delay.py | 152 +++++++++++++++ .../tests_devices/test_laser_delay_golden.py | 69 ++++++- 4 files changed, 349 insertions(+), 48 deletions(-) diff --git a/superxas_bec/device_configs/x10da_laser_delay.yaml b/superxas_bec/device_configs/x10da_laser_delay.yaml index 08a5ca3..e464cd1 100644 --- a/superxas_bec/device_configs/x10da_laser_delay.yaml +++ b/superxas_bec/device_configs/x10da_laser_delay.yaml @@ -28,6 +28,7 @@ laser_delay: steps_per_rf_period: 36 step_sleep: 0.1 phase_reference: stored + phase_path: script # "shortest" is opt-in, see LASER_DELAY.md deviceTags: - laser - timing diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index 6bc714d..2d85813 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -17,6 +17,12 @@ How a delay ``t`` (X-ray minus laser, as read on the oscilloscope) is set: always starts at the phase read back from V1/V2 and walks in steps of ``sign(dP) * rf_period / steps_per_rf_period`` with ``step_sleep`` seconds between steps; the last step lands exactly on ``readback + dP``. Afterwards ``P`` is stored as the new ``phase_t``. +* **Phase path.** ``phase_path="script"`` (default) ramps ``dP`` as above, up to about two cavity + periods. ``phase_path="shortest"`` (opt-in) first folds ``dP`` by whole cavity periods into + ``(-cavity_period/2, +cavity_period/2]`` and stores ``P - k * cavity_period``. That is only the + same physical delay if the laser timing repeats every cavity period *and* the cavity period is a + whole number of RF periods: with ``rf_period=2.0`` and ``cavity_period=12.01`` each folded period + leaves the phase shifter 0.01 ns away from where the script would put it (see ``LASER_DELAY.md``). The stored phase is "unwrapped": the voltages only encode the phase modulo one RF period, while the stored value keeps track of how many RF periods the shifter has been turned. That is why the script @@ -71,6 +77,7 @@ DELAY_CARD_FINE_UNIT = 0.01 DELAY_CARD_FINE_DIVISOR = 0.009999 PHASE_REFERENCES = ("stored", "readback") +PHASE_PATHS = ("script", "shortest") class LaserDelayNotCalibratedError(RuntimeError): @@ -137,6 +144,20 @@ def wrap_phase(value: float, rf_period: float) -> float: return (value + rf_period / 2) % rf_period - rf_period / 2 +def fold_phase_delta(delta: float, cavity_period: float) -> tuple[float, int]: + """Fold a relative phase move by whole cavity periods into (-cavity/2, +cavity/2]. + + Returns: + tuple[float, int]: ``(delta - k * cavity_period, k)``. A move that is already inside the + interval is returned unchanged (``k == 0``, bit for bit). + """ + half = cavity_period / 2 + if -half < delta <= half: + return delta, 0 + wraps = math.ceil((delta - half) / cavity_period) + return delta - wraps * cavity_period, int(wraps) + + @dataclass(frozen=True) class LaserDelayCalibration: """Calibration of the delay, the four numbers of the script's ``Timing.offset`` file.""" @@ -159,6 +180,48 @@ class _Constants: steps_per_rf_period: int step_sleep: float phase_reference: str + phase_path: str + + +@dataclass(frozen=True) +class _PhasePlan: + """Phase shifter part of a move: ramp from ``start`` by ``delta``, then store ``stored_after``.""" + + target: float # the script's ForPhaseShifter, P + start: float # read back from V1/V2 + reference: float # what delta is measured from: stored phase or read-back + delta: float # relative ramp, folded for phase_path "shortest" + cavity_wraps: int # whole cavity periods folded away (0 for phase_path "script") + offsets: np.ndarray # intermediate ramp offsets, without the final step to delta + stored_after: float # new phase_t: P, or P - cavity_wraps * cavity_period + + @property + def n_puts(self) -> int: + """V1/V2 pairs written, including the final one.""" + return len(self.offsets) + 1 + + +def plan_phase_move( + target_t: float, calibration: LaserDelayCalibration, constants: _Constants, start: float +) -> _PhasePlan: + """Phase shifter ramp for a move to delay ``target_t``, starting at read-back phase ``start``.""" + phase_target = (-target_t) % constants.cavity_period - calibration.offset_phase_t + reference = calibration.phase_t if constants.phase_reference == "stored" else start + delta = phase_target - reference + wraps = 0 + if constants.phase_path == "shortest": + delta, wraps = fold_phase_delta(delta, constants.cavity_period) + offsets = phase_ramp_offsets(delta, constants.rf_period, constants.steps_per_rf_period) + stored_after = phase_target - wraps * constants.cavity_period if wraps else phase_target + return _PhasePlan( + target=phase_target, + start=start, + reference=reference, + delta=delta, + cavity_wraps=wraps, + offsets=offsets, + stored_after=stored_after, + ) # --------------------------------------------------------------------------------------------- # @@ -281,6 +344,11 @@ def _phase_reference(value): raise ValueError(f"phase_reference must be one of {PHASE_REFERENCES}, got {value!r}") +def _phase_path(value): + if value not in PHASE_PATHS: + raise ValueError(f"phase_path must be one of {PHASE_PATHS}, got {value!r}") + + # --------------------------------------------------------------------------------------------- # # Device # # --------------------------------------------------------------------------------------------- # @@ -314,6 +382,8 @@ class LaserDelay(PSIDeviceBase, PositionerBase): step_sleep (float): seconds between two phase ramp steps. phase_reference (str): ``"stored"`` (script behaviour) or ``"readback"``; what the relative phase move is computed from, see module docstring. + phase_path (str): ``"script"`` (default, script behaviour) or ``"shortest"``: fold the + relative phase move into half a cavity period either way. Opt-in, see module docstring. limits (list[float] | None): optional soft limits ``[low, high]`` in ns. """ @@ -385,6 +455,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): phase_reference = Cpt( CheckedSignal, value="stored", kind=Kind.config, validator=_phase_reference ) + phase_path = Cpt(CheckedSignal, value="script", kind=Kind.config, validator=_phase_path) # Soft limits (BEC publishes them as the device limits). Equal values mean no limits. low_limit_travel = Cpt( @@ -418,6 +489,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): steps_per_rf_period: int = 36, step_sleep: float = 0.1, phase_reference: str = "stored", + phase_path: str = "script", limits: list[float] | tuple[float, float] | None = None, scan_info: ScanInfo | None = None, device_manager: DeviceManagerBase | None = None, @@ -444,6 +516,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): self.steps_per_rf_period.put(steps_per_rf_period) self.step_sleep.put(float(step_sleep)) self.phase_reference.put(phase_reference) + self.phase_path.put(phase_path) if limits is not None: if len(limits) != 2: raise ValueError(f"limits must be [low, high], got {limits!r}") @@ -638,36 +711,48 @@ class LaserDelay(PSIDeviceBase, PositionerBase): def plan_move(self, position: float) -> dict: """Dry run of a move: what would be written, without writing anything. - Reads V1/V2 for the ramp start. Useful to compare with the script before the first move. + Reads V1/V2 for the ramp start. Useful to compare with the script before the first move, + and to see how long a move takes with the current ``phase_path``. Returns: - dict: ``delay_coarse``, ``delay_fine``, ``phase_target``, ``phase_start``, - ``phase_delta``, ``phase_steps`` (puts of V1/V2 incl. the last one) and + dict: ``delay_coarse``, ``delay_fine``, ``phase_target`` (the script's target ``P``), + ``phase_start``, ``phase_delta`` (the ramp actually done), ``phase_path``, + ``cavity_wraps`` (``k``: the stored phase after the move is ``P - k * cavity_period``; + always 0 for ``phase_path="script"``, counts the folds since the calibration for + ``"shortest"``), ``wrap_offset_ns`` (``-k * cavity_period`` modulo the RF period: how + far the phase shifter ends from where the script would put it; 0.01 ns per fold with + the script's 2.0/12.01 ns), ``phase_steps`` (puts of V1/V2 incl. the last one) and ``estimated_duration_s``. """ target = float(position) self.check_value(target) calibration = self._require_calibration() constants = self._constants() - total_t = -calibration.offset_t - target - coarse, fine = delay_card_setting(total_t, calibration.laser_period) - phase_target = (-target) % constants.cavity_period - calibration.offset_phase_t - phase_start = self._read_phase(constants.rf_period) - reference = calibration.phase_t if constants.phase_reference == "stored" else phase_start - delta = phase_target - reference - offsets = phase_ramp_offsets(delta, constants.rf_period, constants.steps_per_rf_period) - n_puts = len(offsets) + 1 + coarse, fine = delay_card_setting(-calibration.offset_t - target, calibration.laser_period) + plan = plan_phase_move( + target, calibration, constants, start=self._read_phase(constants.rf_period) + ) return { "target": target, "delay_coarse": coarse, "delay_fine": fine, - "phase_target": float(phase_target), - "phase_start": float(phase_start), - "phase_delta": float(delta), - "phase_steps": n_puts, - "estimated_duration_s": n_puts * constants.step_sleep, + "phase_target": float(plan.target), + "phase_start": float(plan.start), + "phase_delta": float(plan.delta), + "phase_path": constants.phase_path, + "cavity_wraps": plan.cavity_wraps, + "wrap_offset_ns": self._wrap_offset(plan.cavity_wraps, constants), + "phase_steps": plan.n_puts, + "estimated_duration_s": plan.n_puts * constants.step_sleep, } + @staticmethod + def _wrap_offset(cavity_wraps: int, constants: _Constants) -> float: + """Phase shifter offset in ns, modulo the RF period, left by folding whole cavity periods.""" + if cavity_wraps == 0: + return 0.0 + return float(wrap_phase(-cavity_wraps * constants.cavity_period, constants.rf_period)) + # ----------------------------------------------------------------------------------------- # # Readbacks used by the computed signals # # ----------------------------------------------------------------------------------------- # @@ -725,6 +810,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): steps_per_rf_period=int(self.steps_per_rf_period.get()), step_sleep=float(self.step_sleep.get()), phase_reference=str(self.phase_reference.get()), + phase_path=str(self.phase_path.get()), ) def _calibration(self) -> LaserDelayCalibration: @@ -832,24 +918,21 @@ class LaserDelay(PSIDeviceBase, PositionerBase): """The script's SetDelay: delay card, then the phase shifter ramp, then bookkeeping.""" if stop_event.is_set(): raise DeviceStoppedError(f"Device {self.name} has been stopped") - total_t = -calibration.offset_t - target - phase_target = (-target) % constants.cavity_period - calibration.offset_phase_t - coarse, fine = delay_card_setting(total_t, calibration.laser_period) + coarse, fine = delay_card_setting(-calibration.offset_t - target, calibration.laser_period) written_any = False done_offset = None - completed = False - reference = calibration.phase_t + plan = None try: self.delay_coarse.put(coarse) written_any = True self.delay_fine.put(fine) - phase_start = self._read_phase(constants.rf_period) - if constants.phase_reference == "readback": - reference = phase_start - else: - mismatch = wrap_phase(calibration.phase_t - phase_start, constants.rf_period) + plan = plan_phase_move( + target, calibration, constants, start=self._read_phase(constants.rf_period) + ) + if constants.phase_reference == "stored": + mismatch = wrap_phase(calibration.phase_t - plan.start, constants.rf_period) if abs(mismatch) > constants.rf_period / (2 * constants.steps_per_rf_period): logger.warning( f"{self.name}: stored phase and V1/V2 read-back differ by " @@ -857,37 +940,39 @@ class LaserDelay(PSIDeviceBase, PositionerBase): "The ramp starts at the read-back, so this move ends that far from the " "intended phase." ) - delta = phase_target - reference - offsets = phase_ramp_offsets(delta, constants.rf_period, constants.steps_per_rf_period) + wraps = "" + if plan.cavity_wraps: + wraps = ( + f"; shortest path, {plan.cavity_wraps:+d} cavity period(s) from the script's " + f"phase: {self._wrap_offset(plan.cavity_wraps, constants):+.4f} ns (mod RF)" + ) logger.info( f"{self.name}: move to {target} ns: DELAY={coarse}, FINE={fine}, phase " - f"{phase_start:.4f} -> +{delta:.4f} ns in {len(offsets) + 1} steps " - f"(~{(len(offsets) + 1) * constants.step_sleep:.1f} s)" + f"{plan.start:.4f} {plan.delta:+.4f} ns in {plan.n_puts} steps " + f"(~{plan.n_puts * constants.step_sleep:.1f} s){wraps}" ) - for offset in offsets: + for offset in plan.offsets: if stop_event.is_set(): raise DeviceStoppedError(f"Device {self.name} has been stopped") - self._write_phase(phase_start + offset, constants) + self._write_phase(plan.start + offset, constants) done_offset = offset if stop_event.wait(constants.step_sleep): raise DeviceStoppedError(f"Device {self.name} has been stopped") if stop_event.is_set(): raise DeviceStoppedError(f"Device {self.name} has been stopped") - self._write_phase(phase_start + delta, constants) - done_offset = delta - completed = True + self._write_phase(plan.start + plan.delta, constants) + done_offset = None + self.phase_t.put(float(plan.stored_after), internal=True) # The script also sleeps after the last step; a stop now no longer matters. stop_event.wait(constants.step_sleep) - finally: - if completed: - self.phase_t.put(float(phase_target), internal=True) - self._update_readback(target) - else: - if done_offset is not None: - # Keep stored and real (unwrapped) phase consistent for the next move. - self.phase_t.put(float(reference + done_offset), internal=True) - if written_any: - self._update_readback(math.nan) + self._update_readback(target) + except BaseException: + if done_offset is not None: + # Keep stored and real (unwrapped) phase consistent for the next move. + self.phase_t.put(float(plan.reference + done_offset), internal=True) + if written_any: + self._update_readback(math.nan) + raise class SimLaserDelay(LaserDelay): diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py index 7e5ba9b..ab8770b 100644 --- a/tests/tests_devices/test_laser_delay.py +++ b/tests/tests_devices/test_laser_delay.py @@ -31,6 +31,7 @@ from superxas_bec.devices.laser_delay import ( LaserDelayNotCalibratedError, SimLaserDelay, delay_card_setting, + fold_phase_delta, phase_from_voltages, phase_ramp_offsets, voltages_for_phase, @@ -109,6 +110,7 @@ def test_default_pvs_constants_and_positioner_type(laser_delay): assert config["laser_delay_steps_per_rf_period"]["value"] == 36 assert config["laser_delay_step_sleep"]["value"] == 0.0 assert config["laser_delay_phase_reference"]["value"] == "stored" + assert config["laser_delay_phase_path"]["value"] == "script" assert get_device_base_class(laser_delay) == "positioner" assert laser_delay.hints == {"fields": ["laser_delay"]} assert laser_delay.egu == "ns" @@ -172,6 +174,7 @@ def test_config_overrides_pvs_and_constants(): "kwargs", [ {"phase_reference": "both"}, + {"phase_path": "fast"}, {"steps_per_rf_period": 0}, {"steps_per_rf_period": 2.5}, {"rf_period": -2.0}, @@ -198,6 +201,10 @@ def test_runtime_constant_update_is_validated(calibrated): with pytest.raises(ValueError): calibrated.phase_reference.set("sometimes").wait(timeout=2) assert calibrated._constants().phase_reference == "stored" + with pytest.raises(ValueError): + calibrated.phase_path.set("fastest").wait(timeout=2) + calibrated.phase_path.set("shortest").wait(timeout=2) + assert calibrated._constants().phase_path == "shortest" def test_runtime_limits_are_validated(calibrated): @@ -707,6 +714,151 @@ def test_first_move_after_calibration_can_ramp_two_cavity_periods(laser_delay): laser_delay.set(target).wait(timeout=10) +# --------------------------------------------------------------------------------------------- # +# phase_path "shortest" # +# --------------------------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "delta, expected, wraps", + [ + (0.0, 0.0, 0), + (6.005, 6.005, 0), # +cavity/2 is inside + (-6.005, 6.005, -1), # -cavity/2 is not + (-6.0, -6.0, 0), + (6.006, 6.006 - 12.01, 1), + (16.13, 16.13 - 12.01, 1), + (11.51, 11.51 - 12.01, 1), + (-24.03, -24.03 + 2 * 12.01, -2), + (30.0, 30.0 - 2 * 12.01, 2), + ], +) +def test_fold_phase_delta(delta, expected, wraps): + folded, k = fold_phase_delta(delta, 12.01) + assert k == wraps and isinstance(k, int) + assert folded == pytest.approx(expected, abs=1e-12) + + +def test_fold_phase_delta_properties(): + rng = np.random.default_rng(7) + for delta in rng.uniform(-60, 60, 2000): + folded, k = fold_phase_delta(float(delta), 12.01) + assert -6.005 - 1e-9 < folded <= 6.005 + 1e-9 + assert folded == pytest.approx(delta - k * 12.01, abs=1e-12) + if -6.005 < delta <= 6.005: + assert folded == delta and k == 0 # unchanged, bit for bit + + +SHORTEST_TARGETS = [-5.0, 5.0, -5.0, 0.0, 0.5, 11.9, 12.2, -24.0, 37.25, -0.88, 400.0, -1500.3] + + +def run_targets(device: LaserDelay, targets: list) -> list[dict]: + """Plan and move to each target; return the plans with the final voltages and stored phase.""" + results = [] + for target in targets: + plan = device.plan_move(target) + device.set(target).wait(timeout=30) + plan["v1"], plan["v2"] = device.phase_v1.get(), device.phase_v2.get() + plan["stored"] = device.phase_t.get() + plan["mismatch"] = device.read_phase_mismatch() + plan["readback"] = device.readback.get() + results.append(plan) + return results + + +@pytest.mark.parametrize("rf_period, cavity_period", [(2.0, 12.01), (2.0014, 12.0084)]) +def test_shortest_path_ramps_at_most_half_a_cavity_period(rf_period, cavity_period): + """Same delay card as the script path, at most cavity/2 of phase per move (also the first move + after calibration), consistent bookkeeping, and the phase shifter ends where the script path + puts it, up to the documented wrap offset (0 when the cavity is a whole number of RF periods). + """ + kwargs = {"step_sleep": 0.0, "rf_period": rf_period, "cavity_period": cavity_period} + runs = {} + for path in ("script", "shortest"): + with patched_device(LaserDelay, name="laser_delay", phase_path=path, **kwargs) as device: + set_hardware(device) + device.calibrate(SCOPE) + runs[path] = run_targets(device, SHORTEST_TARGETS) + max_steps = math.ceil((cavity_period / 2) / (rf_period / 36)) + 1 + for script, short in zip(runs["script"], runs["shortest"]): + assert (short["delay_coarse"], short["delay_fine"]) == ( + script["delay_coarse"], + script["delay_fine"], + ) + assert short["phase_path"] == "shortest" and script["cavity_wraps"] == 0 + assert abs(short["phase_delta"]) <= cavity_period / 2 + 1e-9 + assert short["phase_steps"] <= max_steps + assert short["readback"] == short["target"] + assert short["mismatch"] == pytest.approx(0, abs=1e-9) + k = short["cavity_wraps"] + assert short["stored"] == pytest.approx(script["stored"] - k * cavity_period, abs=1e-9) + end_short = phase_from_voltages(short["v1"], short["v2"], rf_period) + end_script = phase_from_voltages(script["v1"], script["v2"], rf_period) + offset = wrap_phase(-k * cavity_period, rf_period) + assert short["wrap_offset_ns"] == pytest.approx(offset, abs=1e-12) + assert wrap_phase(end_short - end_script - offset, rf_period) == pytest.approx(0, abs=1e-9) + if cavity_period == 12.0084: + assert offset == pytest.approx(0, abs=1e-9) + else: + assert offset == pytest.approx(wrap_phase(-0.01 * k, 2.0), abs=1e-9) + first = runs["shortest"][0] + assert runs["script"][0]["phase_delta"] > cavity_period # script: long first move + assert first["phase_delta"] == pytest.approx( + runs["script"][0]["phase_delta"] - cavity_period, abs=1e-9 + ) + + +def test_shortest_path_is_the_script_path_when_no_fold_is_needed(): + """Moves that stay within half a cavity period write exactly what the script path writes.""" + targets = [1.5, 2.0, 3.0, 2.2, 7.0] + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as device: + set_hardware(device) + device.calibrate(SCOPE) + device.set(1.0).wait(timeout=10) + state = device.get_calibration() + hardware = { + "coarse": device.delay_coarse.get(), + "fine": device.delay_fine.get(), + "v1": device.phase_v1.get(), + "v2": device.phase_v2.get(), + } + log_script = record_puts(device) + for target in targets: + device.set(target).wait(timeout=10) + with patched_device( + LaserDelay, name="laser_delay", step_sleep=0.0, phase_path="shortest" + ) as device: + set_hardware(device, **hardware) + device.set_calibration(**state, current_delay=1.0) + log_short = record_puts(device) + for target in targets: + assert device.plan_move(target)["cavity_wraps"] == 0 + device.set(target).wait(timeout=10) + assert log_short == log_script + + +def test_stop_during_a_shortest_path_move_keeps_the_bookkeeping(laser_delay): + laser_delay.phase_path.put("shortest") + laser_delay.calibrate(SCOPE) + laser_delay.step_sleep.put(0.02) + log = record_puts(laser_delay) + plan = laser_delay.plan_move(-5.0) + assert plan["cavity_wraps"] == 1 and 0 < plan["phase_delta"] < 6.005 + status = laser_delay.set(-5.0) + assert wait_until(lambda: len(log) >= 2 + 2 * 5) + laser_delay.stop() + with pytest.raises(DeviceStoppedError): + status.wait(timeout=1) + assert wait_until(lambda: not laser_delay.moving) + assert laser_delay.read_phase_mismatch() == pytest.approx(0, abs=1e-9) + laser_delay.step_sleep.put(0.0) + laser_delay.set(-5.0).wait(timeout=10) + end = phase_from_voltages(laser_delay.phase_v1.get(), laser_delay.phase_v2.get(), 2.0) + expected = plan["phase_start"] + plan["phase_delta"] + assert wrap_phase(end - expected, 2.0) == pytest.approx(0, abs=1e-9) + assert laser_delay.phase_t.get() == pytest.approx(plan["phase_target"] - 12.01, abs=1e-9) + + @pytest.mark.parametrize("value", [float("nan"), float("inf"), "abc"]) def test_rejects_non_finite_targets(calibrated, value): with pytest.raises(ValueError): diff --git a/tests/tests_devices/test_laser_delay_golden.py b/tests/tests_devices/test_laser_delay_golden.py index 98c2881..68132f3 100644 --- a/tests/tests_devices/test_laser_delay_golden.py +++ b/tests/tests_devices/test_laser_delay_golden.py @@ -25,8 +25,10 @@ from ophyd_devices.tests.utils import patched_device from superxas_bec.devices.laser_delay import ( LaserDelay, delay_card_setting, + phase_from_voltages, phase_ramp_offsets, voltages_for_phase, + wrap_phase, ) REFERENCE_SCRIPT = Path(__file__).parent / "reference" / "DelayLaser.py.txt" @@ -182,9 +184,9 @@ def set_pv_values(device: LaserDelay, values: dict) -> None: signals[pvname]._read_pv.mock_data = value -def run_device(scenario: Scenario) -> dict: - """Run a scenario through the device.""" - with patched_device(LaserDelay, name="laser_delay", step_sleep=0.0) as device: +def run_device(scenario: Scenario, **device_kwargs) -> dict: + """Run a scenario through the device (default configuration unless ``device_kwargs``).""" + 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) @@ -279,6 +281,67 @@ def test_device_matches_reference_script(scenario_id, monkeypatch, tmp_path): 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.""" -- 2.54.0 From 1576990ba68f4874da09b4e72133b8f0db566a17 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 18:49:01 +0200 Subject: [PATCH 21/25] docs(devices): document the laser delay phase path, runtime settings and PV failures --- superxas_bec/devices/laser_delay.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index 2d85813..853a551 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -435,7 +435,8 @@ class LaserDelay(PSIDeviceBase, PositionerBase): doc="Stored (unwrapped) phase shifter delay in ns", ) - # Tunable constants (deviceConfig; runtime changes via set_device_config apply to the next move) + # Tunable constants (deviceConfig). Runtime changes (dev.laser_delay.step_sleep.set(0.05)) apply + # to the next move. rf_period = Cpt( CheckedSignal, value=2.0, kind=Kind.config, validator=_positive_number("rf_period") ) @@ -501,7 +502,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): self._phase_v1_pv = phase_v1_pv self._phase_v2_pv = phase_v2_pv self._move_lock = threading.RLock() # guards _move_status and _move_stop - self._hw_lock = threading.Lock() # held by the move task while it writes PVs + self._hw_lock = threading.Lock() # held by the move task and by calibrate/set_calibration self._move_stop = threading.Event() # one event per move, set to interrupt that move self._move_status: StatusBase | None = None super().__init__( -- 2.54.0 From 94c3f5aa2c3558c891c58f935754be302f05b12a Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 18:57:31 +0200 Subject: [PATCH 22/25] refactor(devices): name the laser delay phase plan and tidy the PV name attribute --- superxas_bec/devices/laser_delay.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index 853a551..08921c3 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -184,14 +184,14 @@ class _Constants: @dataclass(frozen=True) -class _PhasePlan: +class PhasePlan: """Phase shifter part of a move: ramp from ``start`` by ``delta``, then store ``stored_after``.""" target: float # the script's ForPhaseShifter, P start: float # read back from V1/V2 reference: float # what delta is measured from: stored phase or read-back delta: float # relative ramp, folded for phase_path "shortest" - cavity_wraps: int # whole cavity periods folded away (0 for phase_path "script") + cavity_wraps: int # k: stored_after = target - k * cavity_period (always 0 for "script") offsets: np.ndarray # intermediate ramp offsets, without the final step to delta stored_after: float # new phase_t: P, or P - cavity_wraps * cavity_period @@ -203,7 +203,7 @@ class _PhasePlan: def plan_phase_move( target_t: float, calibration: LaserDelayCalibration, constants: _Constants, start: float -) -> _PhasePlan: +) -> PhasePlan: """Phase shifter ramp for a move to delay ``target_t``, starting at read-back phase ``start``.""" phase_target = (-target_t) % constants.cavity_period - calibration.offset_phase_t reference = calibration.phase_t if constants.phase_reference == "stored" else start @@ -213,7 +213,7 @@ def plan_phase_move( delta, wraps = fold_phase_delta(delta, constants.cavity_period) offsets = phase_ramp_offsets(delta, constants.rf_period, constants.steps_per_rf_period) stored_after = phase_target - wraps * constants.cavity_period if wraps else phase_target - return _PhasePlan( + return PhasePlan( target=phase_target, start=start, reference=reference, @@ -274,6 +274,10 @@ class _ConstructorOnly: Writing the current value is a no-op; a new value needs a config reload. """ + def __init__(self): + self._key = "" + self._attr = "" + def __set_name__(self, owner, name): self._key = name self._attr = f"_{name}" -- 2.54.0 From 4630ef1c6bea7d89873579f40738666049f7949a Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 3 Oct 2026 19:27:24 +0200 Subject: [PATCH 23/25] docs(devices): pass relative= to scans.mv in the laser delay examples --- superxas_bec/devices/laser_delay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index 08921c3..391fbe4 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -364,7 +364,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): Usage (BEC client):: dev.laser_delay.calibrate(546.04 - 546.92) # scope: X-ray minus laser, after laser restart - scans.mv(dev.laser_delay, 1.2) + scans.mv(dev.laser_delay, 1.2, relative=False) scans.line_scan(dev.laser_delay, -5, 5, steps=21, exp_time=1, relative=False) A move writes the delay card, then ramps the phase shifter voltages (up to a few hundred steps -- 2.54.0 From 70c08e9c11b7f94dcf03c657cbb06361c7e5ba6f Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 8 Oct 2026 22:48:41 +0200 Subject: [PATCH 24/25] feat(devices): keep the laser delay calibration across restarts and verify it before moving --- .../device_configs/laser_delay_sim.yaml | 8 +- .../device_configs/x10da_laser_delay.yaml | 14 +- superxas_bec/devices/laser_delay.py | 1104 ++++++++++++++--- tests/tests_devices/test_laser_delay.py | 664 +++++++++- .../tests_devices/test_laser_delay_golden.py | 32 +- 5 files changed, 1610 insertions(+), 212 deletions(-) diff --git a/superxas_bec/device_configs/laser_delay_sim.yaml b/superxas_bec/device_configs/laser_delay_sim.yaml index 463bae2..1f2ebf0 100644 --- a/superxas_bec/device_configs/laser_delay_sim.yaml +++ b/superxas_bec/device_configs/laser_delay_sim.yaml @@ -4,8 +4,11 @@ # 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("/superxas_bec/device_configs/laser_delay_sim.yaml") -# Then: dev.laser_delay.calibrate(-0.88); scans.line_scan(dev.laser_delay, -5, 5, steps=21, exp_time=0.1, relative=False) -# step_sleep is shortened so that demo moves take seconds instead of tens of seconds. +# 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 @@ -13,6 +16,7 @@ laser_delay: deviceClass: superxas_bec.devices.laser_delay.SimLaserDelay deviceConfig: step_sleep: 0.01 + calibration_file: ~/.superxas_bec/laser_delay_sim_calibration.json deviceTags: - laser - timing diff --git a/superxas_bec/device_configs/x10da_laser_delay.yaml b/superxas_bec/device_configs/x10da_laser_delay.yaml index e464cd1..c1d9ec5 100644 --- a/superxas_bec/device_configs/x10da_laser_delay.yaml +++ b/superxas_bec/device_configs/x10da_laser_delay.yaml @@ -2,15 +2,18 @@ ## Laser delay (pump-probe) ## ################################### # Delay card X10DA-ES2-TIM:SET1 + RF phase shifter 2 (ES4) for the Duetto laser, external trigger. -# Usage and calibration: superxas_bec/devices/LASER_DELAY.md +# 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 every laser -# restart, or dev.laser_delay.set_calibration(...) to restore a previous one. +# 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 @@ -27,8 +30,9 @@ laser_delay: vmax: 1.0 steps_per_rf_period: 36 step_sleep: 0.1 - phase_reference: stored - phase_path: script # "shortest" is opt-in, see LASER_DELAY.md + 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 diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index 391fbe4..999e97c 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -10,10 +10,11 @@ How a delay ``t`` (X-ray minus laser, as read on the oscilloscope) is set: ``DELAY = int(t1 / 2)`` (units of 2 ns) and ``FINE = int((t1 mod 2) / 0.009999)`` (units of 0.01 ns). The divisor 0.009999 instead of 0.01 is taken over from the script on purpose: it stops values such as ``0.5 / 0.01 = 49.99999...`` from being truncated one unit low. + It can give ``FINE = 200``; by default (``fine_rollover``) that is written as ``DELAY + 1`` and + ``FINE = 0``, the same delay. * **Phase shifter (two voltages V1 = vmax cos(phi), V2 = vmax sin(phi)).** Target phase ``P = (-t mod cavity_period) - offset_phase_t``. The move is relative: ``dP = P - phase_t`` - where ``phase_t`` is the phase stored by the previous move (``phase_reference="stored"``, the - script's behaviour) or the phase read back from V1/V2 (``phase_reference="readback"``). The ramp + where ``phase_t`` is the phase stored by the previous move (the script's behaviour). The ramp always starts at the phase read back from V1/V2 and walks in steps of ``sign(dP) * rf_period / steps_per_rf_period`` with ``step_sleep`` seconds between steps; the last step lands exactly on ``readback + dP``. Afterwards ``P`` is stored as the new ``phase_t``. @@ -22,34 +23,39 @@ How a delay ``t`` (X-ray minus laser, as read on the oscilloscope) is set: ``(-cavity_period/2, +cavity_period/2]`` and stores ``P - k * cavity_period``. That is only the same physical delay if the laser timing repeats every cavity period *and* the cavity period is a whole number of RF periods: with ``rf_period=2.0`` and ``cavity_period=12.01`` each folded period - leaves the phase shifter 0.01 ns away from where the script would put it (see ``LASER_DELAY.md``). + leaves the phase shifter 0.01 ns away from where the script would put it. The stored phase is "unwrapped": the voltages only encode the phase modulo one RF period, while the stored value keeps track of how many RF periods the shifter has been turned. That is why the script -(and the default ``phase_reference="stored"``) computes ``dP`` from the stored value. If somebody -changes V1/V2 outside this device, stored and read-back phase no longer agree modulo the RF -period; the ``phase_mismatch`` signal shows the difference and a warning is logged at the start of -every move. +(and this device) computes ``dP`` from the stored value. If somebody changes V1/V2 outside this +device, stored and read-back phase no longer agree modulo the RF period; the ``phase_mismatch`` +signal shows the difference, and a move is refused when it exceeds one ramp step. Calibration (the script's ``CurrentSituation``, run after every laser restart) reads the EVR laser period, the delay card and the phase shifter and combines them with the oscilloscope reading ``s`` = X-ray minus laser: ``offset_t = -(2 DELAY + 0.01 FINE) - s`` and ``offset_phase_t = -phase_t - (s mod cavity_period)``. The script kept these numbers in a file -``Timing.offset``; here they are config signals on the device (see ``LASER_DELAY.md``). +``Timing.offset``; here they are config signals on the device, kept across BEC restarts in a JSON +file (``calibration_file``) and checked against the hardware when the device connects. The readback of the positioner is the last commanded delay. After ``calibrate(s)`` it is ``s``, -because the model maps the current settings to ``t = s``. It is NaN before calibration and after an -interrupted move. +because the model maps the current settings to ``t = s``. It is NaN before calibration, while a move +runs and after an interrupted move. """ from __future__ import annotations import contextlib +import datetime import functools +import inspect +import json import math +import os import threading import time from dataclasses import asdict, dataclass +from pathlib import Path from typing import TYPE_CHECKING, Callable import numpy as np @@ -60,9 +66,10 @@ from ophyd import FormattedComponent as FCpt from ophyd import Kind, Signal from ophyd.positioner import PositionerBase from ophyd.signal import InternalSignal, SignalRO -from ophyd.utils import InvalidState, LimitError +from ophyd.utils import InvalidState, LimitError, ReadOnlyError from ophyd_devices import DeviceStatus, PSIDeviceBase, StatusBase from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError +from ophyd_devices.utils.psi_device_base_utils import TaskKilledError if TYPE_CHECKING: # pragma: no cover from bec_lib.devicemanager import DeviceManagerBase, ScanInfo @@ -75,8 +82,16 @@ DELAY_CARD_COARSE_UNIT = 2.0 DELAY_CARD_FINE_UNIT = 0.01 #: Divisor used when *writing* the fine value. Kept from the script (see module docstring). DELAY_CARD_FINE_DIVISOR = 0.009999 +#: Fine units per coarse unit. The script can write FINE = 200 (see ``delay_card_setting``). +DELAY_CARD_FINE_PER_COARSE = 200 +#: Hard limit of the phase shifter voltages in V (ES4 analog outputs). +PHASE_VOLTAGE_LIMIT = 5.0 +#: A restored calibration older than this is reported with a warning (it is redone about weekly). +CALIBRATION_MAX_AGE_S = 7 * 24 * 3600 + +#: Fewest phase ramp steps per RF period (the script's 36: a step of 10 degrees). +MIN_STEPS_PER_RF_PERIOD = 36 -PHASE_REFERENCES = ("stored", "readback") PHASE_PATHS = ("script", "shortest") @@ -88,17 +103,33 @@ class LaserDelayBusyError(RuntimeError): """Raised when an action needs the device idle while a move is still running.""" +class LaserDelayHardwareError(RuntimeError): + """Raised when the hardware is not in the state a move or calibration needs.""" + + +class LaserDelayPersistenceError(RuntimeError): + """Raised by calibrate/set_calibration when the calibration could not be saved.""" + + # --------------------------------------------------------------------------------------------- # # Maths, kept as plain functions so that they can be checked against the reference script. # # --------------------------------------------------------------------------------------------- # -def delay_card_setting(total_t: float, laser_period: float) -> tuple[int, int]: - """Coarse and fine delay card values for a total delay, exactly as the script's MoveDelayCard. +def delay_card_setting( + total_t: float, laser_period: float, fine_rollover: bool = False +) -> tuple[int, int]: + """Coarse and fine delay card values for a total delay, as the script's MoveDelayCard. + + The script computes ``FINE = int((t1 mod 2) / 0.009999)``, which is 200 (2.00 ns) when + ``t1 mod 2 >= 1.9998``. With ``fine_rollover`` such a value is written as the next coarse unit + and ``FINE = 0`` instead (the same delay, and FINE stays within 0..199). Without it the values + are the script's, bit for bit. Args: total_t (float): ``-offset_t - t`` in ns. laser_period (float): laser period in ns. + fine_rollover (bool): carry ``FINE = 200`` into the coarse value. Returns: tuple[int, int]: ``(DELAY, FINE)``, in units of 2 ns and 0.01 ns. @@ -106,6 +137,10 @@ def delay_card_setting(total_t: float, laser_period: float) -> tuple[int, int]: t1 = total_t % laser_period coarse = int(t1 / DELAY_CARD_COARSE_UNIT) fine = int((t1 % DELAY_CARD_COARSE_UNIT) / DELAY_CARD_FINE_DIVISOR) + if fine_rollover and fine >= DELAY_CARD_FINE_PER_COARSE: + coarse, fine = coarse + 1, fine - DELAY_CARD_FINE_PER_COARSE + if (coarse * DELAY_CARD_COARSE_UNIT) >= laser_period: + coarse = 0 # one full laser period later is the same delay return coarse, fine @@ -171,6 +206,18 @@ class LaserDelayCalibration: """Plain dictionary (RPC friendly).""" return {key: float(value) for key, value in asdict(self).items()} + def is_valid(self) -> bool: + """All four numbers finite and a positive laser period.""" + values = self.as_dict().values() + return all(math.isfinite(v) for v in values) and self.laser_period > 0 + + +_EMPTY_CALIBRATION = LaserDelayCalibration(math.nan, math.nan, math.nan, math.nan) + + +def _utc_now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + @dataclass(frozen=True) class _Constants: @@ -179,8 +226,13 @@ class _Constants: vmax: float steps_per_rf_period: int step_sleep: float - phase_reference: str phase_path: str + fine_rollover: bool + + @property + def step_ns(self) -> float: + """Size of one phase ramp step in ns.""" + return self.rf_period / self.steps_per_rf_period @dataclass(frozen=True) @@ -189,7 +241,7 @@ class PhasePlan: target: float # the script's ForPhaseShifter, P start: float # read back from V1/V2 - reference: float # what delta is measured from: stored phase or read-back + reference: float # what delta is measured from: the stored phase phase_t delta: float # relative ramp, folded for phase_path "shortest" cavity_wraps: int # k: stored_after = target - k * cavity_period (always 0 for "script") offsets: np.ndarray # intermediate ramp offsets, without the final step to delta @@ -204,9 +256,12 @@ class PhasePlan: def plan_phase_move( target_t: float, calibration: LaserDelayCalibration, constants: _Constants, start: float ) -> PhasePlan: - """Phase shifter ramp for a move to delay ``target_t``, starting at read-back phase ``start``.""" + """Phase shifter ramp for a move to delay ``target_t``, starting at read-back phase ``start``. + + The relative move is computed from the stored (unwrapped) phase, as in the script. + """ phase_target = (-target_t) % constants.cavity_period - calibration.offset_phase_t - reference = calibration.phase_t if constants.phase_reference == "stored" else start + reference = calibration.phase_t delta = phase_target - reference wraps = 0 if constants.phase_path == "shortest": @@ -233,21 +288,69 @@ class CheckedSignal(Signal): """Soft signal that validates every write, used for tunable constants and the soft limits. Writes come from the constructor, from runtime changes in the client - (``dev.laser_delay.step_sleep.set(0.05).wait()``, ``dev.laser_delay.limits = [-20, 20]``) and + (``dev.laser_delay.step_sleep.set(0.15).wait()``, ``dev.laser_delay.limits = [-20, 20]``) and from runtime ``deviceConfig`` updates, so a bad value is rejected instead of being used in the next move. + + ``validator`` is a callable taking the value, or the name of a method of the parent device + taking the value (for checks that depend on the device class). """ - def __init__(self, *args, validator: Callable[[object], None] | None = None, **kwargs): + def __init__(self, *args, validator: Callable[[object], None] | str | None = None, **kwargs): self._validator = validator super().__init__(*args, **kwargs) def check_value(self, value): super().check_value(value) - if self._validator is not None: + if isinstance(self._validator, str): + getattr(self.parent, self._validator)(value) + elif self._validator is not None: self._validator(value) +class _DeviceWritesOnly: # pylint: disable=no-member + """Mixin for the hardware signals: only the parent device's own code may write them. + + A move ramps the phase shifter and keeps the stored phase in step with it; a direct write + (``dev.laser_delay.phase_v1.set(...)`` from a client) would bypass the ramp, the voltage limit + and the bookkeeping. The parent allows writes per thread (``LaserDelay._hardware_writes``). + Values are also range-checked (``value_range``) in every case. + """ + + def __init__(self, *args, value_range: tuple[float, float] | None = None, **kwargs): + self._value_limits = value_range + super().__init__(*args, **kwargs) + + def check_value(self, value): + super().check_value(value) + if self._value_limits is not None: + low, high = self._value_limits + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{self.name}: {value!r} is not a number") from exc + if not low <= number <= high: + raise ValueError(f"{self.name}: {number} is outside [{low}, {high}]") + + def put(self, value, *args, **kwargs): + parent = self.parent + if parent is not None and not parent.hardware_writes_allowed(): + raise ReadOnlyError( + f"{self.name} is written only by {parent.name} itself; move with " + f"umv(dev.{parent.name}, ) or a scan instead of writing it directly." + ) + self.check_value(value) + super().put(value, *args, **kwargs) + + +class _HardwareEpicsSignal(_DeviceWritesOnly, EpicsSignal): + """EPICS signal of the delay card or the phase shifter (written only by the device).""" + + +class _HardwareSoftSignal(_DeviceWritesOnly, CheckedSignal): + """Soft stand-in for a hardware PV in the simulation (written only by the device).""" + + class ComputedPhaseSignal(SignalRO): """Read-only signal whose value is computed by the parent device on every read. @@ -266,7 +369,7 @@ class ComputedPhaseSignal(SignalRO): class _ConstructorOnly: - """Read-only attribute for a constructor-only ``deviceConfig`` key (the PV names). + """Read-only attribute for a constructor-only ``deviceConfig`` key (PV names, storage). When a runtime ``deviceConfig`` update fails, BEC's device server re-applies the whole previous ``deviceConfig`` with ``setattr``/``Signal.set`` per key. Without an attribute of that name the @@ -319,40 +422,81 @@ def _positive_number(name: str) -> Callable[[object], None]: return check -def _non_negative_number(name: str) -> Callable[[object], None]: +def _phase_voltage_amplitude(value): + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"vmax must be a number, got {value!r}") from exc + if not math.isfinite(number) or not 0 < number <= PHASE_VOLTAGE_LIMIT: + raise ValueError( + f"vmax must be above 0 and at most the phase shifter hard limit of " + f"{PHASE_VOLTAGE_LIMIT} V, got {value!r}" + ) + + +def _steps_per_rf_period(value): + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"steps_per_rf_period must be an integer, got {value!r}") from exc + if isinstance(value, bool) or not number.is_integer() or number < MIN_STEPS_PER_RF_PERIOD: + raise ValueError( + f"steps_per_rf_period must be an integer of at least {MIN_STEPS_PER_RF_PERIOD} (a " + f"phase step of at most 1/{MIN_STEPS_PER_RF_PERIOD} of the RF period), got {value!r}" + ) + + +def _boolean(name: str) -> 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 math.isfinite(number) or number < 0: - raise ValueError(f"{name} must be a non-negative finite number, got {value!r}") + if not isinstance(value, (bool, np.bool_)): + raise ValueError(f"{name} must be True or False, got {value!r}") return check -def _positive_integer(name: str) -> Callable[[object], None]: - def check(value): - try: - number = float(value) - except (TypeError, ValueError) as exc: - raise ValueError(f"{name} must be an integer, got {value!r}") from exc - if isinstance(value, bool) or not number.is_integer() or number < 1: - raise ValueError(f"{name} must be a positive integer, got {value!r}") - - return check - - -def _phase_reference(value): - if value not in PHASE_REFERENCES: - raise ValueError(f"phase_reference must be one of {PHASE_REFERENCES}, got {value!r}") - - def _phase_path(value): if value not in PHASE_PATHS: raise ValueError(f"phase_path must be one of {PHASE_PATHS}, got {value!r}") +# --------------------------------------------------------------------------------------------- # +# Calibration storage (kept across BEC restarts) # +# --------------------------------------------------------------------------------------------- # + + +class _FileCalibrationStore: + """Calibration kept in a JSON file on the device server host (like ``Timing.offset``).""" + + def __init__(self, path: str): + self.path = Path(os.path.expanduser(path)) + + def __str__(self) -> str: + return str(self.path) + + def load(self) -> dict | None: + """Saved content, or None if there is no file.""" + if not self.path.exists(): + return None + return json.loads(self.path.read_text(encoding="utf-8")) + + def save(self, content: dict) -> None: + """Write atomically (temporary file in the same directory, fsync, rename).""" + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_name(f".{self.path.name}.{os.getpid()}.{threading.get_ident()}.tmp") + try: + with open(tmp, "w", encoding="utf-8") as handle: + handle.write(json.dumps(content, indent=2)) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp, self.path) + finally: + if tmp.exists(): + tmp.unlink() + + def destroy(self) -> None: + """Nothing to release.""" + + # --------------------------------------------------------------------------------------------- # # Device # # --------------------------------------------------------------------------------------------- # @@ -371,6 +515,11 @@ class LaserDelay(PSIDeviceBase, PositionerBase): of ``step_sleep`` seconds). It runs in a background task; ``stop()`` interrupts the ramp between two steps and leaves the voltages at the last written step. + Safety checks: the delay card and the phase shifter are written only by the device; voltages + above 5 V are never written; a move is refused when the phase shifter amplitude or phase does + not match the device's bookkeeping, or when a restored calibration could not be verified; every + write is read back. + Args: name (str): device name. prefix (str): unused, kept for the common device signature. PV names are given in full. @@ -381,17 +530,34 @@ class LaserDelay(PSIDeviceBase, PositionerBase): phase_v2_pv (str): phase shifter voltage V2 (sin) PV. rf_period (float): RF period in ns (script ``RFPeriod``). cavity_period (float): laser cavity period in ns (script ``CavityPeriod``, Duetto). - vmax (float): phase shifter voltage amplitude (script ``Vmax``). - steps_per_rf_period (int): phase ramp steps per RF period (script ``NumStepsPerPeriod``). - step_sleep (float): seconds between two phase ramp steps. - phase_reference (str): ``"stored"`` (script behaviour) or ``"readback"``; what the relative - phase move is computed from, see module docstring. + vmax (float): phase shifter voltage amplitude (script ``Vmax``), at most 5 V. + steps_per_rf_period (int): phase ramp steps per RF period (script ``NumStepsPerPeriod``), + at least 36. + step_sleep (float): seconds between two phase ramp steps, at least ``MIN_STEP_SLEEP``. phase_path (str): ``"script"`` (default, script behaviour) or ``"shortest"``: fold the relative phase move into half a cavity period either way. Opt-in, see module docstring. + fine_rollover (bool): write a fine value of 200 as the next coarse unit and 0 (default). + False writes 200 like the script. + calibration_file (str | None): JSON file on the device server host that keeps the + calibration across BEC restarts. None: the calibration is lost at every restart. limits (list[float] | None): optional soft limits ``[low, high]`` in ns. """ - USER_ACCESS = ["calibrate", "set_calibration", "get_calibration", "plan_move"] + USER_ACCESS = [ + "calibrate", + "set_calibration", + "get_calibration", + "get_calibration_info", + "plan_move", + ] + + #: Shortest allowed time between two phase ramp steps in s: the value the scientist tested. A + #: faster ramp needs their OK and a change here (the simulation overrides it). + MIN_STEP_SLEEP = 0.1 + #: A move or calibration is refused when hypot(V1, V2) differs from vmax by more than this. + AMPLITUDE_TOLERANCE = 0.1 + #: Read-back tolerance for the written phase shifter voltages, in V. + VOLTAGE_READBACK_TOLERANCE = 1e-3 # Readback: last commanded delay (named like the device, hinted) readback = Cpt( @@ -399,18 +565,38 @@ class LaserDelay(PSIDeviceBase, PositionerBase): ) motor_is_moving = Cpt(InternalSignal, value=0, kind=Kind.omitted, doc="1 while a move runs") - # Hardware + # Hardware (written only by the device itself) delay_coarse = FCpt( - EpicsSignal, "{self._delay_coarse_pv}", kind=Kind.normal, doc="Delay card, units of 2 ns" + _HardwareEpicsSignal, + "{self._delay_coarse_pv}", + kind=Kind.normal, + value_range=(0, 65535), + doc="Delay card, units of 2 ns", ) delay_fine = FCpt( - EpicsSignal, "{self._delay_fine_pv}", kind=Kind.normal, doc="Delay card, units of 0.01 ns" + _HardwareEpicsSignal, + "{self._delay_fine_pv}", + kind=Kind.normal, + value_range=(0, DELAY_CARD_FINE_PER_COARSE), + doc="Delay card, units of 0.01 ns", ) evr_period = FCpt( EpicsSignalRO, "{self._evr_period_pv}", kind=Kind.config, doc="EVR laser period in µs" ) - phase_v1 = FCpt(EpicsSignal, "{self._phase_v1_pv}", kind=Kind.normal, doc="Phase shifter V1") - phase_v2 = FCpt(EpicsSignal, "{self._phase_v2_pv}", kind=Kind.normal, doc="Phase shifter V2") + phase_v1 = FCpt( + _HardwareEpicsSignal, + "{self._phase_v1_pv}", + kind=Kind.normal, + value_range=(-PHASE_VOLTAGE_LIMIT, PHASE_VOLTAGE_LIMIT), + doc="Phase shifter V1", + ) + phase_v2 = FCpt( + _HardwareEpicsSignal, + "{self._phase_v2_pv}", + kind=Kind.normal, + value_range=(-PHASE_VOLTAGE_LIMIT, PHASE_VOLTAGE_LIMIT), + doc="Phase shifter V2", + ) # Derived from the hardware on every read phase_readback = Cpt( @@ -439,7 +625,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): doc="Stored (unwrapped) phase shifter delay in ns", ) - # Tunable constants (deviceConfig). Runtime changes (dev.laser_delay.step_sleep.set(0.05)) apply + # Tunable constants (deviceConfig). Runtime changes (dev.laser_delay.step_sleep.set(0.15)) apply # to the next move. rf_period = Cpt( CheckedSignal, value=2.0, kind=Kind.config, validator=_positive_number("rf_period") @@ -447,20 +633,15 @@ class LaserDelay(PSIDeviceBase, PositionerBase): cavity_period = Cpt( CheckedSignal, value=12.01, kind=Kind.config, validator=_positive_number("cavity_period") ) - vmax = Cpt(CheckedSignal, value=1.0, kind=Kind.config, validator=_positive_number("vmax")) + vmax = Cpt(CheckedSignal, value=1.0, kind=Kind.config, validator=_phase_voltage_amplitude) steps_per_rf_period = Cpt( - CheckedSignal, - value=36, - kind=Kind.config, - validator=_positive_integer("steps_per_rf_period"), - ) - step_sleep = Cpt( - CheckedSignal, value=0.1, kind=Kind.config, validator=_non_negative_number("step_sleep") - ) - phase_reference = Cpt( - CheckedSignal, value="stored", kind=Kind.config, validator=_phase_reference + CheckedSignal, value=36, kind=Kind.config, validator=_steps_per_rf_period ) + step_sleep = Cpt(CheckedSignal, value=0.1, kind=Kind.config, validator="_check_step_sleep") phase_path = Cpt(CheckedSignal, value="script", kind=Kind.config, validator=_phase_path) + fine_rollover = Cpt( + CheckedSignal, value=True, kind=Kind.config, validator=_boolean("fine_rollover") + ) # Soft limits (BEC publishes them as the device limits). Equal values mean no limits. low_limit_travel = Cpt( @@ -470,12 +651,13 @@ class LaserDelay(PSIDeviceBase, PositionerBase): CheckedSignal, value=0.0, kind=Kind.omitted, validator=_finite_number("high limit") ) - # PV names: constructor-only deviceConfig keys, see _ConstructorOnly + # Constructor-only deviceConfig keys, see _ConstructorOnly delay_coarse_pv = _ConstructorOnly() delay_fine_pv = _ConstructorOnly() evr_period_pv = _ConstructorOnly() phase_v1_pv = _ConstructorOnly() phase_v2_pv = _ConstructorOnly() + calibration_file = _ConstructorOnly() # pylint: disable=too-many-arguments,too-many-locals def __init__( @@ -493,8 +675,9 @@ class LaserDelay(PSIDeviceBase, PositionerBase): vmax: float = 1.0, steps_per_rf_period: int = 36, step_sleep: float = 0.1, - phase_reference: str = "stored", phase_path: str = "script", + fine_rollover: bool = True, + calibration_file: str | None = None, limits: list[float] | tuple[float, float] | None = None, scan_info: ScanInfo | None = None, device_manager: DeviceManagerBase | None = None, @@ -505,23 +688,30 @@ class LaserDelay(PSIDeviceBase, PositionerBase): self._evr_period_pv = evr_period_pv self._phase_v1_pv = phase_v1_pv self._phase_v2_pv = phase_v2_pv + self._calibration_file = calibration_file self._move_lock = threading.RLock() # guards _move_status and _move_stop self._hw_lock = threading.Lock() # held by the move task and by calibrate/set_calibration + self._hw_permit = threading.local() # per-thread permission to write the hardware signals self._move_stop = threading.Event() # one event per move, set to interrupt that move self._move_status: StatusBase | None = None + self._calibration_meta: dict = {} # calibrated_at, scope value of the last calibration + self._unverified: list[str] = [] # reasons the calibration may not match the hardware super().__init__( prefix=prefix, name=name, scan_info=scan_info, device_manager=device_manager, **kwargs ) self.readback.name = self.name self._position = math.nan + self._store: _FileCalibrationStore | None = None + if calibration_file: + self._store = _FileCalibrationStore(calibration_file) # Constants: validated by CheckedSignal.check_value, a bad value fails here. self.rf_period.put(float(rf_period)) self.cavity_period.put(float(cavity_period)) self.vmax.put(float(vmax)) self.steps_per_rf_period.put(steps_per_rf_period) self.step_sleep.put(float(step_sleep)) - self.phase_reference.put(phase_reference) self.phase_path.put(phase_path) + self.fine_rollover.put(fine_rollover) if limits is not None: if len(limits) != 2: raise ValueError(f"limits must be [low, high], got {limits!r}") @@ -574,17 +764,28 @@ class LaserDelay(PSIDeviceBase, PositionerBase): """Move to a delay ``position`` (ns). Returns a status that finishes when the ramp is done. Raises: - LaserDelayNotCalibratedError: if the device was never calibrated. - LaserDelayBusyError: if a previous move is still running. + LaserDelayNotCalibratedError: if the device is not calibrated, or a restored + calibration could not be verified. + + A move requested while another one runs is not raised: the returned status is already + failed with ``LaserDelayBusyError`` and the running move goes on. BEC's device server turns + an exception from ``set()`` into a status whose failure calls ``stop()`` (ophyd + ``DeviceStatus._handle_failure``), which would end the move that is running. """ - target = float(position) - self.check_value(target) - self._require_calibration() # refuse early; the task takes its own snapshot later with self._move_lock: if self._move_status is not None and not self._move_status.done: - raise LaserDelayBusyError( - f"{self.name} is still moving; wait for the move to finish or stop it first." + status = self._refused( + LaserDelayBusyError( + f"{self.name} is still moving; wait for the move to finish or stop it first." + ), + position, ) + if wait: + status.wait(timeout=timeout) + return status + self.check_value(position) + target = float(position) + self._require_calibration() # refuse early; the task takes its own snapshot later stop_event = threading.Event() status = DeviceStatus( self, timeout=timeout, description=f"{self.name}: move to {target} ns" @@ -601,6 +802,73 @@ class LaserDelay(PSIDeviceBase, PositionerBase): status.wait(timeout=timeout) return status + ######################################## + # Beamline Specific Implementations # + ######################################## + + def on_init(self) -> None: + """ + Called when the device is initialized. + + No signals are connected at this point. If you like to + set default values on signals, please use on_connected instead. + """ + + def on_connected(self) -> None: + """ + Called after the device is connected and its signals are connected. + Default values for signals should be set here. + """ + # Restore the calibration from the store (the file), see _load_calibration. Never + # raises: the device connects uncalibrated if the restore fails. + try: + self._load_calibration() + except Exception as exc: # pylint: disable=broad-except + logger.error( + f"{self.name}: could not restore the calibration from {self._store}: {exc}. " + f"Calibrate again with dev.{self.name}.calibrate()." + ) + + def on_stage(self) -> DeviceStatus | StatusBase | None: + """ + Called while staging the device. + + Information about the upcoming scan can be accessed from the scan_info (self.scan_info.msg) object. + """ + + def on_unstage(self) -> DeviceStatus | StatusBase | None: + """Called while unstaging the device.""" + + def on_pre_scan(self) -> DeviceStatus | StatusBase | None: + """Called right before the scan starts on all devices automatically.""" + + def on_trigger(self) -> DeviceStatus | StatusBase | None: + """Called when the device is triggered.""" + + def on_complete(self) -> DeviceStatus | StatusBase | None: + """Called to inquire if a device has completed a scans.""" + + def on_kickoff(self) -> DeviceStatus | StatusBase | None: + """Called to kickoff a device for a fly scan. Has to be called explicitly.""" + + def on_stop(self) -> None: + """Called when the device is stopped.""" + # Interrupt a running phase ramp between two steps and fail its status. + self._interrupt_move(DeviceStoppedError(f"Device {self.name} has been stopped")) + + def on_destroy(self) -> None: + """Called when the device is destroyed. Cleanup resources here.""" + # Fail the move status before the base class kills the task: the kill can land anywhere + # in the task, including after the ramp but before the status is resolved, so whoever + # waits on it (a scan, umv) is released here. + self._interrupt_move(DeviceStoppedError(f"Device {self.name} has been destroyed")) + if self._store is not None: + self._store.destroy() + + ######################################## + # Helper Methods # + ######################################## + # ----------------------------------------------------------------------------------------- # # User access # # ----------------------------------------------------------------------------------------- # @@ -617,6 +885,9 @@ class LaserDelay(PSIDeviceBase, PositionerBase): Returns: dict: the new calibration (``laser_period``, ``offset_t``, ``offset_phase_t``, ``phase_t``). + + Raises: + LaserDelayPersistenceError: the calibration was applied but could not be saved. """ s = float(scope_xray_minus_laser) if not math.isfinite(s): @@ -624,7 +895,9 @@ class LaserDelay(PSIDeviceBase, PositionerBase): f"{self.name}: scope reading must be finite, got {scope_xray_minus_laser!r}" ) with self._exclusive_hardware("calibrate"): - return self._calibrate(s) + result = self._calibrate(s) + self._save_calibration(raise_on_error=True) + return result def _calibrate(self, s: float) -> dict[str, float]: constants = self._constants() @@ -640,21 +913,21 @@ class LaserDelay(PSIDeviceBase, PositionerBase): delay_card_t = delay_card_time(dt1, dt2) v1 = self.phase_v1.get() v2 = self.phase_v2.get() + problem = self._amplitude_problem(v1, v2, constants) + if problem: + raise LaserDelayHardwareError(f"{self.name}: cannot calibrate: {problem}") phase_t = phase_from_voltages(v1, v2, constants.rf_period) ph = s % constants.cavity_period offset_phase_t = -phase_t - ph offset_t = -delay_card_t - s - if math.hypot(v1, v2) < 0.5 * constants.vmax: - logger.warning( - f"{self.name}: phase shifter amplitude {math.hypot(v1, v2):.3f} V is far below " - f"vmax={constants.vmax}; the read-back phase may be meaningless." - ) calibration = LaserDelayCalibration( laser_period=float(laser_period), offset_t=float(offset_t), offset_phase_t=float(offset_phase_t), phase_t=float(phase_t), ) + self._calibration_meta = {"calibrated_at": _utc_now(), "scope_xray_minus_laser": s} + self._unverified = [] self._store_calibration(calibration, current_delay=s) logger.info( f"{self.name}: calibrated with scope X-ray minus laser = {s} ns: " @@ -670,13 +943,13 @@ class LaserDelay(PSIDeviceBase, PositionerBase): phase_t: float, current_delay: float | None = None, ) -> dict[str, float]: - """Set the calibration directly, e.g. after a BEC restart, from a previous calibration. + """Set the calibration directly, e.g. from the script's ``Timing.offset``. The argument order is the order of the script's ``Timing.offset`` file, so ``set_calibration(*[float(x) for x in numpy.loadtxt("Timing.offset")])`` takes over the script's state. ``phase_t`` must be the stored phase of the last move (``get_calibration()`` after that move), not a fresh read-back: the voltages only know the phase modulo one RF - period. + period. The next move still checks that the phase shifter matches ``phase_t``. Args: laser_period (float): laser period in ns. @@ -685,6 +958,9 @@ class LaserDelay(PSIDeviceBase, PositionerBase): phase_t (float): stored phase shifter delay in ns. current_delay (float | None): the delay the hardware is at, shown as readback. NaN if None. + + Raises: + LaserDelayPersistenceError: the calibration was applied but could not be saved. """ values = { "laser_period": laser_period, @@ -700,18 +976,45 @@ class LaserDelay(PSIDeviceBase, PositionerBase): calibration = LaserDelayCalibration(**{key: float(val) for key, val in values.items()}) delay = math.nan if current_delay is None else float(current_delay) with self._exclusive_hardware("set_calibration"): + self._calibration_meta = {"calibrated_at": _utc_now(), "scope_xray_minus_laser": None} + self._unverified = [] self._store_calibration(calibration, current_delay=delay) + self._save_calibration(raise_on_error=True) logger.info(f"{self.name}: calibration set to {calibration.as_dict()}") return calibration.as_dict() def get_calibration(self) -> dict[str, float]: """Current calibration (NaN values when not calibrated).""" - return LaserDelayCalibration( - laser_period=float(self.laser_period.get()), - offset_t=float(self.offset_t.get()), - offset_phase_t=float(self.offset_phase_t.get()), - phase_t=float(self.phase_t.get()), - ).as_dict() + return self._calibration().as_dict() + + def get_calibration_info(self) -> dict: + """Calibration with its origin and state. + + Returns: + dict: ``calibration`` (as ``get_calibration``), ``current_delay``, ``calibrated_at`` + (UTC, ISO 8601), ``age_days``, ``scope_xray_minus_laser`` (None after + ``set_calibration``), ``verified`` (False after a restore that could not be checked + against the hardware: moves are refused), ``unverified_reasons`` and ``storage`` (the + file that keeps it across restarts, None if not kept). + """ + calibrated_at = self._calibration_meta.get("calibrated_at") + age_days = None + if calibrated_at: + try: + age = time.time() - datetime.datetime.fromisoformat(calibrated_at).timestamp() + age_days = round(age / 86400, 2) + except ValueError: + age_days = None + return { + "calibration": self.get_calibration(), + "current_delay": float(self.readback.get()), + "calibrated_at": calibrated_at, + "age_days": age_days, + "scope_xray_minus_laser": self._calibration_meta.get("scope_xray_minus_laser"), + "verified": self._calibration().is_valid() and not self._unverified, + "unverified_reasons": list(self._unverified), + "storage": str(self._store) if self._store is not None else None, + } def plan_move(self, position: float) -> dict: """Dry run of a move: what would be written, without writing anything. @@ -726,16 +1029,21 @@ class LaserDelay(PSIDeviceBase, PositionerBase): always 0 for ``phase_path="script"``, counts the folds since the calibration for ``"shortest"``), ``wrap_offset_ns`` (``-k * cavity_period`` modulo the RF period: how far the phase shifter ends from where the script would put it; 0.01 ns per fold with - the script's 2.0/12.01 ns), ``phase_steps`` (puts of V1/V2 incl. the last one) and - ``estimated_duration_s``. + the script's 2.0/12.01 ns), ``phase_steps`` (puts of V1/V2 incl. the last one), + ``estimated_duration_s`` and ``refused`` (why the move would be refused, or None). """ target = float(position) self.check_value(target) calibration = self._require_calibration() constants = self._constants() - coarse, fine = delay_card_setting(-calibration.offset_t - target, calibration.laser_period) - plan = plan_phase_move( - target, calibration, constants, start=self._read_phase(constants.rf_period) + coarse, fine = delay_card_setting( + -calibration.offset_t - target, calibration.laser_period, constants.fine_rollover + ) + v1, v2 = self.phase_v1.get(), self.phase_v2.get() + start = float(phase_from_voltages(v1, v2, constants.rf_period)) + plan = plan_phase_move(target, calibration, constants, start=start) + refused = self._amplitude_problem(v1, v2, constants) or self._phase_problem( + calibration, start, constants ) return { "target": target, @@ -749,6 +1057,7 @@ class LaserDelay(PSIDeviceBase, PositionerBase): "wrap_offset_ns": self._wrap_offset(plan.cavity_wraps, constants), "phase_steps": plan.n_puts, "estimated_duration_s": plan.n_puts * constants.step_sleep, + "refused": refused, } @staticmethod @@ -780,21 +1089,66 @@ class LaserDelay(PSIDeviceBase, PositionerBase): return float(wrap_phase(stored - self.read_phase(), rf_period)) # ----------------------------------------------------------------------------------------- # - # PSIDeviceBase hooks # + # Hardware write permission # # ----------------------------------------------------------------------------------------- # - def on_stop(self) -> None: - """Interrupt a running phase ramp between two steps and fail its status.""" - self._interrupt_move(DeviceStoppedError(f"Device {self.name} has been stopped")) + def hardware_writes_allowed(self) -> bool: + """True in a thread that is inside ``_hardware_writes`` (the device's own code).""" + return getattr(self._hw_permit, "depth", 0) > 0 - def on_destroy(self) -> None: - """Interrupt a running move and fail its status before the base class kills the task. + @contextlib.contextmanager + def _hardware_writes(self): + """Allow this thread to write the delay card and phase shifter signals.""" + self._hw_permit.depth = getattr(self._hw_permit, "depth", 0) + 1 + try: + yield + finally: + self._hw_permit.depth -= 1 - The base class shuts the task handler down by raising an exception in the task thread. That - can land anywhere in the task, including after the ramp but before the status is resolved, - so the status is failed here first; whoever waits on it (a scan, ``umv``) is released. - """ - self._interrupt_move(DeviceStoppedError(f"Device {self.name} has been destroyed")) + # ----------------------------------------------------------------------------------------- # + # Validators # + # ----------------------------------------------------------------------------------------- # + + def _check_step_sleep(self, value) -> None: + minimum = type(self).MIN_STEP_SLEEP + try: + number = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"step_sleep must be a number, got {value!r}") from exc + if not math.isfinite(number) or number < minimum: + raise ValueError(f"step_sleep must be at least {minimum} s, got {value!r}") + + def _amplitude_problem(self, v1: float, v2: float, constants: _Constants) -> str | None: + """Why the phase shifter amplitude does not allow a ramp, or None.""" + amplitude = math.hypot(v1, v2) + if abs(amplitude - constants.vmax) > self.AMPLITUDE_TOLERANCE * constants.vmax: + return ( + f"phase shifter amplitude hypot(V1, V2) = {amplitude:.3f} V but vmax is " + f"{constants.vmax} V (ES4 IOC rebooted, voltages changed by hand, or vmax " + f"changed?). The first ramp step would jump the voltages. Restore V1/V2 to " + f"amplitude {constants.vmax} V outside BEC, or set vmax to the amplitude in use, " + f"then calibrate again." + ) + return None + + @staticmethod + def _phase_problem( + calibration: LaserDelayCalibration, start: float, constants: _Constants + ) -> str | None: + """Why the phase shifter does not match the stored phase, or None.""" + mismatch = wrap_phase(calibration.phase_t - start, constants.rf_period) + if abs(mismatch) > constants.step_ns: + return ( + f"the phase shifter is {mismatch:+.4f} ns (modulo the RF period) away from the " + f"stored phase, more than one ramp step ({constants.step_ns:.4f} ns). It was " + f"turned outside BEC, or a move was interrupted without bookkeeping. Calibrate " + f"again with the scope." + ) + return None + + # ----------------------------------------------------------------------------------------- # + # Internals # + # ----------------------------------------------------------------------------------------- # def _interrupt_move(self, exc: Exception) -> None: with self._move_lock: @@ -803,10 +1157,6 @@ class LaserDelay(PSIDeviceBase, PositionerBase): if status is not None: self._finish_status(status, exc) - # ----------------------------------------------------------------------------------------- # - # Internals # - # ----------------------------------------------------------------------------------------- # - def _constants(self) -> _Constants: return _Constants( rf_period=float(self.rf_period.get()), @@ -814,8 +1164,8 @@ class LaserDelay(PSIDeviceBase, PositionerBase): vmax=float(self.vmax.get()), steps_per_rf_period=int(self.steps_per_rf_period.get()), step_sleep=float(self.step_sleep.get()), - phase_reference=str(self.phase_reference.get()), phase_path=str(self.phase_path.get()), + fine_rollover=bool(self.fine_rollover.get()), ) def _calibration(self) -> LaserDelayCalibration: @@ -828,14 +1178,19 @@ class LaserDelay(PSIDeviceBase, PositionerBase): def _require_calibration(self) -> LaserDelayCalibration: calibration = self._calibration() - values = calibration.as_dict().values() - if not all(math.isfinite(v) for v in values) or calibration.laser_period <= 0: + if not calibration.is_valid(): raise LaserDelayNotCalibratedError( f"{self.name} is not calibrated. Run dev.{self.name}.calibrate() after the laser is on, or restore a previous calibration with " f"dev.{self.name}.set_calibration(...). Current calibration: " f"{calibration.as_dict()}" ) + if self._unverified: + raise LaserDelayNotCalibratedError( + f"{self.name}: the calibration does not match the hardware: " + f"{'; '.join(self._unverified)}. Moves are refused until " + f"dev.{self.name}.calibrate()." + ) return calibration @contextlib.contextmanager @@ -868,6 +1223,165 @@ class LaserDelay(PSIDeviceBase, PositionerBase): self.readback.put(float(value), internal=True) self._set_position(float(value)) # runs the "readback" subscription (BEC update) + def _mark_unverified(self, reason: str) -> None: + if reason not in self._unverified: + self._unverified.append(reason) + + # ----------------------------------------------------------------------------------------- # + # Calibration storage # + # ----------------------------------------------------------------------------------------- # + + def _extra_saved_state(self) -> dict: + """More content for the calibration file (used by the simulation).""" + return {} + + def _save_calibration( + self, move_in_progress: bool = False, raise_on_error: bool = False + ) -> bool: + """Write calibration, readback and the move marker to the store. + + Returns False (and logs an error) if the store could not be written; with + ``raise_on_error`` a ``LaserDelayPersistenceError`` is raised instead. + """ + if self._store is None: + return True + calibration = self._calibration() + if not calibration.is_valid(): + return True + readback = float(self.readback.get()) + content = { + "device": self.name, + "saved_at": _utc_now(), + **self._calibration_meta, + "calibration": calibration.as_dict(), + "constants": { + "rf_period": float(self.rf_period.get()), + "cavity_period": float(self.cavity_period.get()), + "vmax": float(self.vmax.get()), + }, + "current_delay": readback if math.isfinite(readback) else None, + "move_in_progress": move_in_progress, + **self._extra_saved_state(), + } + try: + self._store.save(content) + except Exception as exc: # pylint: disable=broad-except + message = ( + f"{self.name}: could not save the calibration to {self._store}: {exc}. It is " + f"active, but will be lost at the next restart." + ) + logger.error(message) + if raise_on_error: + raise LaserDelayPersistenceError(message) from exc + return False + return True + + def _read_saved_calibration(self) -> dict | None: + if self._store is None: + return None + return self._store.load() + + def _load_calibration(self) -> None: + """Restore the calibration from the store when the device connects. + + The four numbers are restored as they were saved. The calibration is *verified* only if the + last move finished, the laser period still matches the EVR, the phase shifter amplitude is + right, its phase matches the stored phase (modulo the RF period, within one step), the delay + card still shows the saved delay and ``rf_period``/``cavity_period``/``vmax`` are unchanged. + Otherwise moves are refused until the next ``calibrate``. The card check matters because + the phase check cannot see a change by whole RF periods; only a BEC move changes the card, + and that saves the marker first. The readback (the delay the hardware is at) is restored + only with a verified calibration. + """ + content = self._read_saved_calibration() + if content is None: + return + calibration = LaserDelayCalibration( + **{key: float(content["calibration"][key]) for key in asdict(_EMPTY_CALIBRATION)} + ) + if not calibration.is_valid(): + raise ValueError(f"not a valid calibration: {calibration.as_dict()}") + constants = self._constants() + reasons = [] + saved_constants = content.get("constants") or {} + for key in ("rf_period", "cavity_period", "vmax"): + if key in saved_constants and not math.isclose( + float(saved_constants[key]), getattr(constants, key), rel_tol=1e-9, abs_tol=1e-9 + ): + reasons.append( + f"{key} was {saved_constants[key]} at the calibration and is " + f"{getattr(constants, key)} now" + ) + if content.get("move_in_progress"): + reasons.append("the device server stopped during a move (the phase is unknown)") + evr_period = 1000 * float(self.evr_period.get()) + if not math.isclose(evr_period, calibration.laser_period, rel_tol=1e-9, abs_tol=1e-6): + reasons.append( + f"the laser period changed from {calibration.laser_period} to {evr_period} ns" + ) + v1, v2 = float(self.phase_v1.get()), float(self.phase_v2.get()) + problem = self._amplitude_problem(v1, v2, constants) + if problem is None: + start = float(phase_from_voltages(v1, v2, constants.rf_period)) + problem = self._phase_problem(calibration, start, constants) + if problem: + reasons.append(problem) + saved_delay = content.get("current_delay") + delay = math.nan if saved_delay is None else float(saved_delay) + if math.isfinite(delay): + expected = delay_card_setting( + -calibration.offset_t - delay, calibration.laser_period, constants.fine_rollover + ) + actual = (self.delay_coarse.get(), self.delay_fine.get()) + if not math.isclose( + delay_card_time(*expected), delay_card_time(*actual), abs_tol=0.005 + ): + reasons.append( + f"the delay card reads DELAY={actual[0]}, FINE={actual[1]} instead of " + f"DELAY={expected[0]}, FINE={expected[1]} for the saved delay {delay} ns (the " + f"delay was changed outside BEC?)" + ) + if reasons: + delay = math.nan + with self._exclusive_hardware("restore the calibration"): + self._calibration_meta = { + "calibrated_at": content.get("calibrated_at"), + "scope_xray_minus_laser": content.get("scope_xray_minus_laser"), + } + self._unverified = list(reasons) + self._store_calibration(calibration, current_delay=delay) + info = self.get_calibration_info() + summary = ( + f"{self.name}: restored the calibration from {self._store} (calibrated " + f"{info['calibrated_at']}): {calibration.as_dict()}, current delay {delay} ns" + ) + if reasons: + logger.warning( + f"{summary}. NOT VERIFIED, moves are refused until " + f"dev.{self.name}.calibrate(): {'; '.join(reasons)}" + ) + else: + logger.info(summary) + if info["age_days"] is not None and info["age_days"] * 86400 > CALIBRATION_MAX_AGE_S: + logger.warning( + f"{self.name}: the restored calibration is {info['age_days']} days old; it is " + "usually redone about once a week with the oscilloscope." + ) + + # ----------------------------------------------------------------------------------------- # + # Move # + # ----------------------------------------------------------------------------------------- # + + def _refused(self, exc: Exception, position) -> StatusBase: + """An already failed move status; ``call_stop_on_failure=False`` as nothing was started.""" + status = DeviceStatus( + self, + call_stop_on_failure=False, + description=f"{self.name}: move to {position!r} ns refused", + ) + status.set_exception(exc) + return status + @staticmethod def _on_move_status_done(stop_event: threading.Event, status: StatusBase) -> None: if not status.success: @@ -887,30 +1401,66 @@ class LaserDelay(PSIDeviceBase, PositionerBase): def _write_phase(self, phase_t: float, constants: _Constants) -> None: v1, v2 = voltages_for_phase(phase_t, constants.rf_period, constants.vmax) - self.phase_v1.put(float(v1)) - self.phase_v2.put(float(v2)) + if max(abs(v1), abs(v2)) > PHASE_VOLTAGE_LIMIT: + raise ValueError( + f"{self.name}: refusing to write V1={v1}, V2={v2}: above the phase shifter hard " + f"limit of {PHASE_VOLTAGE_LIMIT} V" + ) + with self._hardware_writes(): + self.phase_v1.put(float(v1)) + self.phase_v2.put(float(v2)) + + def _write_delay_card(self, coarse: int, fine: int) -> None: + with self._hardware_writes(): + self.delay_coarse.put(coarse) + self.delay_fine.put(fine) + actual = (self.delay_coarse.get(use_monitor=False), self.delay_fine.get(use_monitor=False)) + if actual != (coarse, fine): + raise LaserDelayHardwareError( + f"{self.name}: wrote DELAY={coarse}, FINE={fine} but the delay card reads " + f"DELAY={actual[0]}, FINE={actual[1]} (value clamped by the IOC?). The phase " + f"shifter was not touched." + ) + + def _check_phase_written(self, phase_t: float, constants: _Constants) -> None: + expected = voltages_for_phase(phase_t, constants.rf_period, constants.vmax) + actual = (self.phase_v1.get(use_monitor=False), self.phase_v2.get(use_monitor=False)) + if any(abs(a - e) > self.VOLTAGE_READBACK_TOLERANCE for a, e in zip(actual, expected)): + problem = ( + f"wrote V1={expected[0]:.4f}, V2={expected[1]:.4f} V but the phase shifter reads " + f"V1={actual[0]:.4f}, V2={actual[1]:.4f} V (value clamped by the IOC?)" + ) + self._mark_unverified(problem) + raise LaserDelayHardwareError(f"{self.name}: {problem}") def _move_task(self, target: float, status: StatusBase, stop_event: threading.Event) -> None: """Body of the move, run by the task handler. Never raises; resolves ``status``. - Readback, ``motor_is_moving`` and the hardware lock are all settled before the status - resolves, so whoever waits on the status sees the final state. Calibration and constants - are read only once the hardware lock is held: a move queued right behind a stopped one - must see the phase bookkeeping of that stopped move. + Readback, ``motor_is_moving``, the store and the hardware lock are all settled before the + status resolves, so whoever waits on the status sees the final state. Calibration and + constants are read only once the hardware lock is held: a move queued right behind a + stopped one must see the phase bookkeeping of that stopped move. """ error = None with self._hw_lock: + wrote = False try: calibration = self._require_calibration() constants = self._constants() - self._execute_move(target, calibration, constants, stop_event) - except Exception as exc: # pylint: disable=broad-except + wrote = self._execute_move(target, calibration, constants, stop_event) + except BaseException as exc: # pylint: disable=broad-except error = exc - if not isinstance(exc, DeviceStoppedError): + wrote = wrote or getattr(exc, "hardware_written", False) + if not isinstance(exc, (DeviceStoppedError, TaskKilledError)): logger.error(f"{self.name}: move to {target} ns failed: {exc}") + if wrote: + # Clears the move marker; the stored phase changes with every move. + self._save_calibration() with self._move_lock: if self._move_status is status: # no newer move queued behind this one self.motor_is_moving.put(0, internal=True) + if isinstance(error, TaskKilledError): + error = DeviceStoppedError(f"Device {self.name} has been destroyed") self._finish_status(status, error) def _execute_move( @@ -919,43 +1469,54 @@ class LaserDelay(PSIDeviceBase, PositionerBase): calibration: LaserDelayCalibration, constants: _Constants, stop_event: threading.Event, - ) -> None: - """The script's SetDelay: delay card, then the phase shifter ramp, then bookkeeping.""" + ) -> bool: + """The script's SetDelay: checks, delay card, phase shifter ramp, bookkeeping. + + Returns True if hardware was written. Nothing is written before all checks passed. An + exception raised after the first write carries ``hardware_written = True``. + """ if stop_event.is_set(): raise DeviceStoppedError(f"Device {self.name} has been stopped") - coarse, fine = delay_card_setting(-calibration.offset_t - target, calibration.laser_period) + v1, v2 = float(self.phase_v1.get()), float(self.phase_v2.get()) + problem = self._amplitude_problem(v1, v2, constants) + if problem: + raise LaserDelayHardwareError(f"{self.name}: move to {target} ns refused: {problem}") + start = float(phase_from_voltages(v1, v2, constants.rf_period)) + problem = self._phase_problem(calibration, start, constants) + if problem: + self._mark_unverified(problem) + raise LaserDelayHardwareError(f"{self.name}: move to {target} ns refused: {problem}") + coarse, fine = delay_card_setting( + -calibration.offset_t - target, calibration.laser_period, constants.fine_rollover + ) + plan = plan_phase_move(target, calibration, constants, start=start) + wraps = "" + if plan.cavity_wraps: + wraps = ( + f"; shortest path, {plan.cavity_wraps:+d} cavity period(s) from the script's " + f"phase: {self._wrap_offset(plan.cavity_wraps, constants):+.4f} ns (mod RF)" + ) + logger.info( + f"{self.name}: move to {target} ns: DELAY={coarse}, FINE={fine}, phase " + f"{plan.start:.4f} {plan.delta:+.4f} ns in {plan.n_puts} steps " + f"(~{plan.n_puts * constants.step_sleep:.1f} s){wraps}" + ) - written_any = False + # Mark the move before the first write: a crash from here on leaves the marker set. Without + # the marker a later restore could trust a phase that is whole RF periods stale (the phase + # check cannot see those), so the move is refused when the marker cannot be saved. + previous_readback = float(self.readback.get()) + self._update_readback(math.nan) + if not self._save_calibration(move_in_progress=True): + self._update_readback(previous_readback) + raise LaserDelayPersistenceError( + f"{self.name}: move to {target} ns refused: the move marker could not be saved to " + f"{self._store} (see the log). The hardware was not touched; fix the store and move " + f"again." + ) done_offset = None - plan = None try: - self.delay_coarse.put(coarse) - written_any = True - self.delay_fine.put(fine) - - plan = plan_phase_move( - target, calibration, constants, start=self._read_phase(constants.rf_period) - ) - if constants.phase_reference == "stored": - mismatch = wrap_phase(calibration.phase_t - plan.start, constants.rf_period) - if abs(mismatch) > constants.rf_period / (2 * constants.steps_per_rf_period): - logger.warning( - f"{self.name}: stored phase and V1/V2 read-back differ by " - f"{mismatch:.4f} ns modulo the RF period (voltages changed outside BEC?). " - "The ramp starts at the read-back, so this move ends that far from the " - "intended phase." - ) - wraps = "" - if plan.cavity_wraps: - wraps = ( - f"; shortest path, {plan.cavity_wraps:+d} cavity period(s) from the script's " - f"phase: {self._wrap_offset(plan.cavity_wraps, constants):+.4f} ns (mod RF)" - ) - logger.info( - f"{self.name}: move to {target} ns: DELAY={coarse}, FINE={fine}, phase " - f"{plan.start:.4f} {plan.delta:+.4f} ns in {plan.n_puts} steps " - f"(~{plan.n_puts * constants.step_sleep:.1f} s){wraps}" - ) + self._write_delay_card(coarse, fine) for offset in plan.offsets: if stop_event.is_set(): raise DeviceStoppedError(f"Device {self.name} has been stopped") @@ -968,27 +1529,236 @@ class LaserDelay(PSIDeviceBase, PositionerBase): self._write_phase(plan.start + plan.delta, constants) done_offset = None self.phase_t.put(float(plan.stored_after), internal=True) + self._check_phase_written(plan.start + plan.delta, constants) # The script also sleeps after the last step; a stop now no longer matters. stop_event.wait(constants.step_sleep) self._update_readback(target) - except BaseException: + except BaseException as exc: if done_offset is not None: # Keep stored and real (unwrapped) phase consistent for the next move. self.phase_t.put(float(plan.reference + done_offset), internal=True) - if written_any: - self._update_readback(math.nan) + try: + exc.hardware_written = True + except AttributeError: + pass raise + return True + + +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: soft signals replace the five PVs (local demo BEC, tests). + """LaserDelay without EPICS, with a simulated oscilloscope (local demo BEC, tests). - The ``*_pv`` arguments are accepted and ignored. Start values: laser period 2.0002 µs, - delay card 150 / 37 (300.37 ns), V1 = 1, V2 = 0. + 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``. """ - delay_coarse = Cpt(Signal, value=150, kind=Kind.normal, doc="Delay card, units of 2 ns") - delay_fine = Cpt(Signal, value=37, kind=Kind.normal, doc="Delay card, units of 0.01 ns") + 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(Signal, value=1.0, kind=Kind.normal, doc="Phase shifter V1") - phase_v2 = Cpt(Signal, value=0.0, kind=Kind.normal, doc="Phase shifter V2") + 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__ +) diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py index ab8770b..f2a9a4f 100644 --- a/tests/tests_devices/test_laser_delay.py +++ b/tests/tests_devices/test_laser_delay.py @@ -5,6 +5,7 @@ from __future__ import annotations import functools +import json import math import threading import time @@ -21,14 +22,21 @@ from bec_server.device_server.devices.config_update_handler import ConfigUpdateH from bec_server.device_server.devices.device_serializer import get_device_base_class from bec_server.device_server.devices.devicemanager import DeviceManagerDS from ophyd.signal import ConnectionTimeoutError, InternalSignalError -from ophyd.utils import LimitError +from ophyd.utils import LimitError, ReadOnlyError from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError +from ophyd_devices.interfaces.protocols.bec_protocols import ( + BECDeviceProtocol, + BECPositionerProtocol, +) from ophyd_devices.tests.utils import MockPV, patched_device from superxas_bec.devices.laser_delay import ( + PHASE_VOLTAGE_LIMIT, LaserDelay, LaserDelayBusyError, + LaserDelayHardwareError, LaserDelayNotCalibratedError, + LaserDelayPersistenceError, SimLaserDelay, delay_card_setting, fold_phase_delta, @@ -42,6 +50,13 @@ CONFIG_DIR = Path(__file__).resolve().parents[2] / "superxas_bec" / "device_conf SCOPE = 546.04 - 546.92 +@pytest.fixture(autouse=True) +def fast_ramp(monkeypatch): + """Tests ramp without sleeping; the beamline minimum is tested on its own.""" + assert LaserDelay.MIN_STEP_SLEEP == 0.1 # the value the scientist tested + monkeypatch.setattr(LaserDelay, "MIN_STEP_SLEEP", 0.0) + + def record_puts(device: LaserDelay) -> list: """Record every put to the four written PVs, in order (thread safe enough for list.append).""" log: list[tuple[str, object]] = [] @@ -109,7 +124,6 @@ def test_default_pvs_constants_and_positioner_type(laser_delay): assert config["laser_delay_vmax"]["value"] == 1.0 assert config["laser_delay_steps_per_rf_period"]["value"] == 36 assert config["laser_delay_step_sleep"]["value"] == 0.0 - assert config["laser_delay_phase_reference"]["value"] == "stored" assert config["laser_delay_phase_path"]["value"] == "script" assert get_device_base_class(laser_delay) == "positioner" assert laser_delay.hints == {"fields": ["laser_delay"]} @@ -120,8 +134,13 @@ def test_default_pvs_constants_and_positioner_type(laser_delay): "calibrate", "set_calibration", "get_calibration", + "get_calibration_info", "plan_move", } + assert config["laser_delay_fine_rollover"]["value"] is True + assert laser_delay.calibration_file is None + assert isinstance(laser_delay, BECPositionerProtocol) + assert isinstance(laser_delay, BECDeviceProtocol) def test_config_overrides_pvs_and_constants(): @@ -138,7 +157,6 @@ def test_config_overrides_pvs_and_constants(): vmax=0.8, steps_per_rf_period=72, step_sleep=0.0, - phase_reference="readback", limits=[-20, 20], ) as device: assert [s.pvname for s in (device.delay_coarse, device.delay_fine, device.evr_period)] == [ @@ -154,7 +172,8 @@ def test_config_overrides_pvs_and_constants(): plan = device.plan_move(3.0) device.set(3.0).wait(timeout=10) phase_puts = log[2:] - # readback reference: the ramp goes from the read-back phase straight to the target + # first move after calibrate: the stored phase is the read-back, so the ramp goes from the + # read-back phase straight to the target assert plan["phase_delta"] == pytest.approx(plan["phase_target"] - plan["phase_start"]) expected_steps = len(phase_ramp_offsets(plan["phase_delta"], 2.0014, 72)) + 1 assert len(phase_puts) == 2 * expected_steps == 2 * plan["phase_steps"] @@ -173,13 +192,16 @@ def test_config_overrides_pvs_and_constants(): @pytest.mark.parametrize( "kwargs", [ - {"phase_reference": "both"}, + {"steps_per_rf_period": 35}, + {"vmax": 0.0}, {"phase_path": "fast"}, {"steps_per_rf_period": 0}, {"steps_per_rf_period": 2.5}, {"rf_period": -2.0}, {"cavity_period": 0}, {"vmax": float("nan")}, + {"vmax": 5.5}, + {"fine_rollover": "yes"}, {"step_sleep": -0.1}, {"limits": [1.0]}, {"limits": [5.0, -5.0]}, @@ -199,8 +221,8 @@ def test_runtime_constant_update_is_validated(calibrated): with pytest.raises(ValueError): calibrated.step_sleep.set(-1).wait(timeout=2) with pytest.raises(ValueError): - calibrated.phase_reference.set("sometimes").wait(timeout=2) - assert calibrated._constants().phase_reference == "stored" + calibrated.steps_per_rf_period.set(18).wait(timeout=2) + assert calibrated._constants().steps_per_rf_period == 36 with pytest.raises(ValueError): calibrated.phase_path.set("fastest").wait(timeout=2) calibrated.phase_path.set("shortest").wait(timeout=2) @@ -248,9 +270,9 @@ def test_runtime_config_update_errors_name_the_real_problem(): ) handler._update_config(msg, threading.Event()) - with pytest.raises(DeviceConfigError, match="phase_reference must be one of"): - update({"phase_reference": "readbak"}) - assert device.phase_reference.get() == "stored" + with pytest.raises(DeviceConfigError, match="phase_path must be one of"): + update({"phase_path": "fastest"}) + assert device.phase_path.get() == "script" with pytest.raises(DeviceConfigError, match="reloading the device config"): update({"phase_v1_pv": "X10DA-ES4:AO-03"}) assert device.phase_v1.pvname == "X10DA-ES4:AO-02" @@ -340,7 +362,8 @@ def test_calibration_publishes_readback_subscription(laser_delay): laser_delay.subscribe(lambda value, **_: values.append(value), event_type="readback", run=False) laser_delay.calibrate(SCOPE) laser_delay.set(1.0).wait(timeout=10) - assert values == [SCOPE, 1.0] + # NaN while the move runs: the delay is undefined until the ramp has finished + assert values[0] == SCOPE and math.isnan(values[1]) and values[2:] == [1.0] def test_set_calibration_restores_previous_state(laser_delay): @@ -414,8 +437,11 @@ def test_move_does_not_block_and_reports_moving(calibrated): assert not status.done assert calibrated.moving assert calibrated.motor_is_moving.get() == 1 + refused = calibrated.set(0.0) # not raised: a failed status, the running move goes on + assert refused.done and not refused.success with pytest.raises(LaserDelayBusyError): - calibrated.set(0.0) + refused.wait(timeout=1) + assert calibrated.moving and not status.done with pytest.raises(LaserDelayBusyError): calibrated.calibrate(SCOPE) calibrated.stop() @@ -651,7 +677,7 @@ def test_delay_card_write_failure_leaves_the_phase_shifter_alone(calibrated): assert calibrated.motor_is_moving.get() == 0 -def test_phase_read_failure_after_the_delay_card_fails_the_move(calibrated): +def test_phase_read_failure_refuses_the_move_before_any_write(calibrated): old_phase_t = calibrated.phase_t.get() log = record_puts(calibrated) with mock.patch.object( @@ -660,37 +686,43 @@ def test_phase_read_failure_after_the_delay_card_fails_the_move(calibrated): status = calibrated.set(-4.9) with pytest.raises(ConnectionTimeoutError): status.wait(timeout=5) - assert [pv for pv, _ in log] == ["X10DA-ES2-TIM:SET1-DELAY", "X10DA-ES2-TIM:SET1-FINE"] + assert log == [] assert calibrated.phase_t.get() == old_phase_t - assert math.isnan(calibrated.readback.get()) + assert calibrated.readback.get() == pytest.approx(SCOPE) # nothing moved assert not calibrated.moving -def test_stored_vs_readback_reference_when_voltages_were_touched(calibrated): +def test_move_refused_when_voltages_were_touched(calibrated): + """Somebody turned the phase shifter outside BEC: the next move is refused before any write, + and every later move too, until a new calibration.""" calibrated.set(1.0).wait(timeout=10) - # somebody turns the phase shifter by hand calibrated.phase_v1._read_pv.mock_data = 0.0 calibrated.phase_v2._read_pv.mock_data = 1.0 assert calibrated.read()["laser_delay_phase_mismatch"]["value"] != pytest.approx(0, abs=1e-3) - plan_stored = calibrated.plan_move(2.0) + assert "away from the stored phase" in calibrated.plan_move(2.0)["refused"] + log = record_puts(calibrated) + status = calibrated.set(2.0) + with pytest.raises(LaserDelayHardwareError, match="away from the stored phase"): + status.wait(timeout=10) + assert log == [] + assert calibrated.readback.get() == 1.0 # nothing was written + assert not calibrated.get_calibration_info()["verified"] + with pytest.raises(LaserDelayNotCalibratedError, match="does not match the hardware"): + calibrated.set(2.0) + calibrated.calibrate(SCOPE) calibrated.set(2.0).wait(timeout=10) - end_stored = calibrated.read_phase() - # stored: relative move from the stored phase, starting at the read-back -> offset persists - assert wrap_phase(end_stored - (0.5 + plan_stored["phase_delta"]), 2.0) == pytest.approx( - 0, abs=1e-9 - ) + assert calibrated.readback.get() == 2.0 - calibrated.phase_v1._read_pv.mock_data = 0.0 - calibrated.phase_v2._read_pv.mock_data = 1.0 - calibrated.phase_reference.put("readback") - plan_readback = calibrated.plan_move(3.0) - calibrated.set(3.0).wait(timeout=10) - # readback: lands on the target phase whatever the voltages were - assert plan_readback["phase_start"] == pytest.approx(0.5) - assert wrap_phase( - calibrated.read_phase() - plan_readback["phase_target"], 2.0 - ) == pytest.approx(0, abs=1e-9) - assert calibrated.phase_t.get() == plan_readback["phase_target"] + +def test_mismatch_below_one_step_is_accepted(calibrated): + """A V1 written without its V2 (IOC failure mid-pair) leaves less than one step: allowed.""" + calibrated.set(1.0).wait(timeout=10) + phase = calibrated.read_phase() + v1, v2 = voltages_for_phase(phase + 0.04, 2.0, 1.0) + calibrated.phase_v1._read_pv.mock_data = v1 + calibrated.phase_v2._read_pv.mock_data = v2 + calibrated.set(2.0).wait(timeout=10) + assert calibrated.readback.get() == 2.0 def test_plan_move_writes_nothing(calibrated): @@ -702,7 +734,7 @@ def test_plan_move_writes_nothing(calibrated): def test_first_move_after_calibration_can_ramp_two_cavity_periods(laser_delay): - """Documented in LASER_DELAY.md: dP = (-t mod cav) + (s mod cav) right after calibration.""" + """Module docstring: dP = (-t mod cav) + (s mod cav) right after calibration.""" laser_delay.calibrate(SCOPE) plan = laser_delay.plan_move(-4.9) assert plan["phase_delta"] == pytest.approx(4.9 + SCOPE % 12.01) @@ -891,3 +923,565 @@ def test_sim_device_moves_without_epics(): ) finally: device.destroy() + + +def test_sim_scope_follows_every_move_on_both_phase_paths(): + """The simulated oscilloscope shows the commanded delay after every move.""" + device = SimLaserDelay(name="laser_delay", step_sleep=0.0, sim_noise=0) + try: + assert device.sim_scope.get() == pytest.approx(-0.88) + device.calibrate(device.sim_scope.get()) + rng = np.random.default_rng(7) + for path in ("script", "shortest"): + device.phase_path.set(path).wait(timeout=2) + for target in rng.uniform(-40, 40, 25): + device.set(float(target)).wait(timeout=30) + assert device.sim_scope.get() == pytest.approx(target, abs=1e-6), path + device.set(-5.0).wait(timeout=30) + assert device.sim_response.get() == pytest.approx(0, abs=1e-6) + device.set(5.0).wait(timeout=30) + assert device.sim_response.get() == pytest.approx(1, abs=1e-6) + finally: + device.destroy() + + +def test_sim_scope_shows_a_wrong_calibration(): + device = SimLaserDelay(name="laser_delay", step_sleep=0.0, sim_noise=0) + try: + device.calibrate(-0.5) # scope misread by 0.38 ns + device.set(2.0).wait(timeout=30) + assert device.sim_scope.get() == pytest.approx(2.0 - 0.38) + finally: + device.destroy() + + +def test_sim_rejects_what_the_iocs_refuse(): + device = SimLaserDelay(name="laser_delay", step_sleep=0.0) + try: + with pytest.raises(ValueError): + device.sim_set_hardware(phase_v1=PHASE_VOLTAGE_LIMIT + 0.1) + with pytest.raises(ValueError): + device.sim_set_hardware(delay_fine=200) + finally: + device.destroy() + + +# --------------------------------------------------------------------------------------------- # +# Delay card fine value 200 # +# --------------------------------------------------------------------------------------------- # + + +def test_delay_card_fine_rollover(): + # t1 mod 2 = 1.99985 -> the script writes FINE 200 + assert delay_card_setting(301.99985, 2000.2) == (150, 200) + assert delay_card_setting(301.99985, 2000.2, fine_rollover=True) == (151, 0) + # the next coarse unit would be a full laser period: wrap to 0 + assert delay_card_setting(1999.99985, 2000.0, fine_rollover=True) == (0, 0) + rng = np.random.default_rng(3) + for total in rng.uniform(-5000, 5000, 5000): + script = delay_card_setting(total, 2000.2) + coarse, fine = delay_card_setting(total, 2000.2, fine_rollover=True) + assert 0 <= fine <= 199 + assert 2 * coarse + 0.01 * fine == pytest.approx(2 * script[0] + 0.01 * script[1]) + + +@pytest.mark.parametrize("rollover, expected", [(True, (151, 0)), (False, (150, 200))]) +def test_move_writes_rolled_over_fine_value(calibrated, rollover, expected): + calibrated.fine_rollover.set(rollover).wait(timeout=2) + offset_t = calibrated.get_calibration()["offset_t"] + target = -offset_t - 301.99985 # delay card total with FINE 200 + plan = calibrated.plan_move(target) + assert (plan["delay_coarse"], plan["delay_fine"]) == expected + log = record_puts(calibrated) + calibrated.set(target).wait(timeout=10) + assert [value for _, value in log[:2]] == list(expected) + + +# --------------------------------------------------------------------------------------------- # +# Phase shifter voltage limit # +# --------------------------------------------------------------------------------------------- # + + +def test_vmax_is_capped_at_the_hard_limit(calibrated): + calibrated.vmax.set(PHASE_VOLTAGE_LIMIT).wait(timeout=2) + with pytest.raises(ValueError, match="hard limit"): + calibrated.vmax.set(PHASE_VOLTAGE_LIMIT + 0.01).wait(timeout=2) + assert calibrated.vmax.get() == PHASE_VOLTAGE_LIMIT + + +# --------------------------------------------------------------------------------------------- # +# Calibration kept across BEC restarts # +# --------------------------------------------------------------------------------------------- # + + +@pytest.fixture +def caplog(): + """Messages logged by the device module (bec_logger is loguru, not std logging).""" + + class Log: + def __init__(self): + self.patcher = mock.patch("superxas_bec.devices.laser_delay.logger") + + @property + def text(self): + return "\n".join(str(c.args[0]) for c in self.mock.method_calls if c.args) + + log = Log() + log.mock = log.patcher.start() + yield log + log.patcher.stop() + + +@pytest.fixture +def calibration_file(tmp_path): + return tmp_path / "sub" / "laser_delay_calibration.json" + + +def make_device(calibration_file, **hardware): + """A fresh device (as after a BEC restart) on the given mocked hardware.""" + context = patched_device( + LaserDelay, name="laser_delay", step_sleep=0.0, calibration_file=str(calibration_file) + ) + device = context.__enter__() + set_hardware(device, **hardware) + return context, device + + +def hardware_of(device): + return { + "coarse": device.delay_coarse.get(), + "fine": device.delay_fine.get(), + "v1": device.phase_v1.get(), + "v2": device.phase_v2.get(), + } + + +def test_calibration_is_saved_after_calibrate_and_every_move(calibration_file): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + saved = json.loads(calibration_file.read_text()) + assert saved["calibration"] == device.get_calibration() + assert saved["current_delay"] == pytest.approx(SCOPE) + assert saved["scope_xray_minus_laser"] == pytest.approx(SCOPE) + device.set(1.5).wait(timeout=10) + saved = json.loads(calibration_file.read_text()) + assert saved["calibration"]["phase_t"] == device.get_calibration()["phase_t"] + assert saved["current_delay"] == 1.5 + assert saved["move_in_progress"] is False + assert saved["constants"] == {"rf_period": 2.0, "cavity_period": 12.01, "vmax": 1.0} + info = device.get_calibration_info() + assert info["calibrated_at"] == saved["calibrated_at"] + assert info["age_days"] == pytest.approx(0, abs=0.01) + assert info["storage"] == str(calibration_file) + assert info["verified"] is True + finally: + context.__exit__(None, None, None) + + +def test_calibration_is_restored_after_a_restart(calibration_file): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + device.set(1.5).wait(timeout=10) + before, hardware = device.get_calibration(), hardware_of(device) + plan_before = device.plan_move(-2.0) + finally: + context.__exit__(None, None, None) + context, device = make_device(calibration_file, **hardware) + try: + assert math.isnan(device.get_calibration()["phase_t"]) + device.on_connected() + assert device.get_calibration() == before + assert device.read()["laser_delay"]["value"] == 1.5 + assert device.plan_move(-2.0) == plan_before + finally: + context.__exit__(None, None, None) + + +def test_restore_with_turned_phase_shifter_refuses_moves(calibration_file, caplog): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + device.set(1.5).wait(timeout=10) + before, hardware = device.get_calibration(), hardware_of(device) + finally: + context.__exit__(None, None, None) + hardware["v1"], hardware["v2"] = hardware["v2"], -hardware["v1"] # phase turned by hand + context, device = make_device(calibration_file, **hardware) + try: + device.on_connected() + assert device.get_calibration() == before + assert math.isnan(device.read()["laser_delay"]["value"]) + assert "NOT VERIFIED" in caplog.text + info = device.get_calibration_info() + assert ( + not info["verified"] and "away from the stored phase" in info["unverified_reasons"][0] + ) + with pytest.raises(LaserDelayNotCalibratedError, match="does not match the hardware"): + device.set(1.0) + finally: + context.__exit__(None, None, None) + + +def test_restore_after_a_crash_during_a_move_refuses_moves(calibration_file): + """The marker written before the first hardware write survives a crashed device server.""" + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + with mock.patch.object(device, "_write_phase", side_effect=SystemExit("crash")): + status = device.set(3.0) + with pytest.raises(Exception): + status.wait(timeout=10) + # simulate the crash: the end-of-move save never happened + content = json.loads(calibration_file.read_text()) + content["move_in_progress"] = True + calibration_file.write_text(json.dumps(content)) + hardware = hardware_of(device) + finally: + context.__exit__(None, None, None) + context, device = make_device(calibration_file, **hardware) + try: + device.on_connected() + assert "stopped during a move" in device.get_calibration_info()["unverified_reasons"][0] + with pytest.raises(LaserDelayNotCalibratedError): + device.set(1.0) + finally: + context.__exit__(None, None, None) + + +def test_move_marker_is_set_before_the_first_write(calibration_file): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + seen = [] + original = device.delay_coarse._write_pv.put + + def check_marker(value, *args, **kwargs): + seen.append(json.loads(calibration_file.read_text())["move_in_progress"]) + return original(value, *args, **kwargs) + + device.delay_coarse._write_pv.put = check_marker + device.set(2.0).wait(timeout=10) + assert seen == [True] + assert json.loads(calibration_file.read_text())["move_in_progress"] is False + finally: + context.__exit__(None, None, None) + + +def test_restore_with_changed_laser_period_refuses_moves(calibration_file): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + hardware = hardware_of(device) + finally: + context.__exit__(None, None, None) + context, device = make_device(calibration_file, evr=1.0, **hardware) + try: + device.on_connected() + assert "laser period changed" in device.get_calibration_info()["unverified_reasons"][0] + finally: + context.__exit__(None, None, None) + + +def test_restore_with_changed_delay_card_refuses_moves(calibration_file): + """A delay card that no longer shows the saved delay means the delay was changed outside BEC + (script, caput). The phase part of such a change can be whole RF periods, invisible to the phase + check, so the calibration is not verified until the next calibrate.""" + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + device.set(1.5).wait(timeout=10) + hardware = hardware_of(device) + finally: + context.__exit__(None, None, None) + hardware["fine"] = (hardware["fine"] + 50) % 200 + context, device = make_device(calibration_file, **hardware) + try: + device.on_connected() + info = device.get_calibration_info() + assert not info["verified"] and "delay card reads" in info["unverified_reasons"][0] + assert math.isnan(device.read()["laser_delay"]["value"]) + with pytest.raises(LaserDelayNotCalibratedError, match="does not match the hardware"): + device.set(1.0) + device.calibrate(SCOPE) + device.set(1.0).wait(timeout=10) + assert device.readback.get() == 1.0 + finally: + context.__exit__(None, None, None) + + +def test_restore_after_an_external_move_by_whole_rf_periods_refuses_moves(calibration_file): + """While BEC was down the script moved the delay by 2.0 ns (one RF period): the voltages are + exactly where the saved phase expects them, only the delay card tells. Without the card check the + next move would be 2.0 ns off while the readback claims the target.""" + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + device.set(5.0).wait(timeout=10) + saved = calibration_file.read_text() # BEC's last state: 5.0 ns + at_5 = hardware_of(device) + device.set(3.0).wait(timeout=10) # what the script does meanwhile: one RF period + hardware = hardware_of(device) + assert (hardware["v1"], hardware["v2"]) == pytest.approx((at_5["v1"], at_5["v2"]), abs=1e-9) + assert (hardware["coarse"], hardware["fine"]) != (at_5["coarse"], at_5["fine"]) + finally: + context.__exit__(None, None, None) + calibration_file.write_text(saved) # BEC never saw the script's move + context, device = make_device(calibration_file, **hardware) + try: + device.on_connected() + assert device.read_phase_mismatch() == pytest.approx(0, abs=1e-9) # phase check is blind + info = device.get_calibration_info() + assert not info["verified"] and "delay card reads" in info["unverified_reasons"][0] + with pytest.raises(LaserDelayNotCalibratedError): + device.set(4.0) + finally: + context.__exit__(None, None, None) + + +def test_restore_with_changed_constants_refuses_moves(calibration_file): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + hardware = hardware_of(device) + finally: + context.__exit__(None, None, None) + context = patched_device( + LaserDelay, + name="laser_delay", + step_sleep=0.0, + cavity_period=12.0084, + calibration_file=str(calibration_file), + ) + device = context.__enter__() + set_hardware(device, **hardware) + try: + device.on_connected() + info = device.get_calibration_info() + assert not info["verified"] and "cavity_period was 12.01" in info["unverified_reasons"][0] + finally: + context.__exit__(None, None, None) + + +def test_old_calibration_is_restored_with_a_warning(calibration_file, caplog): + context, device = make_device(calibration_file) + try: + device.calibrate(SCOPE) + finally: + context.__exit__(None, None, None) + content = json.loads(calibration_file.read_text()) + content["calibrated_at"] = "2026-01-01T00:00:00+00:00" + calibration_file.write_text(json.dumps(content)) + context, device = make_device(calibration_file) + try: + device.on_connected() + assert device.read()["laser_delay"]["value"] == pytest.approx(SCOPE) + assert "days old" in caplog.text + finally: + context.__exit__(None, None, None) + + +def test_broken_calibration_file_leaves_the_device_uncalibrated(calibration_file, caplog): + calibration_file.parent.mkdir(parents=True) + calibration_file.write_text("{not json") + context, device = make_device(calibration_file) + try: + device.on_connected() # must not raise: the device still connects + assert "could not restore" in caplog.text + with pytest.raises(LaserDelayNotCalibratedError): + device.set(1.0) + finally: + context.__exit__(None, None, None) + + +def test_no_calibration_file_configured_writes_nothing(laser_delay, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + laser_delay.on_connected() + laser_delay.calibrate(SCOPE) + assert laser_delay.get_calibration_info()["storage"] is None + assert not list(tmp_path.iterdir()) + + +def test_unwritable_calibration_store_refuses_the_move(tmp_path, caplog): + """Without the move marker a later restore could trust a stale phase: fail safe.""" + blocker = tmp_path / "file" + blocker.write_text("") + context, device = make_device(blocker / "cal.json") # parent is a file + try: + with pytest.raises(LaserDelayPersistenceError, match="will be lost at the next restart"): + device.calibrate(SCOPE) + assert device.get_calibration_info()["verified"] # applied anyway + log = record_puts(device) + status = device.set(1.0) + with pytest.raises(LaserDelayPersistenceError, match="move marker could not be saved"): + status.wait(timeout=10) + assert log == [] + assert device.readback.get() == pytest.approx(SCOPE) # nothing moved + assert device.motor_is_moving.get() == 0 and not device.moving + assert "could not save" in caplog.text + finally: + context.__exit__(None, None, None) + + +def test_sim_restart_restores_hardware_and_calibration(tmp_path): + path = tmp_path / "cal.json" + device = SimLaserDelay(name="laser_delay", step_sleep=0.0, sim_noise=0, calibration_file=path) + try: + device.on_connected() + device.calibrate(device.sim_scope.get()) + for target in (3.3, -7.1, 14.2): + device.set(target).wait(timeout=30) + finally: + device.destroy() + device = SimLaserDelay(name="laser_delay", step_sleep=0.0, sim_noise=0, calibration_file=path) + try: + device.on_connected() + assert device.read()["laser_delay"]["value"] == 14.2 + assert device.sim_scope.get() == pytest.approx(14.2) + device.set(-2.0).wait(timeout=30) + assert device.sim_scope.get() == pytest.approx(-2.0) + finally: + device.destroy() + + +# --------------------------------------------------------------------------------------------- # +# Safety checks (deployment audit 2026-10-08) # +# --------------------------------------------------------------------------------------------- # + + +def test_hardware_signals_refuse_direct_writes(calibrated): + """dev.laser_delay.phase_v1.set(...) from a client would bypass the ramp and the bookkeeping.""" + log = record_puts(calibrated) + for signal, value in ( + (calibrated.phase_v1, 0.5), + (calibrated.phase_v2, 7.5), + (calibrated.delay_coarse, 10), + (calibrated.delay_fine, 10), + ): + with pytest.raises(ReadOnlyError, match="written only by laser_delay"): + signal.put(value) + with pytest.raises(ReadOnlyError): + signal.set(value).wait(timeout=2) + assert log == [] + + +def test_hardware_writes_are_range_checked_even_for_the_device(calibrated): + with calibrated._hardware_writes(): + with pytest.raises(ValueError, match="outside"): + calibrated.phase_v1.put(PHASE_VOLTAGE_LIMIT + 0.01) + with pytest.raises(ValueError, match="outside"): + calibrated.delay_fine.put(201) + + +def test_direct_write_from_another_thread_during_a_move_is_refused(laser_delay): + """The permission is per thread: a client write while the move task writes is still refused.""" + laser_delay.step_sleep.put(0.02) + laser_delay.calibrate(SCOPE) + status = laser_delay.set(0.5) # crosses t = 0: a long ramp + assert wait_until(lambda: laser_delay.phase_v1._write_pv.mock_data != 0.6) + with pytest.raises(ReadOnlyError): + laser_delay.phase_v1.put(0.0) + laser_delay.stop() + with pytest.raises(DeviceStoppedError): + status.wait(timeout=5) + + +def test_beamline_minimum_step_sleep(monkeypatch): + monkeypatch.setattr(LaserDelay, "MIN_STEP_SLEEP", 0.1) # the real value, see fast_ramp + with pytest.raises(ValueError, match="at least 0.1"): + with patched_device(LaserDelay, name="laser_delay", step_sleep=0.05): + pass + with patched_device(LaserDelay, name="laser_delay") as device: + assert device.step_sleep.get() == 0.1 + with pytest.raises(ValueError, match="at least 0.1"): + device.step_sleep.set(0.05).wait(timeout=2) + device.step_sleep.set(0.15).wait(timeout=2) + assert SimLaserDelay.MIN_STEP_SLEEP == 0.0 + + +def test_busy_refusal_does_not_stop_the_running_move(laser_delay): + """BEC v4's mv ends without waiting; the next queued scan's set arrives while the ramp runs. The + refusal must not end that ramp: the device server turns an exception from set() into a status + whose failure calls stop(), so the refusal is an already failed status instead.""" + laser_delay.step_sleep.put(0.02) + laser_delay.calibrate(SCOPE) + log = record_puts(laser_delay) + first = laser_delay.set(0.5) # crosses t = 0: a long ramp + assert wait_until(lambda: len(log) >= 2 + 2 * 3) + refused = laser_delay.set(1.0) + assert refused.done and not refused.success + assert isinstance(refused.exception(), LaserDelayBusyError) + assert not refused._call_stop_on_failure # pylint: disable=protected-access + n_puts = len(log) + time.sleep(0.2) + assert len(log) > n_puts, "the running move was stopped by the refusal" + assert not first.done and laser_delay.moving + laser_delay.step_sleep.put(0.0) + first.wait(timeout=30) + assert laser_delay.readback.get() == 0.5 + + +def test_move_refused_when_the_amplitude_is_wrong(calibrated): + """ES4 IOC reboot (V1 = V2 = 0): the first ramp step would jump to vmax.""" + calibrated.phase_v1._read_pv.mock_data = 0.0 + calibrated.phase_v2._read_pv.mock_data = 0.0 + log = record_puts(calibrated) + status = calibrated.set(1.0) + with pytest.raises(LaserDelayHardwareError, match="amplitude"): + status.wait(timeout=5) + assert log == [] + with pytest.raises(LaserDelayHardwareError, match="amplitude"): + calibrated.calibrate(SCOPE) + + +def test_raising_vmax_at_runtime_does_not_jump_the_voltages(calibrated): + calibrated.vmax.set(5.0).wait(timeout=2) + log = record_puts(calibrated) + status = calibrated.set(1.0) + with pytest.raises(LaserDelayHardwareError, match="amplitude"): + status.wait(timeout=5) + assert log == [] + + +def test_readback_is_nan_while_moving(laser_delay): + laser_delay.step_sleep.put(0.02) + laser_delay.calibrate(SCOPE) + status = laser_delay.set(0.5) # crosses t = 0: about 200 steps of 0.02 s + assert wait_until(lambda: math.isnan(laser_delay.read()["laser_delay"]["value"])) + assert laser_delay.moving + status.wait(timeout=30) + assert laser_delay.read()["laser_delay"]["value"] == 0.5 + + +def test_clamped_delay_card_fails_the_move_before_the_phase_shifter(calibrated): + log = record_puts(calibrated) + original = calibrated.delay_fine.get + + def clamped(*args, **kwargs): + value = original(*args, **kwargs) + return min(value, 10) if kwargs.get("use_monitor") is False else value + + with mock.patch.object(calibrated.delay_fine, "get", side_effect=clamped): + status = calibrated.set(-0.5) # FINE above 10 + with pytest.raises(LaserDelayHardwareError, match="delay card reads"): + status.wait(timeout=5) + assert [pv for pv, _ in log] == ["X10DA-ES2-TIM:SET1-DELAY", "X10DA-ES2-TIM:SET1-FINE"] + assert math.isnan(calibrated.readback.get()) + assert calibrated.get_calibration_info()["verified"] # phase untouched: next move is fine + + +def test_clamped_phase_voltage_fails_the_move_and_refuses_the_next(calibrated): + original = calibrated.phase_v2.get + + def clamped(*args, **kwargs): + value = original(*args, **kwargs) + return value + 0.2 if kwargs.get("use_monitor") is False else value + + with mock.patch.object(calibrated.phase_v2, "get", side_effect=clamped): + status = calibrated.set(1.0) + with pytest.raises(LaserDelayHardwareError, match="phase shifter reads"): + status.wait(timeout=5) + assert not calibrated.get_calibration_info()["verified"] + with pytest.raises(LaserDelayNotCalibratedError): + calibrated.set(2.0) diff --git a/tests/tests_devices/test_laser_delay_golden.py b/tests/tests_devices/test_laser_delay_golden.py index 68132f3..1e8dac4 100644 --- a/tests/tests_devices/test_laser_delay_golden.py +++ b/tests/tests_devices/test_laser_delay_golden.py @@ -24,6 +24,7 @@ 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, @@ -41,6 +42,13 @@ 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} @@ -69,9 +77,6 @@ SCENARIOS = { ), "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]), - "voltages_touched_between_moves": Scenario( - STATE_A, 546.04 - 546.92, [1.0, {V1: 0.0, V2: 1.0}, 2.0, {V1: -0.7, V2: -0.7}, -3.0] - ), } @@ -186,6 +191,7 @@ def set_pv_values(device: LaserDelay, values: dict) -> None: 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) @@ -429,3 +435,23 @@ def test_phase_ramp_matches_script_for_many_moves(monkeypatch, tmp_path): 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 == [] -- 2.54.0 From e691a816ab6041e8efe9cefd31ee8efb9c3f37ae Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 12 Oct 2026 11:35:47 +0200 Subject: [PATCH 25/25] refactor(devices): move the simulated laser delay into its own module --- .../device_configs/laser_delay_sim.yaml | 2 +- superxas_bec/devices/laser_delay.py | 224 ---------------- superxas_bec/devices/laser_delay_sim.py | 252 ++++++++++++++++++ tests/tests_devices/test_laser_delay.py | 4 +- 4 files changed, 255 insertions(+), 227 deletions(-) create mode 100644 superxas_bec/devices/laser_delay_sim.py diff --git a/superxas_bec/device_configs/laser_delay_sim.yaml b/superxas_bec/device_configs/laser_delay_sim.yaml index 1f2ebf0..fb24026 100644 --- a/superxas_bec/device_configs/laser_delay_sim.yaml +++ b/superxas_bec/device_configs/laser_delay_sim.yaml @@ -13,7 +13,7 @@ laser_delay: readoutPriority: baseline description: Simulated laser delay in ns (no EPICS) - deviceClass: superxas_bec.devices.laser_delay.SimLaserDelay + deviceClass: superxas_bec.devices.laser_delay_sim.SimLaserDelay deviceConfig: step_sleep: 0.01 calibration_file: ~/.superxas_bec/laser_delay_sim_calibration.json diff --git a/superxas_bec/devices/laser_delay.py b/superxas_bec/devices/laser_delay.py index 999e97c..9b78289 100644 --- a/superxas_bec/devices/laser_delay.py +++ b/superxas_bec/devices/laser_delay.py @@ -48,7 +48,6 @@ from __future__ import annotations import contextlib import datetime import functools -import inspect import json import math import os @@ -347,10 +346,6 @@ class _HardwareEpicsSignal(_DeviceWritesOnly, EpicsSignal): """EPICS signal of the delay card or the phase shifter (written only by the device).""" -class _HardwareSoftSignal(_DeviceWritesOnly, CheckedSignal): - """Soft stand-in for a hardware PV in the simulation (written only by the device).""" - - class ComputedPhaseSignal(SignalRO): """Read-only signal whose value is computed by the parent device on every read. @@ -1543,222 +1538,3 @@ class LaserDelay(PSIDeviceBase, PositionerBase): pass raise return True - - -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__ -) diff --git a/superxas_bec/devices/laser_delay_sim.py b/superxas_bec/devices/laser_delay_sim.py new file mode 100644 index 0000000..d0cf2be --- /dev/null +++ b/superxas_bec/devices/laser_delay_sim.py @@ -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__ +) diff --git a/tests/tests_devices/test_laser_delay.py b/tests/tests_devices/test_laser_delay.py index f2a9a4f..0c98d8e 100644 --- a/tests/tests_devices/test_laser_delay.py +++ b/tests/tests_devices/test_laser_delay.py @@ -37,7 +37,6 @@ from superxas_bec.devices.laser_delay import ( LaserDelayHardwareError, LaserDelayNotCalibratedError, LaserDelayPersistenceError, - SimLaserDelay, delay_card_setting, fold_phase_delta, phase_from_voltages, @@ -45,6 +44,7 @@ from superxas_bec.devices.laser_delay import ( voltages_for_phase, wrap_phase, ) +from superxas_bec.devices.laser_delay_sim import SimLaserDelay CONFIG_DIR = Path(__file__).resolve().parents[2] / "superxas_bec" / "device_configs" SCOPE = 546.04 - 546.92 @@ -301,7 +301,7 @@ def test_yaml_entries_map_onto_constructor_arguments(): entry = standard["laser_delay"] assert entry["deviceClass"] == "superxas_bec.devices.laser_delay.LaserDelay" assert entry["onFailure"] == "buffer" - assert sim["laser_delay"]["deviceClass"] == "superxas_bec.devices.laser_delay.SimLaserDelay" + assert sim["laser_delay"]["deviceClass"] == "superxas_bec.devices.laser_delay_sim.SimLaserDelay" for config in (entry, sim["laser_delay"]): with mock.patch.object(ophyd, "cl") as mock_cl: mock_cl.get_pv = MockPV -- 2.54.0