Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
- `StandardReadable` now keeps one format per registered child, keyed by the child, so registering a child again replaces its format instead of adding a second contribution, and the order of `hints` is the order children were first registered. The private `_describe_config_funcs`, `_read_config_funcs` and `_has_hints` tuples are gone: a subclass that added to them now adds a hook to `_describe_configuration_funcs`, `_read_configuration_funcs` or `_hints_funcs` (see ADR 0023).
- The deprecated `ConfigSignal` and `HintedSignal` aliases are removed from `ophyd_async.core`; use `StandardReadableFormat.CONFIG_SIGNAL`, `HINTED_SIGNAL` or `HINTED_UNCACHED_SIGNAL`. `StandardReadable.add_readables()` is deprecated in favour of `set_readable_format()` on each device.
- `store_settings()` writes the readable format changes of the Device tree under the reserved key `<READABLE_FORMATS>`, and `apply_settings()` resets the tree to its class formats before applying them. Settings files stored before this have no such key and leave formats alone. `retrieve_settings()` warns about and drops any other `<...>` key.
- `StandardDetector` is now a `StandardReadable`: signals read in a step scan or reported as configuration are registered with `set_readable_format()`, as on any other `StandardReadable`, and what the data logics produce is added to `read()`, `describe()` and `hints` alongside them. `add_config_signals()` is deprecated in favour of `set_readable_format(signal, StandardReadableFormat.CONFIG_SIGNAL)`. A detector with no hinted fields now reports `hints` as `{}` rather than `{"fields": []}`.
- `DetectorTriggerLogic.config_sigs()` is removed: `get_deadtime()` is given the value of every signal the detector reports as configuration, including those declared on children registered as `CHILD`, so a signal that determines deadtime must be registered as `CONFIG_SIGNAL`.
- The `prepare_single` data logic tier is removed, with `ReadableDataProvider`, `SignalDataProvider` and `adcore.PluginSignalDataLogic`. Register a plugin scalar such as `NDStatsIO.total` with `set_readable_format(signal, StandardReadableFormat.HINTED_SIGNAL)` instead.
- The areaDetector driver and plugin classes (`ADBaseIO`, `NDROIIO`, `NDStatsIO` and `NDROIStatNIO`) are now `StandardReadable`s that declare their own configuration and read signals, so registering a plugin as `CHILD` of an `AreaDetector` puts them in `read()` and `read_configuration()`.
- `ADAcquireLogic` sets `WaitForPlugins` on `stage()`, from its new `wait_for_plugins` argument (default on), rather than `AreaDetector` hiding it, so a step scan reads plugin scalars for the frame it has just taken.

### Added

Expand Down
4 changes: 4 additions & 0 deletions docs/explanations/decisions/0012-detector-rewrite.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ Date: 2026-01-20

Accepted

Partly superseded by [ADR 0024](0024-standard-detector-is-a-standard-readable.md), which
made `StandardDetector` a `StandardReadable` and removed the `prepare_single` tier along
with `ReadableDataProvider`.

## Context

The original `StandardDetector` implementation had several architectural issues:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 24. StandardDetector is a StandardReadable

Date: 2026-10-30

## Status

Accepted

## Context

[ADR 0012](0012-detector-rewrite.md) gave `StandardDetector` its own ways to put a signal
in a step scan: `add_config_signals` for configuration, and the `prepare_single` data
logic tier with `ReadableDataProvider` for values read once per event. Every other Device
does both by registering the signal on a `StandardReadable`, and
[ADR 0023](0023-runtime-readable-format.md) made that registry keyed by Device and
changeable at runtime, which is the per-plugin opt-in the #1395 reviewers asked for.

## Decision

### `StandardDetector` inherits `StandardReadable`

A detector's step-scan signals are registered like any other Device's. The detector does not
override `read()`, `describe()` or `hints`: each verb gathers a tuple of callables and the
detector appends its own, as `StandardFlyable` does to `_stage_funcs`, because overriding
meant re-deriving the registered children's contributions in each verb, which would drift.
`add_config_signals` becomes a deprecated wrapper.

A trigger logic computes deadtime from all the detector's configuration signals rather than
nominating its own, as the values that determine deadtime ought to be recorded anyway. They
come from the registry, not `read_configuration()`, so read-only signals such as
`ADBaseIO.model` count.

`AreaDetector` registers its driver but no plugin: a plugin is opted in with
`set_readable_format(plugin, StandardReadableFormat.CHILD)` and out with `None`, the
per-plugin, reversible choice the #1395 reviewers asked for.

### The `prepare_single` data logic tier goes

Registering a signal now gets one value per event into `read()` for any Device, which was
all [ADR 0012](0012-detector-rewrite.md)'s `prepare_single` tier did. It is deleted with its
providers rather than kept as a second way to do one thing, the one that drops itself with a
warning once a scan asks for more than one collection.

## Consequences

- A detector's signals can be switched between techniques, stored and restored like any
other Device's ([ADR 0023](0023-runtime-readable-format.md)).
- A signal that determines deadtime must be registered as configuration.
- Out-of-tree `prepare_single` data logics must register their signals instead; there is no
shim.
22 changes: 14 additions & 8 deletions docs/how-to/implement-ad-detector.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ Implement methods for each trigger mode your detector supports:

Only implement the prepare methods for trigger modes your detector actually supports. The detector will automatically report which trigger types are available based on which methods are implemented.

If the detector has configuration values that should be captured in the scan then implement:
- `config_sigs()` - Return the set of signals that should appear in read_configuration()
Configuration values that should be captured in the scan are declared on the IO class,
with [](#StandardReadableFormat.CONFIG_SIGNAL), exactly as on any other
[](#StandardReadable). The trigger logic does not nominate them.

If you support external triggering you should also implement:
- `get_deadtime(config_values)` - Calculate the minimum time between exposures based on configuration values
- `get_deadtime(config_values)` - Calculate the minimum time between exposures. It is
given the value of *every* signal the detector reports as configuration, so a signal
it needs must be declared with [](#StandardReadableFormat.CONFIG_SIGNAL).

To preserve hardware state in plans like [`bp.count`](#bluesky.plans.count) and
[`bps.trigger_and_read`](#bluesky.plan_stubs.trigger_and_read) that call `trigger()`
Expand Down Expand Up @@ -116,12 +119,15 @@ For example, for ADAravis:
The composition-based architecture makes it possible to add multiple data outputs to a detector. After creating the detector, you can call `add_detector_logics()` to add additional data sources:

### Reading stats plugins alongside file writing
A plugin scalar needs no data logic: a detector is a [](#StandardReadable), so register
the signal and it appears in `read()` alongside whatever the file writer produces.

```python
from ophyd_async.epics.adcore import PluginSignalDataLogic
from ophyd_async.core import StandardReadableFormat as Format

det = adaravis.AravisDetector(prefix, path_provider)
# Add stats total as a readable signal in events
det.add_detector_logics(adcore.PluginSignalDataLogic(det.driver, det.stats.total))
det = adaravis.AravisDetector(prefix, path_provider, plugins={"stats": stats})
# Add stats total as a hinted signal in events
det.set_readable_format(det.stats.total, Format.HINTED_UNCACHED_SIGNAL)
```

### Multiple HDF writers for different ROIs
Expand Down Expand Up @@ -267,4 +273,4 @@ The detector will now support:
- Step scanning with `trigger()`
- Fly scanning with `kickoff()` and `complete()`
- Automatic handling of file writing and StreamAsset document emission
- Configuration signal reporting based on your trigger logic
- Configuration signal reporting from the signals declared with `StandardReadableFormat.CONFIG_SIGNAL` on the IO classes
2 changes: 1 addition & 1 deletion docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ effect on ophyd-async itself.
Controls the implicit prepare performed by
[`StandardDetector.trigger()`](#ophyd_async.core.StandardDetector.trigger) when
[`prepare()`](#ophyd_async.core.StandardDetector.prepare) has not been called since
the last [`stage()`](#ophyd_async.core.StandardDetector.stage).
the last `stage()`.

| Value | Behaviour |
|-------|-----------|
Expand Down
6 changes: 4 additions & 2 deletions docs/tutorials/implementing-detectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,10 @@ If we wanted to support external triggering, we would also implement:
- `prepare_level()` for external level/gate triggering (high level duration determines exposure)

We could also implement:
- `get_deadtime()` to calculate the minimum time between exposures based on configuration
- `config_sigs()` to return signals that should appear in read_configuration()
- `get_deadtime()` to calculate the minimum time between exposures. It is given the value
of every signal the detector reports as configuration, so a signal it needs must be
declared with [](#StandardReadableFormat.CONFIG_SIGNAL) like on any other
[](#StandardReadable): the trigger logic does not nominate signals itself
- `default_trigger_info()` to return the [](#TriggerInfo) to use when `trigger()` is called without a preceding `prepare()` (governed by [](#OPHYD_ASYNC_PRESERVE_DETECTOR_STATE)).

### `BlobAcquireLogic`
Expand Down
4 changes: 0 additions & 4 deletions src/ophyd_async/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
soft_command,
)
from ._data_providers import (
ReadableDataProvider,
SignalDataProvider,
StreamableDataProvider,
StreamResourceDataProvider,
StreamResourceInfo,
Expand Down Expand Up @@ -306,9 +304,7 @@ def __getattr__(name):
"AutoMaxIncrementingPathProvider",
"UUIDFilenameProvider",
# Data Providers
"ReadableDataProvider",
"StreamableDataProvider",
"SignalDataProvider",
"StreamResourceInfo",
"StreamResourceDataProvider",
# Flyer
Expand Down
27 changes: 1 addition & 26 deletions src/ophyd_async/core/_data_providers.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,14 @@
from abc import abstractmethod
from collections.abc import AsyncIterator, Sequence
from dataclasses import dataclass
from typing import Any

from bluesky.protocols import Reading, StreamAsset
from bluesky.protocols import StreamAsset
from event_model import ComposeStreamResource, DataKey, StreamRange

from ._signal import SignalR, SignalW
from ._utils import ConfinedModel


class ReadableDataProvider:
@abstractmethod
async def make_datakeys(self) -> dict[str, DataKey]:
"""Return a DataKey for each Readable that produces a Reading.

Called before the first exposure is taken.
"""

@abstractmethod
async def make_readings(self) -> dict[str, Reading]:
"""Read the Signals and return their values."""


@dataclass
class SignalDataProvider(ReadableDataProvider):
signal: SignalR

async def make_datakeys(self) -> dict[str, DataKey]:
return await self.signal.describe()

async def make_readings(self) -> dict[str, Reading]:
return await self.signal.read(cached=False)


class StreamableDataProvider:
collections_written_signal: SignalR[int]

Expand Down
Loading