From d3f7871855bf3646bf123fe75d89b6ff98b55d02 Mon Sep 17 00:00:00 2001 From: menzel Date: Mon, 31 Aug 2026 15:43:18 +0200 Subject: [PATCH] feat(falcon): add prime() to arm the HDF5 plugin after an IOC restart The areaDetector file plugin refuses to be staged until one NDArray has passed through it, so every restart of the SITORO IOC leaves the Falcon raising ophyd's UnprimedPlugin at the start of a scan. The generic HDF5Plugin.warmup() cannot be used, as it drives parent.cam, which the Falcon does not have. prime() pushes a single spectrum through the plugin using user-advanced pixels, so no external gate signal is required and a disconnected trigger cable cannot mask the priming. No file is written. The trigger configuration is captured beforehand and restored in a finally block. on_stage never resets pixel_advance_mode, ignore_gate or pixels_per_buffer -- only on_connected does -- so a prime that bailed out halfway would otherwise leave the detector deaf to the gate and silently starve every subsequent scan. prime() and is_primed() are exposed via USER_ACCESS, so they are callable from the BEC client as dev.falcon.prime(). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KLnmUurqcNd1FiDY5M2uZr --- csaxs_bec/devices/epics/falcon_csaxs.py | 108 +++++++++++++++++++++++ tests/tests_devices/test_falcon_csaxs.py | 73 +++++++++++++++ 2 files changed, 181 insertions(+) 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 -- 2.54.0