16. Replace AreaDetector writer parameters with ADWriterFactory#

Date: 2026-05-15

Status#

Accepted

Context#

ADR 0012 introduced the composition-based AreaDetector baseclass. Its __init__ accepted three writer-related constructor parameters side by side with every other parameter:

AreaDetector(
    driver,
    prefix,
    path_provider=path_provider,
    writer_type=NDFileHDF5IO,
    writer_suffix="HDF1:",
    ...
)

Several problems arose as the codebase matured:

  1. Three parameters always travel together. path_provider, writer_type, and writer_suffix are only meaningful as a unit; passing them individually obscures the relationship and requires callers to remember which combinations are valid.

  2. No first-class support for multiple writers. The original API exposed a single writer slot, so attaching a second HDF writer for a ROI plugin required calling add_detector_logics() after construction with low-level arguments that duplicated information already present at call time.

  3. No way to override array shape or data-type signals. The writer logic hard-coded driver.array_size_{x,y,z}, driver.data_type, and driver.color_mode as the source of NDArray metadata. This was wrong for ROI plugins (which have their own size_x/size_y signals) and for processing plugins that remap the data type or colour mode.

  4. Deferred driver construction. Detector subclasses (e.g. AravisDetector) create their driver internally; the driver signals therefore do not exist at the point where the caller constructs ADWriterFactory. Any eager NDArrayDescription(data_type_signal=driver.data_type, ...) call would need the driver to already exist—which it does not.

Decision#

Replace the three writer parameters with a single *writer_factories varargs of ADWriterFactory instances:

AreaDetector(
    driver,
    prefix,
    ADWriterFactory.hdf(path_provider),
    ...
)

ADWriterFactory#

A @dataclass (generic over the plugin IO type) with fields:

Field

Type

Purpose

writer_cls

type[NDPluginFileIOT]

Plugin class to instantiate

writer_suffix

str

PV suffix appended to prefix

writer_name

str

Attribute name on the detector (det.hdf, det.hdf1, …)

datakey_suffix

str

Suffix appended to the datakey name in stream resources

array_description

NDArrayDescription | Callable[[ADBaseIO], NDArrayDescription] | None

Override for array shape/type metadata

data_logic_factory

callable

Builds the DetectorDataLogic from writer + description

Three static constructors—hdf(), jpeg(), and tiff()—provide sensible defaults. They are named after the file format they produce, and their writer_name defaults to the same string ("hdf", "jpeg", "tiff"), so the writer is automatically stored at det.hdf etc.

__call__(prefix, driver, plugins) runs at AreaDetector.__init__ time, when the driver already exists, and returns (writer_plugin, DetectorDataLogic).

NDArrayDescription and the callable override#

NDArrayDescription bundles the three signals that describe an NDArray frame:

@dataclass
class NDArrayDescription:
    shape_signals: Sequence[SignalR[int]]
    data_type_signal: SignalR[ADBaseDataType]
    color_mode_signal: SignalR[ADBaseColorMode]

When array_description is None, __call__ auto-builds it from driver.array_size_{z,y,x}, driver.data_type, and driver.color_mode.

When a caller needs signals from a different source (an ROI plugin, a processing plugin) and the driver is only created inside the detector subclass, a callable is used:

ADWriterFactory.hdf(
    path_provider,
    writer_name="hdf1",
    datakey_suffix="-roi1",
    array_description=lambda driver: NDArrayDescription(
        shape_signals=(roi1.size_y, roi1.size_x),
        data_type_signal=driver.data_type,   # driver available here
        color_mode_signal=driver.color_mode,
    ),
)

The callable receives the fully-constructed driver at __call__ time, which is the first moment all driver signals exist. This design:

  • keeps data_type_signal and color_mode_signal mandatory on NDArrayDescription (no silent defaults, no silent fall-through);

  • allows any signal to be overridden, not just the shape—so a plugin that remaps the data type or colour mode can supply its own signal;

  • avoids adding a new Callable type to NDArrayDescription itself, keeping that dataclass a plain value object.

Multiple writers#

Passing multiple factories gives each writer a distinct name and datakey suffix:

det = adaravis.AravisDetector(
    "PREFIX:",
    ADWriterFactory.hdf(path_provider, writer_name="hdf1", datakey_suffix="-roi1",
                        array_description=lambda driver: NDArrayDescription(...)),
    ADWriterFactory.hdf(path_provider, writer_name="hdf2", datakey_suffix="-roi2",
                        array_description=lambda driver: NDArrayDescription(...)),
    plugins={"roi1": roi1, "roi2": roi2},
)
det.hdf1  # NDFileHDF5IO for ROI 1
det.hdf2  # NDFileHDF5IO for ROI 2

No post-construction add_detector_logics() call is required.

prefix placement and guard#

prefix is placed before *writer_factories (as a positional parameter with a default of None) so that callers using the standard single-writer pattern can pass it positionally. A guard raises ValueError if factories are supplied but prefix is None:

if writer_factories and prefix is None:
    raise ValueError("prefix is required when writer_factories are given")

Consequences#

  • AreaDetector.__init__ and all seven bundled subclasses are updated; no compatibility shim is provided (the library is pre-1.0).

  • writer_name must be unique across factories passed to the same detector; duplicate names raise an AttributeError at construction time.

  • Callers that previously passed writer_type=None to suppress file writing now simply omit all factory arguments.

  • ADHDFDataLogic and ADMultipartDataLogic expose their NDArrayDescription as array_description to avoid ambiguity with the similarly-named Python concept.