How-To: Scan Area Detector with Motor(s)#

Goals

  • Use an EPICS motor (gp:m1) as the independent axis.

  • Use an EPICS area detector (ad:) to collect images at each step.

  • Record ROI statistics (total counts, maximum value, unique ID) without saving image files.

  • Run several scan plans: bp.count, bp.scan (1-D), bp.scan (2 motors), bp.grid_scan (2-D mesh).

This notebook requires a live EPICS system. Use ad: for the area detector IOC and gp: for the general-purpose IOC (motors).

Setup#

%matplotlib inline
import warnings

warnings.filterwarnings("ignore", category=FutureWarning)

import databroker
import bluesky
import bluesky.plans as bp
from bluesky.callbacks.best_effort import BestEffortCallback
from ophyd import EpicsMotor

from apstools.devices import ad_creator

cat = databroker.temp()
RE = bluesky.RunEngine({})
RE.subscribe(cat.v1.insert)
bec = BestEffortCallback()
RE.subscribe(bec)
1

EPICS Devices#

Connect to the motor and the area detector.

m1 = EpicsMotor("gp:m1", name="m1")
m2 = EpicsMotor("gp:m2", name="m2")
m1.wait_for_connection(timeout=10)
m2.wait_for_connection(timeout=10)
print(f"{m1.connected=}  position={m1.position:.4f}")
print(f"{m2.connected=}  position={m2.position:.4f}")
m1.connected=True  position=1.0000
m2.connected=True  position=0.5000

Create the area detector using ad_creator(). We include the cam, image, pva, roi1, and stats1 plugins. The hdf1 plugin is intentionally omitted here — we want ROI statistics only, with no image files written.

IOC = "ad:"

adsimdet = ad_creator(
    IOC,
    name="adsimdet",
    plugins=["cam", "image", "pva", "roi1", "stats1"],
)
adsimdet.wait_for_connection(timeout=15)
print(f"{adsimdet.connected=}")
adsimdet.connected=True

Configure the detector#

Route the image through the ROI plugin into the Stats plugin, and select which statistics signals to record.

# Wire up: cam -> roi1 -> stats1
adsimdet.roi1.nd_array_port.put(adsimdet.cam.port_name.get())
adsimdet.stats1.nd_array_port.put(adsimdet.roi1.port_name.get())

# One frame per trigger.
adsimdet.cam.stage_sigs["num_images"] = 1
adsimdet.cam.stage_sigs["wait_for_plugins"] = "Yes"

# Non-blocking callbacks for all plugins.
adsimdet.image.stage_sigs["blocking_callbacks"] = "No"
adsimdet.pva.stage_sigs["blocking_callbacks"] = "No"
adsimdet.roi1.stage_sigs["blocking_callbacks"] = "No"
adsimdet.stats1.stage_sigs["blocking_callbacks"] = "No"

# Hint the statistics signals we want in the scan table.
adsimdet.stats1.kind = "hinted"
adsimdet.stats1.total.kind = "hinted"
adsimdet.stats1.max_value.kind = "hinted"
adsimdet.stats1.unique_id.kind = "hinted"

Visualise the plugin chain#

adsimdet.visualize_asyn_digraph()
../_images/887b8b0de9773f9315dd0c52f8756b801b094b451a587226de37d93f2dcae308.png

bp.count — collect statistics without a motor scan#

uids = RE(bp.count([adsimdet], num=3))
cat.v2[uids[-1]].primary.read()[
    ["adsimdet_stats1_total", "adsimdet_stats1_max_value", "adsimdet_stats1_unique_id"]
]
Transient Scan ID: 1     Time: 2026-03-27 11:49:38
Persistent Unique Scan ID: 'c11b47fd-a1fc-467b-9aba-e620aa625656'
New stream: 'primary'
+-----------+------------+---------------------------+---------------------------+-----------------------+
|   seq_num |       time | adsimdet_stats1_unique_id | adsimdet_stats1_max_value | adsimdet_stats1_total |
+-----------+------------+---------------------------+---------------------------+-----------------------+
|         1 | 11:49:38.2 |                      2276 |                       269 |              29246287 |
|         2 | 11:49:38.6 |                      2277 |                       270 |              29297297 |
|         3 | 11:49:39.0 |                      2278 |                       269 |              28175553 |
+-----------+------------+---------------------------+---------------------------+-----------------------+
generator count ['c11b47fd'] (scan num: 1)

<xarray.Dataset> Size: 96B
Dimensions:                    (time: 3)
Coordinates:
  * time                       (time) float64 24B 1.775e+09 1.775e+09 1.775e+09
Data variables:
    adsimdet_stats1_total      (time) float64 24B 2.925e+07 2.93e+07 2.818e+07
    adsimdet_stats1_max_value  (time) float64 24B 268.8 269.9 269.5
    adsimdet_stats1_unique_id  (time) int64 24B 2276 2277 2278
../_images/8401d4151a2d95fce7f7a6a4d52fd440ed7aef40d93ef7f39a51a81a281b3cc6.png

bp.scan — 1-D scan with one motor#

Step m1 from −1 to +1 in 5 steps, collecting stats at each point.

uids = RE(bp.scan([adsimdet], m1, -1, 1, 5))
dataset = cat.v2[uids[-1]].primary.read()
dataset[["m1", "adsimdet_stats1_total", "adsimdet_stats1_max_value"]]
Transient Scan ID: 2     Time: 2026-03-27 11:49:40
Persistent Unique Scan ID: '1f640509-2ed0-41af-ace4-a7d1df6c6ce1'
New stream: 'primary'
+-----------+------------+------------+---------------------------+---------------------------+-----------------------+
|   seq_num |       time |         m1 | adsimdet_stats1_unique_id | adsimdet_stats1_max_value | adsimdet_stats1_total |
+-----------+------------+------------+---------------------------+---------------------------+-----------------------+
|         1 | 11:49:42.4 |    -1.0000 |                      2279 |                       268 |              29913680 |
|         2 | 11:49:43.6 |    -0.5000 |                      2280 |                       269 |              30736723 |
|         3 | 11:49:44.8 |    -0.0000 |                      2281 |                       271 |              30024676 |
|         4 | 11:49:45.9 |     0.5000 |                      2282 |                       271 |              30500824 |
|         5 | 11:49:47.0 |     1.0000 |                      2283 |                       267 |              31769754 |
+-----------+------------+------------+---------------------------+---------------------------+-----------------------+
generator scan ['1f640509'] (scan num: 2)

<xarray.Dataset> Size: 160B
Dimensions:                    (time: 5)
Coordinates:
  * time                       (time) float64 40B 1.775e+09 ... 1.775e+09
Data variables:
    m1                         (time) float64 40B -1.0 -0.5 -2.485e-06 0.5 1.0
    adsimdet_stats1_total      (time) float64 40B 2.991e+07 ... 3.177e+07
    adsimdet_stats1_max_value  (time) float64 40B 268.1 269.0 271.2 271.1 267.1
../_images/6bc0b17109a36f51b25f387be8ac8c8c1fca075354ebcc94804494d973d71347.png

bp.scan — 1-D scan with two motors together#

Both motors step simultaneously; same number of points.

uids = RE(
    bp.scan(
        [adsimdet],
        m1,
        -1,
        1,
        m2,
        0,
        2,
        5,
    )
)
dataset = cat.v2[uids[-1]].primary.read()
dataset[["m1", "m2", "adsimdet_stats1_total"]]
Transient Scan ID: 3     Time: 2026-03-27 11:49:48
Persistent Unique Scan ID: 'a55a6bef-07f5-4f13-b35e-833dd8efb708'
New stream: 'primary'
+-----------+------------+------------+------------+---------------------------+---------------------------+-----------------------+
|   seq_num |       time |         m1 |         m2 | adsimdet_stats1_unique_id | adsimdet_stats1_max_value | adsimdet_stats1_total |
+-----------+------------+------------+------------+---------------------------+---------------------------+-----------------------+
|         1 | 11:49:50.4 |    -1.0000 |     0.0000 |                      2284 |                       267 |              34214746 |
|         2 | 11:49:51.6 |    -0.5000 |     0.5000 |                      2285 |                       267 |              35908695 |
|         3 | 11:49:52.8 |    -0.0000 |     1.0000 |                      2286 |                       267 |              36131026 |
|         4 | 11:49:53.9 |     0.5000 |     1.5000 |                      2287 |                       269 |              35832060 |
|         5 | 11:49:55.1 |     1.0000 |     2.0000 |                      2288 |                       265 |              38058780 |
+-----------+------------+------------+------------+---------------------------+---------------------------+-----------------------+
generator scan ['a55a6bef'] (scan num: 3)

<xarray.Dataset> Size: 160B
Dimensions:                (time: 5)
Coordinates:
  * time                   (time) float64 40B 1.775e+09 1.775e+09 ... 1.775e+09
Data variables:
    m1                     (time) float64 40B -1.0 -0.5 -2.485e-06 0.5 1.0
    m2                     (time) float64 40B 0.0 0.5 1.0 1.5 2.0
    adsimdet_stats1_total  (time) float64 40B 3.421e+07 3.591e+07 ... 3.806e+07
../_images/69b2c2f00be0c717a254d789bb60a4bf0e033effe68c287edac36de3dee2e8b1.png

bp.grid_scan — 2-D mesh scan#

m1 steps along the outer axis; m2 steps along the inner axis (snake pattern).

uids = RE(
    bp.grid_scan(
        [adsimdet],
        m1,
        -1,
        1,
        3,
        m2,
        -0.5,
        0.5,
        4,
        snake_axes=True,
    )
)
dataset = cat.v2[uids[-1]].primary.read()
dataset[["m1", "m2", "adsimdet_stats1_total", "adsimdet_stats1_max_value"]]
Transient Scan ID: 4     Time: 2026-03-27 11:49:56
Persistent Unique Scan ID: '90199571-e23d-4ff1-8a9d-456eb99e10fa'
New stream: 'primary'
+-----------+------------+------------+------------+---------------------------+---------------------------+-----------------------+
|   seq_num |       time |         m1 |         m2 | adsimdet_stats1_unique_id | adsimdet_stats1_max_value | adsimdet_stats1_total |
+-----------+------------+------------+------------+---------------------------+---------------------------+-----------------------+
|         1 | 11:49:58.9 |    -1.0000 |    -0.5000 |                      2289 |                       269 |              38630924 |
|         2 | 11:50:00.4 |    -1.0000 |    -0.1667 |                      2290 |                       269 |              38239819 |
|         3 | 11:50:01.7 |    -1.0000 |     0.1667 |                      2291 |                       267 |              37635304 |
|         4 | 11:50:03.1 |    -1.0000 |     0.5000 |                      2292 |                       270 |              37289665 |
|         5 | 11:50:05.1 |    -0.0000 |     0.5000 |                      2293 |                       272 |              38115543 |
|         6 | 11:50:06.4 |    -0.0000 |     0.1667 |                      2294 |                       267 |              39025189 |
|         7 | 11:50:07.7 |    -0.0000 |    -0.1667 |                      2295 |                       270 |              38914974 |
|         8 | 11:50:09.0 |    -0.0000 |    -0.5000 |                      2296 |                       271 |              38481101 |
|         9 | 11:50:11.1 |     1.0000 |    -0.5000 |                      2297 |                       272 |              38741140 |
|        10 | 11:50:12.4 |     1.0000 |    -0.1667 |                      2298 |                       266 |              37114590 |
|        11 | 11:50:13.8 |     1.0000 |     0.1667 |                      2299 |                       271 |              38317887 |
|        12 | 11:50:15.1 |     1.0000 |     0.5000 |                      2300 |                       269 |              39950394 |
+-----------+------------+------------+------------+---------------------------+---------------------------+-----------------------+
generator grid_scan ['90199571'] (scan num: 4)
<xarray.Dataset> Size: 480B
Dimensions:                    (time: 12)
Coordinates:
  * time                       (time) float64 96B 1.775e+09 ... 1.775e+09
Data variables:
    m1                         (time) float64 96B -1.0 -1.0 -1.0 ... 1.0 1.0 1.0
    m2                         (time) float64 96B -0.5 -0.1667 ... 0.1667 0.5
    adsimdet_stats1_total      (time) float64 96B 3.863e+07 ... 3.995e+07
    adsimdet_stats1_max_value  (time) float64 96B 268.7 269.3 ... 271.2 268.6
../_images/43cf3c70d9f69c4167ce87cce51c9eee9be473452b1e33443652c20047777258.png

Bonus: scan with HDF5 image files#

The scans above record only ROI statistics — no image files are written. To also write image files, add the hdf1 file-writer plugin to the detector. Everything else — the motor scan, the ROI statistics — works identically.

File paths#

The area detector IOC and the bluesky workstation may mount the image directory under different paths. Define both so the HDF5 plugin can be configured correctly.

import pathlib

AD_IOC_MOUNT_PATH = pathlib.Path("/tmp")
BLUESKY_MOUNT_PATH = pathlib.Path("/tmp/docker_ioc/iocad/tmp")
IMAGE_DIR = "images"

# Must end with '/' — pathlib does not add it.
WRITE_PATH_TEMPLATE = f"{AD_IOC_MOUNT_PATH / IMAGE_DIR}/"
READ_PATH_TEMPLATE = f"{BLUESKY_MOUNT_PATH / IMAGE_DIR}/"

Create the detector with hdf1#

Add the hdf1 plugin to the list passed to ad_creator(). The write_path_template and read_path_template tell the plugin where to write files (IOC path) and where bluesky can read them back (local path).

from apstools.devices import HDF5FileWriterPlugin
from apstools.devices import ensure_AD_plugin_primed

det_hdf = ad_creator(
    IOC,
    name="det_hdf",
    plugins=[
        "cam",
        "image",
        "pva",
        "roi1",
        "stats1",
        {
            "hdf1": {
                "class": HDF5FileWriterPlugin,
                "write_path_template": WRITE_PATH_TEMPLATE,
                "read_path_template": READ_PATH_TEMPLATE,
            }
        },
    ],
)
det_hdf.wait_for_connection(timeout=15)
print(f"{det_hdf.connected=}")
det_hdf.connected=True

Configure#

Same ROI-statistics setup as above, plus HDF5-specific settings. ensure_AD_plugin_primed() ensures ophyd knows the image dimensions before acquisition begins.

det_hdf.roi1.nd_array_port.put(det_hdf.cam.port_name.get())
det_hdf.stats1.nd_array_port.put(det_hdf.roi1.port_name.get())

det_hdf.cam.stage_sigs["num_images"] = 1
det_hdf.cam.stage_sigs["wait_for_plugins"] = "Yes"
det_hdf.image.stage_sigs["blocking_callbacks"] = "No"
det_hdf.pva.stage_sigs["blocking_callbacks"] = "No"
det_hdf.roi1.stage_sigs["blocking_callbacks"] = "No"
det_hdf.stats1.stage_sigs["blocking_callbacks"] = "No"
det_hdf.hdf1.stage_sigs["blocking_callbacks"] = "No"

det_hdf.stats1.kind = "hinted"
det_hdf.stats1.total.kind = "hinted"
det_hdf.stats1.max_value.kind = "hinted"
det_hdf.stats1.unique_id.kind = "hinted"

det_hdf.hdf1.kind = "hinted"
det_hdf.hdf1.create_directory.put(-5)

ensure_AD_plugin_primed(det_hdf.hdf1, allow=True)

Scan — ROI statistics and HDF5 image files#

The scan collects the same statistics as before. In addition, each image is written to an HDF5 file by the hdf1 plugin.

uids = RE(bp.scan([det_hdf], m1, -1, 1, 5))
dataset = cat.v2[uids[-1]].primary.read()
dataset[["m1", "det_hdf_stats1_total", "det_hdf_stats1_max_value"]]
Transient Scan ID: 5     Time: 2026-03-27 11:50:17
Persistent Unique Scan ID: '4d69ce0a-169b-412e-a27f-9767d8358f40'
New stream: 'primary'
+-----------+------------+------------+--------------------------+--------------------------+----------------------+
|   seq_num |       time |         m1 | det_hdf_stats1_unique_id | det_hdf_stats1_max_value | det_hdf_stats1_total |
+-----------+------------+------------+--------------------------+--------------------------+----------------------+
|         1 | 11:50:20.0 |    -1.0000 |                     2301 |                      271 |             36807077 |
|         2 | 11:50:21.4 |    -0.5000 |                     2302 |                      266 |             34654953 |
|         3 | 11:50:23.0 |    -0.0000 |                     2303 |                      271 |             36628788 |
|         4 | 11:50:24.4 |     0.5000 |                     2304 |                      269 |             34445429 |
|         5 | 11:50:25.8 |     1.0000 |                     2305 |                      272 |             35464250 |
+-----------+------------+------------+--------------------------+--------------------------+----------------------+
generator scan ['4d69ce0a'] (scan num: 5)

<xarray.Dataset> Size: 160B
Dimensions:                   (time: 5)
Coordinates:
  * time                      (time) float64 40B 1.775e+09 ... 1.775e+09
Data variables:
    m1                        (time) float64 40B -1.0 -0.5 -2.485e-06 0.5 1.0
    det_hdf_stats1_total      (time) float64 40B 3.681e+07 ... 3.546e+07
    det_hdf_stats1_max_value  (time) float64 40B 271.1 265.6 270.5 269.2 271.9
../_images/d7552d9ca177e85bb0f4001b148e31d8b30899ac09cd7f9b31b34ea0b7772b67.png

Locate the image file#

from apstools.devices import AD_full_file_name_local

image_file = AD_full_file_name_local(det_hdf.hdf1)
print(f"Image file: {image_file}")
print(f"Exists on local filesystem: {image_file.exists()}")
Image file: /tmp/docker_ioc/iocad/tmp/images/d3f38f4e-1bf7-4846-9139_000000.h5
Exists on local filesystem: True