How to write a camera for continuous acquisition¶
In continuous acquisition,
a camera takes frames one after another whether or not a plan runs, so a live
view can show them at any time. On this page you write such a camera as an
ophyd-async device, and plans read its
latest frame into their events or write its frames to a file without stopping
the acquisition. The plans that read are those of bluesky, and the one that
writes is a few lines you add. The last section does the same for a camera
served by an areaDetector
IOC.
Prerequisites¶
You need a service that runs the camera, and How to write a service shows how to write one and declare it. The device on this page expects the service to serve these process variables under its prefix, over PVAccess:
| Process variable | Holds |
|---|---|
Acquire |
true while the camera acquires. A write to it completes at once, without waiting for the acquisition to end. |
Exposure |
The exposure time, in seconds. |
Image |
The latest frame, as an NTNDArray. It holds a frame of the right shape from the start. |
ImageCount |
The number of frames taken so far. It goes up by one each time Image changes. |
Capture |
true while frames go to the file. The service sets it back to false after NumCapture frames. |
NumCapture |
The number of frames to write, or 0 to write until Capture is set to false. |
FilePath |
The file:// address of the next file. Writing it sets NumCaptured to 0. |
NumCaptured |
The number of frames written to the current file. |
The service writes the frames into the dataset /data of an HDF5 file.
PVAccess carries a frame together with its shape, which
Channel Access can't, so the
session speaks PVAccess; see
Name the transport.
Write the camera¶
The camera reads the latest frame when a plan asks for one. A plan starts the acquisition if it was stopped and leaves it as it found it, which a small class does for the camera and, further down, for the writer:
@dataclass
class AcquireForPlan:
"""Start a camera for a plan, and leave it as the plan found it."""
acquire: SignalRW[bool]
was_acquiring: bool = False
async def start(self) -> None:
self.was_acquiring = await self.acquire.get_value()
if not self.was_acquiring:
await self.acquire.set(True)
async def restore(self) -> None:
if not self.was_acquiring:
await self.acquire.set(False)
The camera itself reads the service's process variables:
class Camera(StandardReadable):
"""A camera that acquires continuously: plans read its latest frame."""
def __init__(self, prefix: str, name: str = "") -> None:
with self.add_children_as_readables(
StandardReadableFormat.HINTED_UNCACHED_SIGNAL
):
self.image = epics_signal_r(np.ndarray, f"pva://{prefix}Image")
with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
self.exposure = epics_signal_rw(float, f"pva://{prefix}Exposure")
self.acquire = epics_signal_rw(bool, f"pva://{prefix}Acquire")
self.image_count = epics_signal_r(int, f"pva://{prefix}ImageCount")
self.acquisition = AcquireForPlan(self.acquire)
super().__init__(name=name)
@AsyncStatus.wrap
async def stage(self) -> None:
await self.acquisition.start()
await super().stage()
@AsyncStatus.wrap
async def unstage(self) -> None:
await super().unstage()
await self.acquisition.restore()
@AsyncStatus.wrap
async def trigger(self) -> None:
seen = await self.image_count.get_value(cached=False)
# frame seen + 1 may have started its exposure before this call
await wait_for_value(self.image_count, lambda n: n >= seen + 2, timeout=10)
HINTED_UNCACHED_SIGNALmakesimagea hint of the camera, so a plan reads it and a live view finds it without knowing its name.UNCACHEDmakes each read ask the service for the frame, instead of returning the last value the device received.CONFIG_SIGNALrecords the exposure time once, in the description of the run, so the events don't repeat it.stageandunstageare what a plan calls before and after it uses the camera; see stage.triggerwaits for the second frame after it is called. The first one may have started its exposure before the call, while a motor was still moving, so it could show the sample in the wrong place.
Write the writer¶
The writer saves frames to a file while the acquisition goes on. It is an
ophyd-async StandardDetector, which takes three parts: one prepares the
number of frames, one starts and stops the capture, and one says where the
frames go:
@dataclass
class CaptureTrigger(DetectorTriggerLogic):
num_capture: SignalRW[int]
async def prepare_internal(
self, num: int, livetime: float, deadtime: float
) -> None:
await self.num_capture.set(num)
@dataclass
class CaptureAcquire(DetectorAcquireLogic):
capture: SignalRW[bool]
num_capture: SignalRW[int]
acquisition: AcquireForPlan
async def ensure_ready(self) -> None:
await self.acquisition.start()
await self.capture.set(False)
async def start_acquiring(self) -> None:
await self.capture.set(True)
async def wait_for_idle(self) -> None:
if await self.num_capture.get_value():
await wait_for_value(self.capture, False, timeout=None)
else:
await self.capture.set(False)
async def ensure_stopped(self) -> None:
await self.capture.set(False)
await self.acquisition.restore()
@dataclass
class CaptureData(DetectorDataLogic):
image: SignalR[np.ndarray]
file_path: SignalRW[str]
num_captured: SignalR[int]
path_provider: PathProvider
async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvider:
info = self.path_provider(datakey_name)
uri = f"{info.directory_uri}{info.filename}.h5"
await self.file_path.set(uri)
await wait_for_value(self.num_captured, 0, timeout=5)
frame = (await self.image.describe())[self.image.name]
shape = tuple(size or 0 for size in frame["shape"])
return StreamResourceDataProvider(
uri=uri,
resources=[
StreamResourceInfo(
data_key=datakey_name,
shape=shape,
chunk_shape=(1, *shape),
dtype_numpy=frame["dtype_numpy"],
parameters={"dataset": "/data"},
)
],
mimetype="application/x-hdf5",
collections_written_signal=self.num_captured,
)
CaptureTriggersetsNumCaptureto the number of frames the plan asks for.CaptureAcquirestarts the acquisition when the writer is staged, and turns off a capture an earlier plan left on. It puts the acquisition back as it was when the writer is unstaged, through the sameAcquireForPlan.wait_for_idlewaits until the service has written the frames asked for, or stops the capture at once when none were asked for.CaptureDatasets the file, then reads the shape and type of a frame fromimage, so the documents of the run describe what the file holds. It tellsblueskythe frames are in/dataof an HDF5 file, and how many are written so far.
The writer builds the three parts from its own process variables:
class CameraWriter(StandardDetector):
"""Write frames to a file while the camera acquires, and leave it acquiring."""
def __init__(
self, prefix: str, path_provider: PathProvider, name: str = ""
) -> None:
self.acquire = epics_signal_rw(bool, f"pva://{prefix}Acquire")
self.image = epics_signal_r(np.ndarray, f"pva://{prefix}Image")
self.capture = epics_signal_rw(bool, f"pva://{prefix}Capture")
self.num_capture = epics_signal_rw(int, f"pva://{prefix}NumCapture")
self.file_path = epics_signal_rw(str, f"pva://{prefix}FilePath")
self.num_captured = epics_signal_r(int, f"pva://{prefix}NumCaptured")
super().__init__(name=name)
self.add_detector_logics(
CaptureTrigger(self.num_capture),
CaptureAcquire(
self.capture, self.num_capture, AcquireForPlan(self.acquire)
),
CaptureData(self.image, self.file_path, self.num_captured, path_provider),
)
A writer inside the camera is never staged
The plans of bluesky stage the device at the top of the tree each
device belongs to. A writer held as an attribute of the camera is never
staged itself, so a second write reuses what the first one prepared and
times out. Declare the writer as a device of its own.
Declare them¶
Declare the camera and the writer beside the service, which gives both its prefix:
class MyApp(QtSession):
config: ClassVar[dict[str, Any]] = {"services": {"transport": "pv-access"}}
camera_service: Annotated[
AsService, Launch("mylab.services.camera", ready="camera ready", prefix="CAM:")
]
camera: Annotated[AsDevice[Camera], Declare(service="camera_service")]
camera_writer: Annotated[AsDevice[CameraWriter], Declare(service="camera_service")]
live: AsPresenter[LiveFrames]
def wire(self) -> Iterator[Link]:
for device in self.devices.values():
for signal in hinted_arrays(device):
yield signal, self.live.show
if __name__ == "__main__":
MyApp().run()
wire links every hinted signal that holds an array to the live view, which
the next section writes.
Show the frames live¶
A live view finds the frames through hints, so it works for any camera, including one whose image signal has another name. The function below returns the hinted signals of a device whose values are arrays:
def hinted_arrays(device: Device) -> list[SignalR[Any]]:
"""Return the hinted signals of `device` that hold arrays, such as images."""
hinted = (
set(device.hints.get("fields", [])) if isinstance(device, HasHints) else set()
)
return [
signal
for signal in walk_devices(device).values()
if isinstance(signal, SignalR)
and signal.name in hinted
and signal.datatype is np.ndarray
]
The camera sends every frame it takes, and a screen needs far fewer, so the presenter passes a frame on only when enough time has gone by since the last one of the same signal:
class LiveFrames:
"""Pass on the latest frame of every camera, at most `rate` times a second."""
sig_frame = Signal(str, object)
def __init__(self, name: str, rate: float = 10.0) -> None:
self.name = name
self.period = 1 / rate
self.shown: dict[str, float] = {}
@slot
def show(self, reading: dict[str, Reading[Any]]) -> None:
now = time.monotonic()
for key, value in reading.items():
if now - self.shown.get(key, 0.0) >= self.period:
self.shown[key] = now
self.sig_frame.emit(key, value["value"])
A view connected to sig_frame gets the name of the signal and the frame.
Frames arrive only while the camera acquires. A plan starts the acquisition
for as long as it runs, and outside a plan you set acquire, for instance
from a button of a view of your own.
Run plans¶
Plans of bluesky read the camera as they would any detector. Each one stages
the camera, so it leaves the acquisition as it found it:
| To | Run |
|---|---|
| Take frames in events | bp.count([camera], num=3) |
| Take a frame at each position of a motor | bp.rel_scan([camera], motor, 0, 2, 3) |
| Read the camera in a loop of your own | bps.trigger_and_read([camera, motor]) inside bpp.stage_wrapper(..., [camera]) |
| Write frames to a file only | write_frames([camera_writer], 5) |
trigger_and_read doesn't stage the camera, so a loop of your own needs the
stage_wrapper around it. bluesky has no plan that prepares a writer and
declares its stream before collecting, which ophyd-async requires, so
writing takes this plan:
def write_frames(
writers: Sequence[StandardDetector], frames: int
) -> MsgGenerator[None]:
"""Write `frames` frames of each writer to a file while its camera runs."""
def capture() -> MsgGenerator[None]:
yield from bps.declare_stream(*writers, name="primary", collect=True)
yield from bps.kickoff_all(*writers, wait=True)
yield from bps.collect_while_completing(writers, writers, flush_period=0.5)
def prepare_and_capture() -> MsgGenerator[None]:
for writer in writers:
yield from bps.prepare(
writer, TriggerInfo(number_of_events=frames), wait=True
)
yield from bpp.run_wrapper(capture())
yield from bpp.stage_wrapper(prepare_and_capture(), writers)
collect_while_completing sends the frames written so far every half
second, so a long write shows progress in the documents of the run.
bp.fly collects without declaring the stream first, and fails with
Single nested data keys should be pre-declared.
Stage a device inside the plan that uses it
Run as a plan of its own, bps.stage(camera) leaves the camera staged,
and the RunEngine unstages it when
that plan ends without waiting for it to finish. The next plan then stages
the camera while it is still being unstaged. Stage a device in the plan
that uses it.
Use an areaDetector camera¶
An areaDetector IOC already does what the service above does, and
ophyd-async drives one in continuous acquisition with ContAcqDetector. The
device is a subclass of it, with two additions.
Needs an ophyd-async release after 0.21.3
ophyd-async 0.21.3 never sends the trigger that lets frames through to
the file, so every plan on this section's camera times out. This section
was checked against the main branch of ophyd-async on 24 October 2026,
before a release with the fix was out.
The IOC needs these plugins, under the names ContAcqDetector looks for:
cam1:, the driver, which acquires in continuous mode.CB1:, a circular buffer fed by the driver, which holds frames back until a plan asks for them.HDF1:, an HDF5 writer fed byCB1:.Pva1:, which serves the latest frame of the driver over PVAccess asPva1:Image.
The device reads Pva1:Image as its hinted signal, and starts the acquisition
when it is staged:
class SimCamera(ContAcqDetector):
"""An areaDetector camera that acquires continuously."""
def __init__(
self, prefix: str, path_provider: PathProvider, name: str = ""
) -> None:
self.image = epics_signal_r(np.ndarray, f"pva://{prefix}Pva1:Image")
super().__init__(prefix, ADWriterFactory.hdf(path_provider), name=name)
self.add_detector_logics(PluginSignalDataLogic(self.driver, self.image))
@AsyncStatus.wrap
async def stage(self) -> None:
self.was_acquiring = await self.driver.acquire.get_value()
if not self.was_acquiring:
await self.driver.image_mode.set(ADImageMode.CONTINUOUS)
# in continuous mode the put completes only when acquisition stops
await set_and_wait_for_value(
self.driver.acquire, True, wait_for_set_completion=False
)
await super().stage()
@AsyncStatus.wrap
async def unstage(self) -> None:
await super().unstage()
if not self.was_acquiring:
await self.driver.acquire.set(False)
PluginSignalDataLogicaddsimageto what the camera reads and to its hints, so the plans and the live view above find it as they find the image of the other camera.- The driver's
acquirewaits, by default, for the acquisition to end, which in continuous mode never happens.set_and_wait_for_valuewithwait_for_set_completion=Falsereturns once the driver reports it is acquiring. stagestarts the acquisition only when it was stopped, because a second start while the first is still waiting stalls the connection to the IOC; see the warning below.
Declare the IOC as an attached service, and link the hinted arrays to the live view as before:
class MyApp(QtSession):
detector_ioc: Annotated[AsService, Attach("SIM:")]
camera: Annotated[AsDevice[SimCamera], Declare(service="detector_ioc")]
live: AsPresenter[LiveFrames]
def wire(self) -> Iterator[Link]:
for device in self.devices.values():
for signal in hinted_arrays(device):
yield signal, self.live.show
if __name__ == "__main__":
MyApp().run()
The camera is its own writer, so write_frames([camera], 5) writes frames
during the acquisition, and bp.count, bp.rel_scan and the loop of the
table run as they do above. Each of them also writes the frames it reads to
the HDF5 file, since the camera always writes through HDF1:.
A second start stalls the connection
A Channel Access write that waits for its completion stays open until the
IOC reports it complete, which a start in continuous mode never does.
While one is open on a process variable, a second waiting write to the
same one stops every reply on that connection, and the plans on the camera
time out. Start the acquisition only when it is stopped, and stop it with
driver.acquire.set(False), which doesn't wait.
The frame travels over PVAccess and everything else over Channel Access. If
the IOC is on another network, the address you give Attach reaches Channel
Access only, so also list the IOC in EPICS_PVA_ADDR_LIST.
The examples in full¶
The camera, the writer and the live view
"""The session of the guide "How to write a camera for continuous acquisition"."""
from __future__ import annotations
import time
from collections.abc import Iterator, Sequence # noqa: TC003
from dataclasses import dataclass
from typing import Annotated, Any, ClassVar
import bluesky.plan_stubs as bps
import bluesky.preprocessors as bpp
import numpy as np
from bluesky.protocols import HasHints, Reading
from bluesky.utils import MsgGenerator # noqa: TC002
from ophyd_async.core import (
AsyncStatus,
DetectorAcquireLogic,
DetectorDataLogic,
DetectorTriggerLogic,
Device,
PathProvider,
SignalR,
SignalRW,
StandardDetector,
StandardReadable,
StandardReadableFormat,
StreamResourceDataProvider,
StreamResourceInfo,
TriggerInfo,
wait_for_value,
walk_devices,
)
from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
from psygnal import Signal
from redsun import AsDevice, AsPresenter, AsService, Declare, Launch, Link, slot
from redsun.qt import QtSession
@dataclass
class AcquireForPlan:
"""Start a camera for a plan, and leave it as the plan found it."""
acquire: SignalRW[bool]
was_acquiring: bool = False
async def start(self) -> None:
self.was_acquiring = await self.acquire.get_value()
if not self.was_acquiring:
await self.acquire.set(True)
async def restore(self) -> None:
if not self.was_acquiring:
await self.acquire.set(False)
@dataclass
class CaptureTrigger(DetectorTriggerLogic):
num_capture: SignalRW[int]
async def prepare_internal(
self, num: int, livetime: float, deadtime: float
) -> None:
await self.num_capture.set(num)
@dataclass
class CaptureAcquire(DetectorAcquireLogic):
capture: SignalRW[bool]
num_capture: SignalRW[int]
acquisition: AcquireForPlan
async def ensure_ready(self) -> None:
await self.acquisition.start()
await self.capture.set(False)
async def start_acquiring(self) -> None:
await self.capture.set(True)
async def wait_for_idle(self) -> None:
if await self.num_capture.get_value():
await wait_for_value(self.capture, False, timeout=None)
else:
await self.capture.set(False)
async def ensure_stopped(self) -> None:
await self.capture.set(False)
await self.acquisition.restore()
@dataclass
class CaptureData(DetectorDataLogic):
image: SignalR[np.ndarray]
file_path: SignalRW[str]
num_captured: SignalR[int]
path_provider: PathProvider
async def prepare_unbounded(self, datakey_name: str) -> StreamResourceDataProvider:
info = self.path_provider(datakey_name)
uri = f"{info.directory_uri}{info.filename}.h5"
await self.file_path.set(uri)
await wait_for_value(self.num_captured, 0, timeout=5)
frame = (await self.image.describe())[self.image.name]
shape = tuple(size or 0 for size in frame["shape"])
return StreamResourceDataProvider(
uri=uri,
resources=[
StreamResourceInfo(
data_key=datakey_name,
shape=shape,
chunk_shape=(1, *shape),
dtype_numpy=frame["dtype_numpy"],
parameters={"dataset": "/data"},
)
],
mimetype="application/x-hdf5",
collections_written_signal=self.num_captured,
)
class CameraWriter(StandardDetector):
"""Write frames to a file while the camera acquires, and leave it acquiring."""
def __init__(
self, prefix: str, path_provider: PathProvider, name: str = ""
) -> None:
self.acquire = epics_signal_rw(bool, f"pva://{prefix}Acquire")
self.image = epics_signal_r(np.ndarray, f"pva://{prefix}Image")
self.capture = epics_signal_rw(bool, f"pva://{prefix}Capture")
self.num_capture = epics_signal_rw(int, f"pva://{prefix}NumCapture")
self.file_path = epics_signal_rw(str, f"pva://{prefix}FilePath")
self.num_captured = epics_signal_r(int, f"pva://{prefix}NumCaptured")
super().__init__(name=name)
self.add_detector_logics(
CaptureTrigger(self.num_capture),
CaptureAcquire(
self.capture, self.num_capture, AcquireForPlan(self.acquire)
),
CaptureData(self.image, self.file_path, self.num_captured, path_provider),
)
class Camera(StandardReadable):
"""A camera that acquires continuously: plans read its latest frame."""
def __init__(self, prefix: str, name: str = "") -> None:
with self.add_children_as_readables(
StandardReadableFormat.HINTED_UNCACHED_SIGNAL
):
self.image = epics_signal_r(np.ndarray, f"pva://{prefix}Image")
with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
self.exposure = epics_signal_rw(float, f"pva://{prefix}Exposure")
self.acquire = epics_signal_rw(bool, f"pva://{prefix}Acquire")
self.image_count = epics_signal_r(int, f"pva://{prefix}ImageCount")
self.acquisition = AcquireForPlan(self.acquire)
super().__init__(name=name)
@AsyncStatus.wrap
async def stage(self) -> None:
await self.acquisition.start()
await super().stage()
@AsyncStatus.wrap
async def unstage(self) -> None:
await super().unstage()
await self.acquisition.restore()
@AsyncStatus.wrap
async def trigger(self) -> None:
seen = await self.image_count.get_value(cached=False)
# frame seen + 1 may have started its exposure before this call
await wait_for_value(self.image_count, lambda n: n >= seen + 2, timeout=10)
def write_frames(
writers: Sequence[StandardDetector], frames: int
) -> MsgGenerator[None]:
"""Write `frames` frames of each writer to a file while its camera runs."""
def capture() -> MsgGenerator[None]:
yield from bps.declare_stream(*writers, name="primary", collect=True)
yield from bps.kickoff_all(*writers, wait=True)
yield from bps.collect_while_completing(writers, writers, flush_period=0.5)
def prepare_and_capture() -> MsgGenerator[None]:
for writer in writers:
yield from bps.prepare(
writer, TriggerInfo(number_of_events=frames), wait=True
)
yield from bpp.run_wrapper(capture())
yield from bpp.stage_wrapper(prepare_and_capture(), writers)
def hinted_arrays(device: Device) -> list[SignalR[Any]]:
"""Return the hinted signals of `device` that hold arrays, such as images."""
hinted = (
set(device.hints.get("fields", [])) if isinstance(device, HasHints) else set()
)
return [
signal
for signal in walk_devices(device).values()
if isinstance(signal, SignalR)
and signal.name in hinted
and signal.datatype is np.ndarray
]
class LiveFrames:
"""Pass on the latest frame of every camera, at most `rate` times a second."""
sig_frame = Signal(str, object)
def __init__(self, name: str, rate: float = 10.0) -> None:
self.name = name
self.period = 1 / rate
self.shown: dict[str, float] = {}
@slot
def show(self, reading: dict[str, Reading[Any]]) -> None:
now = time.monotonic()
for key, value in reading.items():
if now - self.shown.get(key, 0.0) >= self.period:
self.shown[key] = now
self.sig_frame.emit(key, value["value"])
class MyApp(QtSession):
config: ClassVar[dict[str, Any]] = {"services": {"transport": "pv-access"}}
camera_service: Annotated[
AsService, Launch("mylab.services.camera", ready="camera ready", prefix="CAM:")
]
camera: Annotated[AsDevice[Camera], Declare(service="camera_service")]
camera_writer: Annotated[AsDevice[CameraWriter], Declare(service="camera_service")]
live: AsPresenter[LiveFrames]
def wire(self) -> Iterator[Link]:
for device in self.devices.values():
for signal in hinted_arrays(device):
yield signal, self.live.show
if __name__ == "__main__":
MyApp().run()
The areaDetector camera
"""The areaDetector session of "How to write a camera for continuous acquisition"."""
from __future__ import annotations
from collections.abc import Iterator # noqa: TC003
from typing import Annotated
import numpy as np
from ophyd_async.core import AsyncStatus, PathProvider, set_and_wait_for_value
from ophyd_async.epics.adcore import (
ADImageMode,
ADWriterFactory,
ContAcqDetector,
PluginSignalDataLogic,
)
from ophyd_async.epics.core import epics_signal_r
from docs.examples.continuous_camera import LiveFrames, hinted_arrays
from redsun import AsDevice, AsPresenter, AsService, Attach, Declare, Link
from redsun.qt import QtSession
class SimCamera(ContAcqDetector):
"""An areaDetector camera that acquires continuously."""
def __init__(
self, prefix: str, path_provider: PathProvider, name: str = ""
) -> None:
self.image = epics_signal_r(np.ndarray, f"pva://{prefix}Pva1:Image")
super().__init__(prefix, ADWriterFactory.hdf(path_provider), name=name)
self.add_detector_logics(PluginSignalDataLogic(self.driver, self.image))
@AsyncStatus.wrap
async def stage(self) -> None:
self.was_acquiring = await self.driver.acquire.get_value()
if not self.was_acquiring:
await self.driver.image_mode.set(ADImageMode.CONTINUOUS)
# in continuous mode the put completes only when acquisition stops
await set_and_wait_for_value(
self.driver.acquire, True, wait_for_set_completion=False
)
await super().stage()
@AsyncStatus.wrap
async def unstage(self) -> None:
await super().unstage()
if not self.was_acquiring:
await self.driver.acquire.set(False)
class MyApp(QtSession):
detector_ioc: Annotated[AsService, Attach("SIM:")]
camera: Annotated[AsDevice[SimCamera], Declare(service="detector_ioc")]
live: AsPresenter[LiveFrames]
def wire(self) -> Iterator[Link]:
for device in self.devices.values():
for signal in hinted_arrays(device):
yield signal, self.live.show
if __name__ == "__main__":
MyApp().run()