13. Preserve Hardware State in Implicit Step-Scan Trigger#

Date: 2026-03-06

Status#

Accepted

Context#

When a StandardDetector has not been explicitly prepared with a TriggerInfo before the first call to trigger() (e.g. in a bp.count or bps.trigger_and_read without a preceding bps.prepare), StandardDetector.trigger() performs an implicit prepare using a default TriggerInfo().

Before this decision the implicit prepare always used TriggerInfo() — a bare default with collections_per_event=1 and number_of_events=1. For EPICS areaDetector (AD) drivers this meant that num_images was unconditionally reset to 1 on the driver before every step-scan point, even if a preceding fly scan had left num_images set to a larger value.

This had two undesirable consequences:

  1. Surprising behaviour after manual setup. If an operator sets acquire_time, then runs a count plan (which calls trigger() without a prepare()), then the detector will not change it. However if they set num_images then it will be overridden with 1, discarding the operator’s hardware configuration without warning.

  2. Inconsistency with ophyd-sync. ophyd-sync devices do not alter detector hardware state that the user did not explicitly request changing. ophyd-async should be a drop-in replacement.

Decision#

Opt-in default_trigger_info() on DetectorTriggerLogic#

Add an optional default_trigger_info(self) -> TriggerInfo method to DetectorTriggerLogic. If it doesn’t exist then keep the existing behaviour of using TriggerInfo()

trigger_info_from_num_images() free function for AD detectors#

A free async helper function is provided in ophyd_async.epics.adcore:

async def trigger_info_from_num_images(driver: ADBaseIO) -> TriggerInfo:
    num = await driver.num_images.get_value()
    return TriggerInfo(collections_per_event=max(1, num))

All EPICS areaDetector TriggerLogic subclasses implement default_trigger_info by delegating to this function, reading back the current num_images value from the driver and returning it as collections_per_event. This preserves the hardware state rather than resetting it.

Opt-in via environment variable#

Because changing the default behaviour of trigger() is a breaking change for existing deployments, the new behaviour is opt-in. Set the environment variable OPHYD_ASYNC_PRESERVE_DETECTOR_STATE=YES.

When the variable is not set (or set to any value other than YES), trigger() without a prior prepare() falls back to the original TriggerInfo() default, preserving backward compatibility.

The env-var approach was chosen to minimise the code change while still exposing the new behaviour as a supported path — default_trigger_info() is placed on the class that creates the TriggerInfo rather than on the detector itself, keeping the logic close to where it belongs.

Consequences#

  • Step scans no longer reset num_images on AD detectors when no explicit prepare() is called and OPHYD_ASYNC_PRESERVE_DETECTOR_STATE=YES is set, making ophyd-async a closer drop-in replacement for ophyd-sync.

  • A fly scan that leaves num_images=500 on the driver will cause a subsequent implicit step scan (with the env var set) to capture 500 frames per trigger point instead of 1. This is the correct behaviour (honour the hardware state) but may surprise users who expected the scan to reset the detector. Plans that care about num_images should call bps.prepare(det, TriggerInfo(...)) explicitly.

  • Without the environment variable the behaviour is unchanged from before.