feat(falcon): add prime() to arm the HDF5 plugin after an IOC restart #307

Merged
menzel merged 1 commits from feat/falcon into main 2026-08-31 15:52:10 +02:00
2 changed files with 181 additions and 0 deletions
+108
View File
@@ -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.<name>()
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
+73
View File
@@ -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