Images, Darks, & whites with EPICS area detector, ophyd, and Bluesky#

In some scientific data acquisition processes, an area detector acquires a sequence of image frames and stores them as a sequence in a data file. In tomography and other processes, the frames are associated with certain imaging conditions, such as when the illumination is on and the sample is in view (known as an image or data frame) or the sample is not in view (a flat, flood, or white frame) or when the illumination is off (a dark or background frame).

This document describes how to configure the Bluesky software to write such HDF5 files when using EPICS and the Area Detector support.

The HDF5 File Writer plugin can be configured to save image frames into separate datasets within the same HDF5 data file. The selection of frame type (image frame, background/dark frame, white/flat frame) is made by use of an existing PV in area detector: $(P):cam1:FrameType which is an mbbo record. In ophyd, the readback version of this PV: $(P):cam1:FrameType_RBV is used to define operational values for the ophyd device. Be sure to configure the readback PV with the same values.

meaning

PV value

PV field

default

dxchange

NeXus

image frame

0

ZRST

Normal

/exchange/data

/entry/data/data

background/dark frame

1

ONST

Background

/exchange/data_dark

/entry/data/dark

white/flat frame

2

TWST

FlatField

/exchange/data_white

/entry/data/white

not used

3

THST

DblCorrelation

""

""

not used

4

FRST

""

""

""

The values will be used as the HDF5 addresses to store that type of frame. With the chosen format (dxchange or NeXus), set the fields of both these PVs ($(P):cam1:FrameType and $(P):cam1:FrameType_RBV).

In NeXus, detector data is stored within the instrument group, traditionally at /entry/instrument/detector/. We then hard link the field data, dark, and white fields to the /entry/data/ group. The hard links provide a shorter HDF5 address. We use this shorter address in EPICS, to fit within the maximum 25 characters allowed by the EPICS mbbo record. We write the data into the /entry/data group and hard link it (in the layout.xml file) to the instrument group.

Tip: Whether you use dxchange or NeXus, be sure the fields of the cam1:FrameType PV are put in the IOC’s autosave configuration so they are restored when the IOC is restarted!

The area detector attributes XML file needs the cam1:FrameType selection PV included in its list. We’ll call it HDF5FrameLocation so we can use the same name in the layout file:

<Attribute
    name="HDF5FrameLocation"
    type="EPICS_PV"
    source="13SIM1:cam1:FrameType"
    dbrtype="DBR_STRING"
    description="HDF5 address for this frame"/>

The HDF5 layout XML file will refer to this HDF5FrameLocation attribute in the setup (add this to the XML file just after the opening hdf5_layout and before the first group element).

<global name="detector_data_destination" ndattribute="HDF5FrameLocation" />

The name detector_data_destination is hard-coded in the source code of the HDF5 file writer.

In the Bluesky plan, write the frame type before acquisition with a value from this table:

use this value

which means a frame of this type

0

image

1

dark

2

white

In any following acquisition(s), the HDF5 file writer will direct the image frame to the dataset as specified by the ZRST, ONST, or TWST field, respectively.

Setup#

Try acquiring three different frame types into the same HDF5 file with the ADSimDetector IOC. Use the NeXus format. The procedures are similar for the data exchange format.

Notebook-specific details#

These details are specific to the area detector running for this notebook.

variable

comments

ad_prefix

IOC prefix for the ADSimdetector IOC

IOC_MOUNT_POINT

root directory of this IOC as mounted on the workstation for this notebook

[1]:
import pathlib

ad_prefix = "ad:"
IOC_MOUNT_POINT = pathlib.Path("/tmp/docker_ioc/iocad")

XML Attributes File#

Here, we create the custom /tmp/attributes.xml (starting from the XML layout file supplied with the ADSimDetector). Rather than use an XML library to write this file, we’ll create all the content here as text and write the file with the usual text file tools. Then, we’ll check that the file exists.

Note: This is the part of the file that connects the cam1:FrameType PV with an attribute to be used in the layout file. We can choose any unique name for this attribute (within the names allowed by XML) but we must use the same name in both the attributes and layout XML files. Here, we choose the name HDF5FrameLocation .

<Attribute
    name="HDF5FrameLocation"
    type="EPICS_PV"
    source="{ad_prefix}cam1:FrameType"
    dbrtype="DBR_STRING"
    description="HDF5 address for this frame"
    />

In a later step, we’ll tell the HDF5 plugin to use it.

NOTE: Very important that you set the attributes file in the cam, and not the hdf1 plugin!

[2]:
xml_file = IOC_MOUNT_POINT / "tmp" / "attributes.xml"  # IOC sees: /tmp/attributes.xml
XML_ATTRIBUTES = f"""
<?xml version="1.0" standalone="no" ?>
<Attributes
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="../../../../../ADCore/XML_schema/NDAttributes.xsd"
    >
    <Attribute name="AcquireTime"         type="EPICS_PV" source="{ad_prefix}cam1:AcquireTime"  dbrtype="DBR_NATIVE"  description="Camera acquire time"/>
    <Attribute name="HDF5FrameLocation"   type="EPICS_PV" source="{ad_prefix}cam1:FrameType"    dbrtype="DBR_STRING"  description="HDF5 address for this frame"/>
    <Attribute name="ImageCounter"        type="PARAM"    source="ARRAY_COUNTER"                datatype="INT"        description="Image counter"/>
    <Attribute name="AttributesFileParam" type="PARAM"    source="ND_ATTRIBUTES_FILE"           datatype="STRING"     description="Attributes file param"/>
    <Attribute name="CameraModel"         type="PARAM"    source="MODEL"                        datatype="STRING"     description="Camera model"/>
    <Attribute name="CameraManufacturer"  type="PARAM"    source="MANUFACTURER"                 datatype="STRING"     description="Camera manufacturer"/>
</Attributes>
"""

with open(xml_file, "w") as f:
    f.write(XML_ATTRIBUTES.strip())
print(f"{xml_file.exists()=}\n{xml_file=}")
xml_file.exists()=True
xml_file=PosixPath('/tmp/docker_ioc/iocad/tmp/attributes.xml')

XML Layout File#

Here, we create the custom /tmp/layout.xml (starting from the XML layout file supplied with the ADSimDetector). Rather than use an XML library to write this file, we’ll create all the content here as text and write the file with the usual text file tools. Then, we’ll check that the file exists.

Note: This is the part of the file that directs each frame type to its configured field:

<global name="detector_data_destination" ndattribute="HDF5FrameLocation" />

The detector_data_destination name is compiled into the HDF5 writer plugin and is required for proper operation. The HDF5FrameLocation coordinates with the same name in the attributes above.

In a later step, we’ll tell the HDF5 plugin to use it.

[3]:
xml_file = IOC_MOUNT_POINT / "tmp" / "layout.xml"  # IOC sees: /tmp/layout.xml
XML_LAYOUT2 = """
<?xml version="1.0" standalone="no" ?>
<hdf5_layout
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="../../../../../ADCore/XML_schema/hdf5_xml_layout_schema.xsd"
    >
  <global name="detector_data_destination" ndattribute="HDF5FrameLocation" />
  <group name="entry">
    <attribute name="NX_class" source="constant" value="NXentry" type="string" />
    <attribute name="default" source="constant" value="data" type="string" />
    <group name="data">
        <attribute name="NX_class" source="constant" value="NXdata" type="string" />
        <attribute name="signal" source="constant" value="data" type="string" />
        <dataset name="data" source="detector">
          <attribute name="units" source="constant" value="counts" type="string" />
          <attribute name="description" source="constant" value="image frame(s)" type="string" />
          <attribute name="target" source="constant" value="/entry/data/data" type="string" />
        </dataset>
        <dataset name="dark" source="detector">
          <attribute name="units" source="constant" value="counts" type="string" />
          <attribute name="description" source="constant" value="dark (background) frame(s)" type="string" />
          <attribute name="target" source="constant" value="/entry/data/dark" type="string" />
        </dataset>
        <dataset name="white" source="detector">
          <attribute name="units" source="constant" value="counts" type="string" />
          <attribute name="description" source="constant" value="white (flat) frame(s)" type="string" />
          <attribute name="target" source="constant" value="/entry/data/white" type="string" />
        </dataset>
    </group>              <!-- end group data -->
    <group name="instrument">
      <attribute name="NX_class" source="constant" value="NXinstrument" type="string" />
      <group name="detector">
        <attribute name="NX_class" source="constant" value="NXdetector" type="string" />
        <hardlink name="data" target="/entry/data/data" />
        <hardlink name="dark" target="/entry/data/dark" />
        <hardlink name="white" target="/entry/data/white" />
      </group>            <!-- end group detector -->
      <group name="NDAttributes" ndattr_default="true">
        <attribute name="NX_class" source="constant" value="NXcollection" type="string" />
      </group>            <!-- end group NDAttribute (default) -->
    </group>              <!-- end group instrument -->
  </group>                <!-- end group entry -->
</hdf5_layout>
"""

with open(xml_file, "w") as f:
    f.write(XML_LAYOUT2.strip())
print(f"{xml_file.exists()=}\n{xml_file=}")
xml_file.exists()=True
xml_file=PosixPath('/tmp/docker_ioc/iocad/tmp/layout.xml')

Ophyd Device Setup#

[4]:
from apstools.devices import CamMixin_V34
from apstools.devices import SingleTrigger_V34
from ophyd import Component
from ophyd import Device
from ophyd import EpicsSignal
from ophyd import EpicsSignalRO
from ophyd import EpicsSignalWithRBV
from ophyd import SimDetector
from ophyd.areadetector import SimDetectorCam
from ophyd.areadetector.plugins import HDF5Plugin_V34
from ophyd.areadetector.plugins import ImagePlugin_V34
from ophyd.areadetector.plugins import PvaPlugin_V34

class MySimDetectorCam(CamMixin_V34, SimDetectorCam):
    nd_attr_status = Component(EpicsSignalRO, "NDAttributesStatus", kind="omitted", string=True)

class MyHDF5Plugin(HDF5Plugin_V34):
    layout_filename = Component(EpicsSignal, "XMLFileName", kind="config", string=True)
    layout_filename_valid = Component(EpicsSignal, "XMLValid_RBV", kind="omitted", string=True)
    nd_attr_status = Component(EpicsSignal, "NDAttributesStatus", kind="omitted", string=True)

class MyDetector(SingleTrigger_V34, SimDetector):
    cam = Component(MySimDetectorCam, suffix="cam1:")
    hdf1 = Component(MyHDF5Plugin, suffix="HDF1:")
    image = Component(ImagePlugin_V34, suffix="image1:")
    pva1 = Component(PvaPlugin_V34, suffix="Pva1:")

Support Functions#

[5]:
import h5py

IMAGE = 0
DARK = 1
WHITE = 2

def set_ad_count_time(det, exposure=1, period=1):
    det.cam.stage_sigs["acquire_time"] = exposure
    det.cam.stage_sigs["acquire_period"] = period

def ad_setup(det, nframes=1):
    """Make this a function.  We'll use again later."""
    det.cam.acquire.put(0)
    det.cam.frame_type.kind = "config"
    det.hdf1.capture.put(0)
    det.hdf1.compression.put("zlib")  # better than `"None"` (default)
    det.hdf1.create_directory.put(-5)
    det.hdf1.file_name.put("test_image")
    det.hdf1.file_path.put("/tmp")
    det.hdf1.kind = 3  # config | normal

    if "compression" in det.hdf1.stage_sigs:
        det.hdf1.stage_sigs.pop("create_directory")

    # The plugins do not block, the cam must wait for the plugins to finish.
    for nm in det.component_names:
        obj = getattr(det, nm)
        if "blocking_callbacks" in dir(obj):  # is it a plugin?
            obj.stage_sigs["blocking_callbacks"] = "No"
    det.cam.stage_sigs["wait_for_plugins"] = "Yes"

    det.cam.stage_sigs["num_images"] = nframes
    det.hdf1.stage_sigs["num_capture"] = 0  # capture ALL frames received
    det.hdf1.stage_sigs["auto_increment"] = "Yes"
    det.hdf1.stage_sigs["auto_save"] = "Yes"
    det.hdf1.stage_sigs["file_template"] = "%s%s_%4.4d.h5"
    det.hdf1.stage_sigs["file_write_mode"] = "Stream"
    det.hdf1.stage_sigs["store_attr"] = "Yes"  # need in this notebook
    det.hdf1.stage_sigs["store_perform"] = "No"  # optional
    det.hdf1.stage_sigs["auto_increment"] = "Yes"
    det.hdf1.stage_sigs["capture"] = 1  # ALWAYS last
    det.hdf1.stage_sigs.move_to_end("capture")  # ... just in case

    # Clear these settings __for this demo__.
    det.hdf1.layout_filename.put("")
    det.cam.nd_attributes_file.put("")

def check_adplugin_primed(plugin, allow_priming=True):
    from apstools.devices import AD_plugin_primed
    from apstools.devices import AD_prime_plugin2

    # this step is needed for ophyd
    if not AD_plugin_primed(plugin):
        if allow_priming:
            print(f"Priming {plugin.dotted_name}")
            AD_prime_plugin2(plugin)
        else:
            raise RuntimeError(
                f"Detector plugin '{plugin.dotted_name}' must be primed first."
            )

def dict_to_table(d, printing=True):
    import pyRestTable

    if len(d) == 0:
        return

    table = pyRestTable.Table()
    table.labels = "key value".split()
    table.rows = [[k, v] for k, v in d.items()]

    if printing:
        print(table)
    else:
        return table

def readings_to_table(d, printing=True):
    import pyRestTable

    if len(d) == 0:
        return

    labels = sorted(set([k for v in d.values() for k in v.keys()]))
    table = pyRestTable.Table()
    # fmt: off
    table.labels = [ "name", ] + list(labels)
    for k, reading in d.items():
        row = [k, ] + [reading.get(r, "") for r in labels]
        table.addRow(row)
    # fmt: on

    if printing:
        print(table)
    else:
        return table

def print_overview(device):
    cfg = device.describe_configuration()
    for k, readings in device.read_configuration().items():
        if k not in cfg:
            cfg[k] = readings
        else:
            cfg[k].update(readings)

    if len(cfg) > 0:
        print(f"'{device.name}' configuration:")
    readings_to_table(cfg)

def practice_device_staging(device):
    print(f"Before staging '{device.name}'")
    device.stage()
    print(f"Device '{device.name}' staged")
    device.unstage()
    print(f"Device '{device.name}' unstaged")

def local_h5_file(det):
    ioc_file = det.hdf1.full_file_name.get()
    return IOC_MOUNT_POINT / ioc_file.lstrip("/")

def check_h5_file(det):
    hfile = local_h5_file(det)
    print(f"{hfile.exists()=} {hfile.name=}")
    addr = "/entry/instrument/NDAttributes"
    with h5py.File(hfile, "r") as root:
        group = root[addr]
        print(f"{len(group)=}  {group=}")
        print(f"members: {[k for k in group]}")

def h5_overview(det):
    h5_file = local_h5_file(det)
    print(f"{h5_file=}")
    with h5py.File(h5_file, "r") as NeXus_data:
        print(f"{('/entry/data' in NeXus_data)=}")
        print(f"{('/entry/data/dark' in NeXus_data)=}")
        print(f"{('/entry/data/data' in NeXus_data)=}")
        print(f"{('/entry/data/white' in NeXus_data)=}")
        print(f"{('/entry/instrument' in NeXus_data)=}")
        print(f"{('/entry/instrument/detector' in NeXus_data)=}")
        print(f"{('/entry/instrument/detector/dark' in NeXus_data)=}")
        print(f"{('/entry/instrument/detector/data' in NeXus_data)=}")
        print(f"{('/entry/instrument/detector/white' in NeXus_data)=}")
        print(f"{('/entry/instrument/NDAttributes' in NeXus_data)=}")
        print(f"{('/entry/instrument/NDAttributes/HDF5FrameLocation' in NeXus_data)=}")
        for addr in "dark data white".split():
            try:
                ds = NeXus_data[f"/entry/data/{addr}"]
                print(f"{ds=}  {ds.shape=}")
            except KeyError:
                pass
    frame_type = det.cam.frame_type.get()
    print(
        f"frame_type: PV='{det.cam.frame_type.pvname}'"
        f"\n    value={frame_type}"
        f" ({det.cam.frame_type.enum_strs[frame_type]})"
        f"\n    choices={det.cam.frame_type.enum_strs}"
    )
    print(f"{h5_file=}")

def setup_frame_type():
    class FrameType(Device):
        zero = Component(EpicsSignal, ".ZRST", string=True)
        one = Component(EpicsSignal, ".ONST", string=True)
        two = Component(EpicsSignal, ".TWST", string=True)
        three = Component(EpicsSignal, ".THST", string=True)

    for pv in ("FrameType", ):
        o = FrameType(f"{ad_prefix}cam1:{pv}", name="o")
        o.wait_for_connection()
        o.zero.put("/entry/data/data")
        o.one.put("/entry/data/dark")
        o.two.put("/entry/data/white")
        o.three.put("")

Configure PV for the Frame Type#

Configure our PV(s) for the NeXus addresses we want to use. We must reconnect our ophyd object after our change to the EPICS PVs to pick up this change. (The choices that the Python objects sees are only updated when the cam1:FrameType PV is first connected.)

Must call setup_frame_type() before creating detector object, so the Python detector object picks up on the different string values. The EPICS IOC gets the correct string directly from the cam1:FrameType PV.

[6]:
setup_frame_type()

Create Detector Object#

… and connect with the Area Detector IOC. Set the counting time per frame (something short). Prime (push an image frame from the camera to the plugin) the HDF5 plugin, if found necessary.

[7]:
adsimdet = MyDetector(ad_prefix, name="det")
adsimdet.wait_for_connection()

NUM_FRAMES = 5
set_ad_count_time(adsimdet, exposure=0.02, period=0.1)
ad_setup(adsimdet, nframes=NUM_FRAMES)
check_adplugin_primed(adsimdet.hdf1)
Priming hdf1

Create RunEngine#

[8]:
import apstools
import bluesky
import databroker
from bluesky import plans as bp

cat = databroker.temp().v2
RE = bluesky.RunEngine()
RE.subscribe(cat.v1.insert)

RE.md["title"] = "images, darks, & flats"
RE.md["versions"]["apstools"] = apstools.__version__
RE.md["repository"] = "bluesky_training"
RE.md["notebook"] = "images_darks_flats"

Review the Configurations#

[9]:
print("RunEngine metadata")
dict_to_table(RE.md)

for o in [adsimdet, adsimdet.cam, adsimdet.hdf1]:
    nm = f"adsimdet.{o.attr_name}".rstrip(".")
    print(f"'{nm}.stage_sigs' stage_sigs")
    dict_to_table(o.stage_sigs)

print_overview(adsimdet)
RunEngine metadata
========== =============================================================
key        value
========== =============================================================
versions   {'ophyd': '1.7.0', 'bluesky': '1.10.0', 'apstools': '1.6.15'}
title      images, darks, & flats
repository bluesky_training
notebook   images_darks_flats
========== =============================================================

'adsimdet.stage_sigs' stage_sigs
============== =====
key            value
============== =====
cam.acquire    0
cam.image_mode 1
============== =====

'adsimdet.cam.stage_sigs' stage_sigs
================ =====
key              value
================ =====
acquire_time     0.02
acquire_period   0.1
wait_for_plugins Yes
num_images       5
================ =====

'adsimdet.hdf1.stage_sigs' stage_sigs
========================== =============
key                        value
========================== =============
enable                     1
blocking_callbacks         No
parent.cam.array_callbacks 1
num_capture                0
auto_increment             Yes
auto_save                  Yes
file_template              %s%s_%4.4d.h5
file_write_mode            Stream
store_attr                 Yes
store_perform              No
capture                    1
========================== =============

'det' configuration:
====================== ======= ======================================================= ================ ========= ===== ============================ ================= ===== ================ ==================
name                   dtype   enum_strs                                               lower_ctrl_limit precision shape source                       timestamp         units upper_ctrl_limit value
====================== ======= ======================================================= ================ ========= ===== ============================ ================= ===== ================ ==================
det_cam_acquire_period number                                                          0.0              3         []    PV:ad:cam1:AcquirePeriod_RBV 1681423107.715916       0.0              0.005
det_cam_acquire_time   number                                                          0.0              3         []    PV:ad:cam1:AcquireTime_RBV   1681423107.818108       0.0              0.001
det_cam_frame_type     integer ('Normal', 'Background', 'FlatField', 'DblCorrelation') None                       []    PV:ad:cam1:FrameType_RBV     1681423053.905152 None  None             0
det_cam_image_mode     integer ('Single', 'Multiple', 'Continuous')                    None                       []    PV:ad:cam1:ImageMode_RBV     1681423108.021729 None  None             2
det_cam_manufacturer   string                                                          None                       []    PV:ad:cam1:Manufacturer_RBV  1681423053.904149 None  None             Simulated detector
det_cam_model          string                                                          None                       []    PV:ad:cam1:Model_RBV         1681423053.904223 None  None             Basic simulator
det_cam_num_exposures  integer                                                         0                          []    PV:ad:cam1:NumExposures_RBV  1681423053.90519        0                1
det_cam_num_images     integer                                                         0                          []    PV:ad:cam1:NumImages_RBV     1681423053.904953       0                100
det_cam_trigger_mode   integer ('Internal', 'External')                                None                       []    PV:ad:cam1:TriggerMode_RBV   1681423053.90516  None  None             0
====================== ======= ======================================================= ================ ========= ===== ============================ ================= ===== ================ ==================

Acquire Images with Standard bp.count() Plan#

To understand what the custom frame type support will do for us, we first demonstrate image acquisition when the custom support is not yet installed. When a custom layout file is not configured in the HDF5 plugin, the standard layout (NeXus schema) writes all image frames into the /entry/data/data field of the HDF5 file.

[10]:
RE(bp.count([adsimdet]))
[10]:
('adb1a4a5-19ce-4302-b18b-76eb1c7a4289',)

After the acquisition, check the output file. This is not a great test since we are only writing a single image type. Just check that HDF5FrameLocation is found in the list of members and that the expected data group is present. Only the test results for the data frames should be True (tests for the paths to those components should also be True).

[11]:
print(f"{local_h5_file(adsimdet)}")
check_h5_file(adsimdet)
h5_overview(adsimdet)
/tmp/docker_ioc/iocad/tmp/test_image_0000.h5
hfile.exists()=True hfile.name='test_image_0000.h5'
len(group)=4  group=<HDF5 group "/entry/instrument/NDAttributes" (4 members)>
members: ['NDArrayEpicsTSSec', 'NDArrayEpicsTSnSec', 'NDArrayTimeStamp', 'NDArrayUniqueId']
h5_file=PosixPath('/tmp/docker_ioc/iocad/tmp/test_image_0000.h5')
('/entry/data' in NeXus_data)=True
('/entry/data/dark' in NeXus_data)=False
('/entry/data/data' in NeXus_data)=True
('/entry/data/white' in NeXus_data)=False
('/entry/instrument' in NeXus_data)=True
('/entry/instrument/detector' in NeXus_data)=True
('/entry/instrument/detector/dark' in NeXus_data)=False
('/entry/instrument/detector/data' in NeXus_data)=True
('/entry/instrument/detector/white' in NeXus_data)=False
('/entry/instrument/NDAttributes' in NeXus_data)=True
('/entry/instrument/NDAttributes/HDF5FrameLocation' in NeXus_data)=False
ds=<HDF5 dataset "data": shape (5, 1024, 1024), type "|u1">  ds.shape=(5, 1024, 1024)
frame_type: PV='ad:cam1:FrameType_RBV'
    value=0 (Normal)
    choices=('Normal', 'Background', 'FlatField', 'DblCorrelation')
h5_file=PosixPath('/tmp/docker_ioc/iocad/tmp/test_image_0000.h5')

Use Ophyd to Acquire Image Frames#

Test the acquisition of the different frame types.

Install Support for Custom Frame Types#

Previously we wrote attributes and layout files for the IOC to use. Configure the IOC to use these files now. With these files, the HDF5 writer will use the cam1:FrameType PV, configured above, to direct each frame to its configured HDF5 address in the file.

NOTE: Very important that you set the attributes file in the cam, and not the hdf1 plugin!

[12]:
adsimdet.cam.nd_attributes_file.put("/tmp/attributes.xml")
print(f"{adsimdet.cam.nd_attr_status.get() = }")

adsimdet.hdf1.layout_filename.put("/tmp/layout.xml")
print(f"{adsimdet.hdf1.layout_filename_valid.get()=}")
adsimdet.cam.nd_attr_status.get() = 'Attributes file OK'
adsimdet.hdf1.layout_filename_valid.get()='Yes'

Start Data Acquisition#

In staging, ophyd sets the various detector PVs to the values to be used for the next data acquisition. The configuration is stored in the various stage_sigs dictionaries printed above.

[13]:
adsimdet.stage()
adsimdet.cam.image_mode.put("Multiple")

Collect Different Frame Types#

Since execution by the RunEngine may appear to be opaque to the user, let’s bypass the RunEngine and collect the different frame types using pure ophyd commands.

For the different image types, first set the frame type, then tell the camera to acquire. The camera has been configured to wait for all plugins to finish before it sets detector state to idle. The responder() function is subscribed to any updates of the detector state PV. A Status() object is used to wait for the PV to reach the value of "Idle" (or 0).

[14]:
from ophyd.status import Status

def acquire(det, frame_type, nframes):
    acquisition = Status()

    def responder(**kwargs):
        """Called when subscribed signal changes."""
        if kwargs.get("value") in (0, "Idle") and not acquisition.done:
            acquisition.set_finished()

    det.cam.frame_type.put(frame_type)
    det.cam.num_images.put(nframes)
    det.cam.acquire.put(1)
    det.cam.detector_state.subscribe(responder)
    acquisition.wait()
    det.cam.detector_state.unsubscribe(responder)

We mix up the sequence of images, darks, and whites to demonstrate that the order of these does not matter. Each image type will be directed to the corresponding HDF5 dataset as it is collected.

Collect one image frame

[15]:
acquire(adsimdet, IMAGE, 1)

Collect two white frames

[16]:
acquire(adsimdet, WHITE, 2)

Collect three dark frames

[17]:
acquire(adsimdet, DARK, 3)

Collect five more image frames

[18]:
acquire(adsimdet, IMAGE, 5)

End Data Acquisition#

Unstaging reverses the stage() step above, restoring PVs to their prior values.

[19]:
adsimdet.unstage()
h5_overview(adsimdet)
h5_file=PosixPath('/tmp/docker_ioc/iocad/tmp/test_image_0001.h5')
('/entry/data' in NeXus_data)=True
('/entry/data/dark' in NeXus_data)=True
('/entry/data/data' in NeXus_data)=True
('/entry/data/white' in NeXus_data)=True
('/entry/instrument' in NeXus_data)=True
('/entry/instrument/detector' in NeXus_data)=True
('/entry/instrument/detector/dark' in NeXus_data)=True
('/entry/instrument/detector/data' in NeXus_data)=True
('/entry/instrument/detector/white' in NeXus_data)=True
('/entry/instrument/NDAttributes' in NeXus_data)=True
('/entry/instrument/NDAttributes/HDF5FrameLocation' in NeXus_data)=False
ds=<HDF5 dataset "dark": shape (3, 1024, 1024), type "|u1">  ds.shape=(3, 1024, 1024)
ds=<HDF5 dataset "data": shape (6, 1024, 1024), type "|u1">  ds.shape=(6, 1024, 1024)
ds=<HDF5 dataset "white": shape (2, 1024, 1024), type "|u1">  ds.shape=(2, 1024, 1024)
frame_type: PV='ad:cam1:FrameType_RBV'
    value=0 (Normal)
    choices=('Normal', 'Background', 'FlatField', 'DblCorrelation')
h5_file=PosixPath('/tmp/docker_ioc/iocad/tmp/test_image_0001.h5')

We’re looking for 3 darks, 6 data, and 2 white frames. All the test results should be True.

Use Bluesky to Acquire Image Frames#

Here, we collect image frames with a custom plan using the bluesky RunEngine (RE).

Custom Bluesky Plan#

[20]:
import bluesky.plan_stubs as bps

def frame_set(det, frame_type=0, num_frames=1, sleep=0.25):
    frame_name = "image background white".split()[frame_type]
    print(f"{frame_type=}  {frame_name=}  {num_frames=}")
    yield from bps.mv(
        det.cam.frame_type, frame_type,
        det.cam.num_images, num_frames,
    )
    if sleep > 0:
        yield from bps.sleep(sleep)
    yield from bps.mv(det.cam.acquire, 1)  # waits for acquire=0
    while det.cam.acquire_busy.get(use_monitor=False) != 0:
        yield from bps.sleep(0.01)

def series(det, sequence, sleep=1):
    total = sum([item[1] for item in sequence])
    print("total frames:", total)

    print("setup")
    yield from bps.mv(
        det.hdf1.auto_save, "Yes",
        det.hdf1.num_capture, 0,
        det.cam.image_mode, "Multiple",
    )
    yield from bps.mv(det.hdf1.file_write_mode, 'Stream')  # TODO: or Capture
    yield from bps.stage(det)

    for frame_specification in sequence:
        frame_type, num_frames = frame_specification
        yield from frame_set(det, frame_type, num_frames, sleep=sleep)

    yield from bps.mv(det.hdf1.capture, 0)  # before file_write_mode = 'Single
    yield from bps.unstage(det)
    yield from bps.mv(
        det.cam.frame_type, IMAGE,
        det.hdf1.file_write_mode, 'Single',
    )

Acquire Image Frames with Bluesky#

Describe a sequence of acquisitions with different frame types and number of frames to collect with each.

Run this sequence with the custom plan (in the RunEngine), then print an overview of the HDF5 file.

[21]:
SEQUENCE = [
    (DARK, 1),
    (WHITE, 1),
    (IMAGE, 1),
    (IMAGE, 1),
    (DARK, 1),
    (IMAGE, 2),
    (WHITE, 1),
    (DARK, 1),
]

RE(series(adsimdet, SEQUENCE, sleep=0.0))
h5_overview(adsimdet)
total frames: 9
setup
frame_type=1  frame_name='background'  num_frames=1
frame_type=2  frame_name='white'  num_frames=1
frame_type=0  frame_name='image'  num_frames=1
frame_type=0  frame_name='image'  num_frames=1
frame_type=1  frame_name='background'  num_frames=1
frame_type=0  frame_name='image'  num_frames=2
frame_type=2  frame_name='white'  num_frames=1
frame_type=1  frame_name='background'  num_frames=1
h5_file=PosixPath('/tmp/docker_ioc/iocad/tmp/test_image_0002.h5')
('/entry/data' in NeXus_data)=True
('/entry/data/dark' in NeXus_data)=True
('/entry/data/data' in NeXus_data)=True
('/entry/data/white' in NeXus_data)=True
('/entry/instrument' in NeXus_data)=True
('/entry/instrument/detector' in NeXus_data)=True
('/entry/instrument/detector/dark' in NeXus_data)=True
('/entry/instrument/detector/data' in NeXus_data)=True
('/entry/instrument/detector/white' in NeXus_data)=True
('/entry/instrument/NDAttributes' in NeXus_data)=True
('/entry/instrument/NDAttributes/HDF5FrameLocation' in NeXus_data)=True
ds=<HDF5 dataset "dark": shape (3, 1024, 1024), type "|u1">  ds.shape=(3, 1024, 1024)
ds=<HDF5 dataset "data": shape (4, 1024, 1024), type "|u1">  ds.shape=(4, 1024, 1024)
ds=<HDF5 dataset "white": shape (2, 1024, 1024), type "|u1">  ds.shape=(2, 1024, 1024)
frame_type: PV='ad:cam1:FrameType_RBV'
    value=0 (Normal)
    choices=('Normal', 'Background', 'FlatField', 'DblCorrelation')
h5_file=PosixPath('/tmp/docker_ioc/iocad/tmp/test_image_0002.h5')

We are looking for this structure in the HDF5 data file (other structure has been omitted, for clarity):

entry:NXentry
  data:NXdata
    dark:NX_UINT8[3,1024,1024] = [ ... ]
    data:NX_UINT8[4,1024,1024] = [ ... ]
    white:NX_UINT8[2,1024,1024] = [ ... ]
  instrument:NXinstrument
    NDAttributes:NXcollection
      HDF5FrameLocation:NX_CHAR[256] = /entry/data/data
    detector:NXdetector
      dark --> /entry/data/dark
      data --> /entry/data/data
      white --> /entry/data/white

All the test results should be True.