diff --git a/csaxs_bec/devices/epics/falcon_csaxs.py b/csaxs_bec/devices/epics/falcon_csaxs.py index 1f5487de..23a27cf3 100644 --- a/csaxs_bec/devices/epics/falcon_csaxs.py +++ b/csaxs_bec/devices/epics/falcon_csaxs.py @@ -3,6 +3,7 @@ import enum import os import threading +import time from typing import Literal from bec_lib.file_utils import get_full_path @@ -65,6 +66,9 @@ class FalconcSAXS(PSIDeviceBase, FalconControl): MIN_READOUT (float) : Minimum readout time for the detector """ + # Methods exposed to the BEC client, i.e. callable as dev.falcon.() + USER_ACCESS = ["prime", "is_primed"] + # specify minimum readout time for detector MIN_READOUT = 3e-3 _pv_timeout = 3 # Timeout for PV operations in seconds @@ -127,6 +131,110 @@ class FalconcSAXS(PSIDeviceBase, FalconControl): # Sets the number of pixels/spectra in the buffer self.pixels_per_buffer.put(self._value_pixel_per_buffer) + def is_primed(self) -> bool: + """ + Check whether the HDF5 plugin has already been primed. + + The plugin only knows the array dimensions and data type once a single NDArray + has passed through it. Before that, its ArraySize readbacks are all zero and + ophyd refuses to stage it. + + Returns: + bool: True if the plugin is primed, False otherwise. + """ + return sum(self.hdf5.array_size.get()) > 0 + + def prime(self, timeout: float = 5) -> None: + """ + Prime the HDF5 plugin by pushing a single spectrum through it. + + The areaDetector file plugin refuses to be staged until it has seen one NDArray, + raising ophyd's UnprimedPlugin. That state lives in the IOC, so this has to be + done once after every restart of the SITORO IOC, and survives restarts of BEC. + + The generic HDF5Plugin.warmup() cannot be used here as it drives 'parent.cam', + which the Falcon (an ADBase, not a DetectorBase) does not have. Instead the + detector is briefly switched to user-advanced pixels, so that no external gate + signal is needed and a missing trigger cable cannot mask the priming. No file + is written. + + The trigger configuration is restored afterwards, also if the priming fails. + + Args: + timeout (float): Time in seconds to wait for the array to reach the plugin. + Defaults to 5. + + Raises: + FalconError: If the plugin is still unprimed after the timeout. + """ + if self.is_primed(): + logger.info(f"HDF5 plugin of {self.name} is already primed.") + return + + # NOTE on_stage does not touch the trigger configuration, only on_connected does. + # Restoring it here is therefore not optional; a Falcon left in USER advance mode + # silently ignores the gate and starves every subsequent scan. + collect_mode = self.collect_mode.get() + pixel_advance_mode = self.pixel_advance_mode.get() + ignore_gate = self.ignore_gate.get() + pixels_per_buffer = self.pixels_per_buffer.get() + + with self._lock: + try: + self.on_stop() + self.hdf5.enable.put(1) + # Prime only; no file should be written. + self.hdf5.capture.put(0) + + self.set_trigger( + mapping_mode=MappingSource.MAPPING, + trigger_source=TriggerSource.USER, + ignore_gate=1, + ) + self.pixels_per_buffer.put(1) + self.pixels_per_run.put(1) + self.preset_real_time.put(0.1) + + self.start_all.put(1) + # The buffer is only emitted once it is full. The IOC may refuse a buffer + # size of 1, so advance as many pixels as it actually accepted. + for _ in range(int(self.pixels_per_buffer.get()) + 1): + self.next_pixel.put(1) + + if not self._wait_for_primed(timeout): + raise FalconError( + f"HDF5 plugin of {self.name} is still unprimed after {timeout}s. " + "Check that the HDF5 plugin is enabled and that nd_array_mode is set." + ) + logger.info( + f"HDF5 plugin of {self.name} primed: array_size={self.hdf5.array_size.get()}" + ) + finally: + self.stop_all.put(1) + self.set_trigger( + mapping_mode=collect_mode, + trigger_source=pixel_advance_mode, + ignore_gate=ignore_gate, + ) + self.pixels_per_buffer.put(pixels_per_buffer) + self.erase_all.put(1) + + def _wait_for_primed(self, timeout: float) -> bool: + """Poll the plugin's array size until it reports a non-zero shape. + + Args: + timeout (float): Maximum time to wait in seconds. + + Returns: + bool: True if the plugin reported a non-zero array size within the timeout. + """ + start = time.time() + while time.time() - start < timeout: + if self.is_primed(): + return True + time.sleep(0.1) + return self.is_primed() + def _initialize_detector_backend(self) -> None: """Initialize the detector backend for Falcon.""" # Enable HDF5 plugin diff --git a/tests/tests_devices/test_falcon_csaxs.py b/tests/tests_devices/test_falcon_csaxs.py index 4069ba6f..c7ca8950 100644 --- a/tests/tests_devices/test_falcon_csaxs.py +++ b/tests/tests_devices/test_falcon_csaxs.py @@ -16,6 +16,7 @@ from ophyd_devices.tests.utils import patched_device from csaxs_bec.devices.epics.falcon_csaxs import ( ACQUIRESTATUS, FalconcSAXS, + FalconError, MappingSource, TriggerSource, ) @@ -230,3 +231,75 @@ def test_falcon_complete(mock_det: FalconcSAXS): hinted_h5_entries=None, metadata={}, ) + + +def test_falcon_is_primed(mock_det: FalconcSAXS): + """The plugin counts as primed as soon as any array dimension is non-zero.""" + falcon = mock_det + + with mock.patch.object(falcon.hdf5.array_size, "get", return_value=(0, 0, 0)): + assert falcon.is_primed() is False + + with mock.patch.object(falcon.hdf5.array_size, "get", return_value=(0, 1, 3000)): + assert falcon.is_primed() is True + + +def test_falcon_prime_skips_when_already_primed(mock_det: FalconcSAXS): + """Priming an already primed plugin must not touch the detector.""" + falcon = mock_det + + with ( + mock.patch.object(falcon, "is_primed", return_value=True), + mock.patch.object(falcon, "set_trigger") as mock_set_trigger, + mock.patch.object(falcon, "on_stop") as mock_on_stop, + ): + falcon.prime() + mock_set_trigger.assert_not_called() + mock_on_stop.assert_not_called() + + +def test_falcon_prime(mock_det: FalconcSAXS): + """Priming advances a pixel by hand and restores the trigger configuration.""" + falcon = mock_det + + falcon.set_trigger( + mapping_mode=MappingSource.MAPPING, trigger_source=TriggerSource.GATE, ignore_gate=0 + ) + falcon.pixels_per_buffer.put(20) + + with ( + mock.patch.object(falcon, "is_primed", side_effect=[False, True]), + mock.patch.object(falcon.next_pixel, "put") as mock_next_pixel, + ): + falcon.prime() + + # A gate signal must not be required, so the pixel is advanced from software. + assert mock_next_pixel.call_count > 0 + assert falcon.hdf5.capture.get() == 0 + + # Trigger configuration restored, otherwise the next scan silently starves. + assert falcon.collect_mode.get() == MappingSource.MAPPING + assert falcon.pixel_advance_mode.get() == TriggerSource.GATE + assert falcon.ignore_gate.get() == 0 + assert falcon.pixels_per_buffer.get() == 20 + + +def test_falcon_prime_restores_config_on_failure(mock_det: FalconcSAXS): + """A failed priming must still leave the detector in its original trigger config.""" + falcon = mock_det + + falcon.set_trigger( + mapping_mode=MappingSource.MAPPING, trigger_source=TriggerSource.GATE, ignore_gate=0 + ) + falcon.pixels_per_buffer.put(20) + + with ( + mock.patch.object(falcon, "is_primed", return_value=False), + mock.patch.object(falcon, "_wait_for_primed", return_value=False), + ): + with pytest.raises(FalconError): + falcon.prime() + + assert falcon.pixel_advance_mode.get() == TriggerSource.GATE + assert falcon.ignore_gate.get() == 0 + assert falcon.pixels_per_buffer.get() == 20