Area Detector with custom HDF5 File Name#

Demonstrate the setup of an EPICS area detector to acquire an image with bluesky and write it to an HDF5 file. Override the default ophyd naming process (which uses random UUIDs) for image files. Instead, let the user control the image file name using the features of the EPICS Area Detector HDF5 plugin. Show the image using the databroker.

Contents

EPICS Area Detector IOC#

This example uses a prebuilt ADSimDetector driver. Prepare as in Example with default file names. Refer to that document for explanations.

ophyd#

The steps of the basic example are combined together here. Refer to that document for explanations. As is customary, the imports come first, then the constants, and classes.

[1]:
from apstools.devices import AD_EpicsFileNameHDF5Plugin
from apstools.devices import AD_full_file_name_local
from apstools.devices import ensure_AD_plugin_primed
from apstools.devices import CamMixin_V34 as CamMixin
from apstools.devices import SingleTrigger_V34 as SingleTrigger
from apstools.devices import SimDetectorCam_V34
from ophyd import ADComponent
from ophyd import DetectorBase
from ophyd import SimDetectorCam
from ophyd.areadetector.plugins import ImagePlugin_V34 as ImagePlugin
import apstools
import bluesky
import bluesky.plans as bp
import bluesky.plan_stubs as bps
import databroker
import hdf5plugin
import pathlib

IOC = "ad:"
IMAGE_DIR = "adsimdet/%Y/%m/%d"
AD_IOC_MOUNT_PATH = pathlib.Path("/tmp")
BLUESKY_MOUNT_PATH = pathlib.Path("/tmp/docker_ioc/iocad/tmp")

# MUST end with a `/`, pathlib will NOT provide it
WRITE_PATH_TEMPLATE = f"{AD_IOC_MOUNT_PATH / IMAGE_DIR}/"
READ_PATH_TEMPLATE = f"{BLUESKY_MOUNT_PATH / IMAGE_DIR}/"

HDF5: AD_EpicsFileNameHDF5Plugin#

The hdf1 component is where we depart from the default ophyd settings. The modifications come from apstools.devices in AD_EpicsFileNameHDF5Plugin(). We repeat certain cautionary details here:

Replace standard ophyd file naming algorithm (where file names are defined as UUID strings, virtually guaranteeing that no existing images files will ever be overwritten).

Caller is responsible for setting values of these Components of the plugin:

  • array_counter

  • auto_increment

  • auto_save

  • compression (only the HDF plugin)

  • create_directory

  • file_name

  • file_number

  • file_path

  • file_template

  • num_capture

Also note:

It is allowed to set file_template="%s%s.h5" if the file name should not include the file number.

Detector Object#

With the above setup, create the Python detector object, adsimdet and connect with EPICS.

[2]:
from apstools.devices import ad_creator

plugins = []
plugins.append({"cam": {"class": SimDetectorCam_V34}})
plugins.append(
    {
        "hdf1": {
            "class": AD_EpicsFileNameHDF5Plugin,
            "write_path_template": WRITE_PATH_TEMPLATE,
            "read_path_template": READ_PATH_TEMPLATE,
        }
    }
)
plugins.append("image")

adsimdet = ad_creator(IOC, name="adsimdet", plugins=plugins)
adsimdet.wait_for_connection(timeout=15)
adsimdet.wait_for_connection(timeout=15)
adsimdet.read_attrs.append("hdf1")
adsimdet.hdf1.create_directory.put(-5)
[3]:

# override default setting from ophyd adsimdet.cam.stage_sigs["wait_for_plugins"] = "Yes" adsimdet.hdf1.stage_sigs["blocking_callbacks"] = "No" adsimdet.hdf1.stage_sigs.move_to_end("capture", last=True) adsimdet.image.stage_sigs["blocking_callbacks"] = "No" # Needed if IOC has just been started adsimdet.hdf1.auto_increment.put("Yes") adsimdet.hdf1.auto_save.put("Yes") adsimdet.hdf1.create_directory.put(-5) ensure_AD_plugin_primed(adsimdet.hdf1, True)

Show the plugin staging now.

[4]:
adsimdet.hdf1.stage_sigs
[4]:
OrderedDict([('enable', 1),
             ('file_write_mode', 'Stream'),
             ('blocking_callbacks', 'No'),
             ('parent.cam.array_callbacks', 1),
             ('capture', 1)])

Configure the plugin’s file naming PVs

As noted above, the caller is now responsible for setting various values of the plugin. Here is a bluesky plan as an example. Feel free to set the values you need with your own code.

[5]:
def prepare_count(
    plugin, file_name, acquire_time, acquire_period,
    n_images=1,
    auto_increment="Yes",
    auto_save="Yes",
    compression=None,
    create_directory=-5,
    file_path=None,
    file_template=None,
):
    compression = compression or "zlib"
    file_path = file_path or plugin.write_path_template  # WRITE_PATH_TEMPLATE
    file_template = file_template or "%s%s_%4.4d.h5"
    n_images = max(n_images, 1)
    image_mode = "Multiple" if n_images > 1 else "Single"

    yield from bps.mv(
        adsimdet.cam.num_images, n_images,
        adsimdet.cam.acquire_time, acquire_time,
        adsimdet.cam.acquire_period, acquire_period,
        adsimdet.cam.image_mode, image_mode,
        plugin.auto_increment, auto_increment,
        plugin.auto_save, auto_save,
        plugin.create_directory, create_directory,
        plugin.file_name, file_name,
        plugin.file_path, file_path,
        plugin.num_capture, n_images,  # save all frames received
        plugin.compression, compression,
        plugin.file_template, file_template,
    )

bluesky#

Take an image.

[6]:
RE = bluesky.RunEngine({})
cat = databroker.temp().v2
RE.subscribe(cat.v1.insert)
RE.md["versions"]["apstools"] = apstools.__version__
RE.md["versions"]["hdf5plugin"] = hdf5plugin.version

NUM_FRAMES = 5
RE(prepare_count(adsimdet.hdf1, "test_file", 0.001, 0.002, NUM_FRAMES))
uids = RE(
    bp.count(
        [adsimdet],
        md=dict(
            title="Area Detector with custom HDF5 File Name",
            purpose="image",
            image_file_name_style="EPICS(AD PVs)",
        )
    )
)

# confirm the plugin captured the expected number of frames
assert adsimdet.hdf1.num_captured.get() == NUM_FRAMES

# Show the image file name on the bluesky (local) workstation:
local_file_name = AD_full_file_name_local(adsimdet.hdf1)
print(f"{local_file_name.exists()=} {local_file_name=}")
local_file_name.exists()=True local_file_name=PosixPath('/tmp/docker_ioc/iocad/tmp/adsimdet/2024/08/25/test_file_0002.h5')

databroker#

View the image from the databroker catalog.

[7]:

run = cat.v2[uids[0]] dataset = run.primary.read() dataset["adsimdet_image"][0][0].plot.pcolormesh() # Show the image file name on the bluesky (local) workstation # Use information from the databroker run _r = run.primary._resources[0] fname = pathlib.Path(f"{_r['root']}{_r['resource_path']}") print(f"{fname.exists()=}\n{fname=}") # confirm the name above () is the same print(f"{(local_file_name == fname)=}")
/home/prjemian/.conda/envs/bluesky_2024_2/lib/python3.11/site-packages/databroker/intake_xarray_core/base.py:23: FutureWarning: The return type of `Dataset.dims` will be changed to return a set of dimension names in future, in order to be more consistent with `DataArray.dims`. To access a mapping from dimension names to lengths, please use `Dataset.sizes`.
  'dims': dict(self._ds.dims),
fname.exists()=True
fname=PosixPath('/tmp/docker_ioc/iocad/tmp/adsimdet/2024/08/25/test_file_0002.h5')
(local_file_name == fname)=True
../_images/examples_de_1_adsim_hdf5_custom_names_14_2.png
[8]:
run = cat.v2[uids[0]]

local_file_name = AD_full_file_name_local(adsimdet.hdf1)
print(f"{local_file_name.exists()=}\n{local_file_name=}")

rsrc = run.primary._resources[0]
fname = pathlib.Path(f"{rsrc['root']}{rsrc['resource_path']}")
print(f"{fname.exists()=}\n{fname=}")

# confirm they are the same
print(f"{(local_file_name == fname)=}")

# confirm the data is of the expected shape
print(f"{run.primary.read()['adsimdet_image'].shape=}")
assert run.primary.read()["adsimdet_image"].shape == (1, NUM_FRAMES, 1024, 1024)
local_file_name.exists()=True
local_file_name=PosixPath('/tmp/docker_ioc/iocad/tmp/adsimdet/2024/08/25/test_file_0002.h5')
fname.exists()=True
fname=PosixPath('/tmp/docker_ioc/iocad/tmp/adsimdet/2024/08/25/test_file_0002.h5')
(local_file_name == fname)=True
run.primary.read()['adsimdet_image'].shape=(1, 5, 1024, 1024)

punx#

Next, we demonstrate access to the HDF5 image file using the punx program.

[9]:
from apstools.utils import unix

for line in unix(f"punx tree {local_file_name}"):
    print(line.decode().strip())
!!! WARNING: this program is not ready for distribution.

/tmp/docker_ioc/iocad/tmp/adsimdet/2024/08/25/test_file_0002.h5 : NeXus data file
  entry:NXentry
    @NX_class = "NXentry"
    data:NXdata
      @NX_class = "NXdata"
      data:NX_UINT8[5,1024,1024] = __array
        __array = [
            [
                [8, 18, 11, '...', 12]
                [14, 15, 10, '...', 11]
                [10, 12, 15, '...', 18]
                ...
                [12, 11, 17, '...', 17]
              ]
            [
                [7, 18, 14, '...', 20]
                [19, 17, 19, '...', 7]
                [19, 10, 20, '...', 12]
                ...
                [7, 14, 10, '...', 20]
              ]
            [
                [11, 13, 16, '...', 8]
                [16, 18, 8, '...', 18]
                [17, 15, 9, '...', 7]
                ...
                [10, 13, 15, '...', 24]
              ]
            [
                [18, 8, 17, '...', 14]
                [7, 9, 13, '...', 20]
                [19, 9, 10, '...', 19]
                ...
                [9, 12, 11, '...', 25]
              ]
            [
                [18, 8, 8, '...', 19]
                [20, 14, 20, '...', 20]
                [7, 9, 20, '...', 8]
                ...
                [19, 14, 15, '...', 14]
              ]
          ]
        @NDArrayDimBinning = [1 1]
        @NDArrayDimOffset = [0 0]
        @NDArrayDimReverse = [0 0]
        @NDArrayNumDims = 2
        @signal = 1
    instrument:NXinstrument
      @NX_class = "NXinstrument"
      NDAttributes:NXcollection
        @NX_class = "NXcollection"
        @hostname = "arf.jemian.org"
        NDArrayEpicsTSSec:NX_UINT32[5] = [1093459878, 1093459878, 1093459878, 1093459878, 1093459878]
          @NDAttrDescription = "The NDArray EPICS timestamp seconds past epoch"
          @NDAttrName = "NDArrayEpicsTSSec"
          @NDAttrSource = "Driver"
          @NDAttrSourceType = "NDAttrSourceDriver"
        NDArrayEpicsTSnSec:NX_UINT32[5] = [745783385, 746863292, 748942542, 751032765, 753122083]
          @NDAttrDescription = "The NDArray EPICS timestamp nanoseconds"
          @NDAttrName = "NDArrayEpicsTSnSec"
          @NDAttrSource = "Driver"
          @NDAttrSourceType = "NDAttrSourceDriver"
        NDArrayTimeStamp:NX_FLOAT64[5] = [1093459878.7232177, 1093459878.7458045, 1093459878.74786, 1093459878.7499433, 1093459878.7520318]
          @NDAttrDescription = "The timestamp of the NDArray as float64"
          @NDAttrName = "NDArrayTimeStamp"
          @NDAttrSource = "Driver"
          @NDAttrSourceType = "NDAttrSourceDriver"
        NDArrayUniqueId:NX_INT32[5] = [3236, 3237, 3238, 3239, 3240]
          @NDAttrDescription = "The unique ID of the NDArray"
          @NDAttrName = "NDArrayUniqueId"
          @NDAttrSource = "Driver"
          @NDAttrSourceType = "NDAttrSourceDriver"
      detector:NXdetector
        @NX_class = "NXdetector"
        data:NX_UINT8[5,1024,1024] = __array
          __array = [
              [
                  [8, 18, 11, '...', 12]
                  [14, 15, 10, '...', 11]
                  [10, 12, 15, '...', 18]
                  ...
                  [12, 11, 17, '...', 17]
                ]
              [
                  [7, 18, 14, '...', 20]
                  [19, 17, 19, '...', 7]
                  [19, 10, 20, '...', 12]
                  ...
                  [7, 14, 10, '...', 20]
                ]
              [
                  [11, 13, 16, '...', 8]
                  [16, 18, 8, '...', 18]
                  [17, 15, 9, '...', 7]
                  ...
                  [10, 13, 15, '...', 24]
                ]
              [
                  [18, 8, 17, '...', 14]
                  [7, 9, 13, '...', 20]
                  [19, 9, 10, '...', 19]
                  ...
                  [9, 12, 11, '...', 25]
                ]
              [
                  [18, 8, 8, '...', 19]
                  [20, 14, 20, '...', 20]
                  [7, 9, 20, '...', 8]
                  ...
                  [19, 14, 15, '...', 14]
                ]
            ]
          @NDArrayDimBinning = [1 1]
          @NDArrayDimOffset = [0 0]
          @NDArrayDimReverse = [0 0]
          @NDArrayNumDims = 2
          @signal = 1
        NDAttributes:NXcollection
          @NX_class = "NXcollection"
          ColorMode:NX_INT32[5] = [0, 0, 0, 0, 0]
            @NDAttrDescription = "Color mode"
            @NDAttrName = "ColorMode"
            @NDAttrSource = "Driver"
            @NDAttrSourceType = "NDAttrSourceDriver"
      performance
        timestamp:NX_FLOAT64[5,5] = __array
          __array = [
              [0.000233554, 0.126672046, 37.925298495, 63.15521263468026, 0.0]
              [0.040277158, 0.04070268, 0.040278826, 196.54725438226674, 198.61552072048974]
              [0.039427267, 0.039458489, 0.079737315, 202.74471229752362, 200.6588759603957]
              [0.039358019, 0.039393824, 0.119131139, 203.07751793783714, 201.45866312920924]
              [0.039170129, 0.039203981, 0.15833512, 204.06090901839792, 202.10298258529124]
            ]