.. _areadetector_tutorial:
AreaDetector Tutorial
=====================
You should already know how to create a `block_` in the `device_layer_` that
looks like a detector, and how to integrate it into a `scan_layer_` Block, using
a `DetectorChildPart`. Now let's build a Detector Block to control an
`EPICS`_ `areaDetector`_ `simDetector`_ and its `plugin chain`_, and integrate
it into a scan.
While we use `simDetector`_ for this tutorial, support for other detectors can
be found in the ``./malcolm/modules`` directory.
Acquisition Strategy
--------------------
The application we have in mind is a multi-dimensional continuous scan, so we
want to be able to take a number of frames with the detector driver, calculate
some statistics on them, and write them in the same dimensionality as the scan
suggests into a `NeXus`_ formatted `HDF5`_ file. The driver and each plugin in
the chain will be represented by a Block in the `hardware_layer_`, and they will
all be controlled detector Block in the `device_layer_`. This is best viewed as
a diagram:
.. digraph:: simDetector_child_connections
bgcolor=transparent
compound=true
node [fontname=Arial fontsize=10 shape=rect style=filled fillcolor="#8BC4E9"]
graph [fontname=Arial fontsize=10]
edge [fontname=Arial fontsize=10 arrowhead=vee]
subgraph cluster_device {
label="Device Layer"
style=filled
color=lightgrey
subgraph cluster_detector {
label="DETECTOR"
ranksep=0.1
color=white
detector_c [label="RunnableController"]
DRV [label=name: 'DRV'>]
POS [label=name: 'POS'>]
STAT [label=name: 'STAT'>]
HDF [label=name: 'HDF'>]
DSET [label=name: 'DSET'>]
detector_c -> DRV [style=invis]
detector_c -> HDF [style=invis]
DRV -> DSET [style=invis]
{rank=same; DRV -> POS -> STAT -> HDF}
}
}
subgraph cluster_hardware {
label="Hardware Layer"
style=filled
color=lightgrey
subgraph cluster_drv {
label="DETECTOR:DRV"
color=white
drv_c [label="StatefulController"]
drv_p [label="CAParts"]
drv_c -> drv_p [style=invis]
}
subgraph cluster_pos {
label="DETECTOR:POS"
color=white
pos_c [label="StatefulController"]
pos_p [label="CAParts"]
pos_c -> pos_p [style=invis]
}
subgraph cluster_stat {
label="DETECTOR:STAT"
color=white
stat_c [label="StatefulController"]
stat_p [label="CAParts"]
stat_c -> stat_p [style=invis]
}
subgraph cluster_hdf {
label="DETECTOR:HDF"
color=white
hdf_c [label="StatefulController"]
hdf_p [label="CAParts"]
hdf_c -> hdf_p [style=invis]
}
}
DRV -> drv_c [lhead=cluster_drv minlen=3 style=dashed]
POS -> pos_c [lhead=cluster_pos minlen=3 style=dashed]
STAT -> stat_c [lhead=cluster_stat minlen=3 style=dashed]
HDF -> hdf_c [lhead=cluster_hdf minlen=3 style=dashed]
.. note::
There is a separation and hence an interface between `part_` and child
`block_`. The interface goes in the child Block, and the logic goes in the
controlling Part. This is desirable because we could potentially have many
possible logic Parts that could control the same kind of child Block, and
making this split keeps the Parts small and more readable.
Each Hardware Block is responsible for controlling a group of `PVs`_ that make
up a single plugin or driver:
- The DRV Block corresponds to the `simDetector`_ driver, which is responsible
for producing the right number of `NDArrays`_, each tagged with a unique ID.
- The POS Block corresponds to the `NDPosPlugin`_ plugin which tags each NDArray
with a number of attributes that can be used to determine its position within
the dataset dimensions.
- The STAT Block corresponds to the `NDPluginStats`_ plugin which tags each
NDArray with a number of statistics that can be calculated from the data.
- The HDF Block corresponds to the `NDFileHDF5`_ plugin which writes NDArrays
into an HDF file, getting the position within the dataset dimensions from an
attribute attached to the NDArray.
The detector Device Block contains 4 Parts, one for each Hardware Block, that
are responsible for setting `Attributes ` on the relevant child
Block in the right order. The Controller is responsible for calling each of its
Parts `Hooked ` methods in the right order.
.. note::
Malcolm's role in this application is purely supervisory, it just sets up
the underlying plugins and presses Acquire. `EPICS`_ is responsible for
writing data
Creating the Blocks
-------------------
So let's start with the `process_definition_`
``./malcolm/modules/demo/DEMO-AREADETECTOR.yaml``:
.. literalinclude:: ../../malcolm/modules/demo/DEMO-AREADETECTOR.yaml
:language: yaml
We have a couple more items to explain than in previous examples:
- The `builtin.defines.cmd_string` entry runs the shell command ``hostname -s``
and makes it available inside this YAML file as ``$(hostname)``. This is
needed because we are interfacing to an IOC that calculates the PV prefix
based on the machine we are currently running on.
- The `builtin.defines.export_env_string` entries are so that we can export the
EPICS server and repeater ports, again required as the IOC runs on these
ports.
The other items are Blocks just like we encountered in previous tutorials.
Device Block
------------
The top level Device Block is a `sim_detector_runnable_block`. Let's take a look
at ``./malcolm/modules/ADSimDetector/blocks/sim_detector_runnable_block.yaml``
to see what one of those looks like:
.. literalinclude:: ../../malcolm/modules/ADSimDetector/blocks/sim_detector_runnable_block.yaml
:language: yaml
The top of the file tells us what parameters should be passed, and defines a
docstring for the Block. After that we instantiate the `RunnableController`,
`sim_detector_driver_block` and its corresponding `DetectorDriverPart`, and
then a `stats_plugin_block` with is corresponding `StatsPluginPart`.
The entry after this is an `include_`. It lets us take some commonly used Blocks
and Parts and instantiate them at the level of the currently defined Block. If
we look at ``./malcolm/modules/ADCore/includes/filewriting_collection.yaml``
we'll see how it does this:
.. literalinclude:: ../../malcolm/modules/ADCore/includes/filewriting_collection.yaml
:language: yaml
This will also instantiate the `DatasetTablePart`, `position_labeller_block` and
it corresponding `PositionLabellerPart`, and then `hdf_writer_block` with its
corresponding `HDFWriterPart`.
The reason we use an include file is so that other detectors can use this same
filewriting collection without having to copy and paste into the top level
object. There is some duplication in the parameter descriptions, but it ensures
that each YAML file is a self contained description of this level downwards.
Hardware Blocks
---------------
If we look at the next level down at something like
``./malcolm/modules/ADSimDetector/blocks/sim_detector_driver_block.yaml`` we
will see our PV interface:
.. literalinclude:: ../../malcolm/modules/ADSimDetector/blocks/sim_detector_driver_block.yaml
:language: yaml
After the parameters, defines and `StatefulController` definition, most of our
CAPart objects are instantiated in
``./malcolm/modules/ADCore/includes/adbase_parts.yaml``. Let's look at the
start of that file:
.. literalinclude:: ../../malcolm/modules/ADCore/includes/adbase_parts.yaml
:language: yaml
:end-before: # For docs: before acquiring
This include structure mirrors that of the underlying templates, and allows us
to maintain a one to one mapping of YAML file to template file. If you look at
all of these CAParts you will see that they wrap up small numbers of PVs into
recognisable Attributes and Methods.
For instance:
.. literalinclude:: ../../malcolm/modules/ADCore/includes/adbase_parts.yaml
:language: yaml
:lines: 18-22
This corresponds to an `attribute_` that caputs to the ``ImageMode`` pv with
callback when set, and uses ``ImageMode_RBV`` as the current value.
Alternatively:
.. literalinclude:: ../../malcolm/modules/ADCore/includes/adbase_parts.yaml
:language: yaml
:lines: 30-35
This corresponds to a `method_` that caputs to the ``Acquire`` pv with callback,
and when it completes checks ``DetectorState_RBV`` to see if the detector
completed successfully or with an error.
Template Designs
----------------
One of the benefits of splitting the Hardware Layer from the Device Layer is
that we now get a useful interface that tells us what to load and save. We
tag all writeable CAParts as config Attributes by default, which will mean that
when we ``save()`` the Device Block, it will write the current value of all
these Attributes of all its child Hardware Blocks to a `design_` file.
We learned in the `motion_tutorial` that Designs are JSON formatted files stored
in the ``config_dir`` on ``save()``, and that they can be loaded by setting
the ``design`` Attribute at runtime. We now introduce the concept of a
`template_design_`. This is a read-only Design that demonstrates how a Block
might be used to implement a particular use case. It always starts with the
text ``template_``.
In our demo, we want our simDetector wired up in such a way that we can
implement the Acquisition Strategy set out earlier. The ``ADSimDetector``
module provides a design ``template_software_triggered`` that will do this for
us. We would discover this by running up Malcolm, and seeing the possible values
in the ``design`` drop-down list. If you are interested you can click
below to expand the text of
``blocks/sim_detector_runnable_block_designs/template_software_triggered.json``
in ``./malcolm/modules/ADSimDetector/`` to see what it will load:
.. container:: toggle
.. container:: header
Template Design JSON: template_software_triggered
.. literalinclude:: ../../malcolm/modules/ADSimDetector/blocks/sim_detector_runnable_block_designs/template_software_triggered.json
:language: json
This Design will setup the plugin chain correctly for areaDetector to work the
way that Malcolm expects. In particular it makes sure that the plugins are in
the correct way that the HDF writer gets the tags it expects on each NDArray
that it receives. There may be many template designs associated with a
particular type of Block to support different use cases.
.. note::
areaDetector plugin chain wiring is done in the Design file rather than in
each plugin Part. This means that the chain can be rewired for different
scan use cases without having to change the code contained plugin Part.
Scan Block Design
-----------------
Scan Blocks can have saved `design_` files just like Device Blocks. The
difference is that they have far fewer entries as their children typically save
their config in their own Design files. If we look at
``./malcolm/modules/demo/blocks/scan_2det_block_designs/template_both_detectors.json``
we will see just how few entries there are:
.. literalinclude:: ../../malcolm/modules/demo/blocks/scan_2det_block_designs/template_both_detectors.json
:language: json
There are a couple of scan Block Attributes that are saved here:
- simultaneousAxes: The superset of axes that are allowed in axesToMove. This
is used to specify the set of axes that are currently movable in a single
run()
- label: A short human readable label that identifies to a user what the scan
will do if run
The reason that these are saved rather than specified in the scan Block
definition or Process Definition is that multiple instances of this scan can
be created with different values for these Attributes.
We imagine that each Device Block will have a number of designs for
hardware or software triggering or different motor setups, and the Scan Block
will say "I need DET with the hardware_trigger design and MOTORS with
hkl_geometry".
The Scan Block will not load its children's designs at init, but will set them
before every ``configure()`` call, ensuring the Device Blocks are all setup
correctly at the beginning of every scan.
Now we know what we need to load, we need to work out when to load it. There is
an ``initial_design`` parameter that we pass to any `ManagerController` or
`RunnableController` that will tell it what design to load when Malcolm starts
up, and we have two layers that are able to load an `initial_design_`:
1. In the detector (Device Layer). In this case, the design will be loaded as
soon as Malcolm starts, but if there is not a clear single design that all
scans use then it is not clear what to set it to.
2. In the scan (Scan Layer). As child designs are loaded on ``configure()``,
the initial_design loading will be deferred until the first time the scan
is run. This means that different scan Blocks can use different initial
designs for their children. For instance one scan Block could require a
Detector to be in software triggered mode, and another scan Block could
require the Detector to be in hardware triggered mode.
In a production system we will generally set the ``initial_design`` of our
scan Blocks (case 2), but we may additionally set the ``initial_design`` of our
child Blocks (case 1) if we want to ensure a particular configuration on Malcolm
startup.
The detector setting at startup is not relevant to us here, so we will set the
``initial_design`` only on the scan Block.
.. caution::
If you set ``initial_design`` on a Block in the ``device_layer``, including
detector Blocks, then PVs will change when you restart Malcolm. This may or
may not be what you want.
It is worth pointing out that we are only likely to set ``initial_design``
once a scan is working. Once a design is set, it will be restored every
``configure()``, so a ``save()`` or unsetting the design is required to keep
any manual changes to child Blocks.
Running a Scan
--------------
First you need an areaDetector IOC. From the Diamond launcher, select
``Utilities -> GDA AreaDetector Simulation``, then click the ``Start IOC``
button.
Let's start up the example and see it in action::
[me@mypc pymalcolm]$ pipenv run imalcolm malcolm/modules/demo/DEMO-AREADETECTOR.yaml
Loading malcolm...
Python 3.7.2 (default, Jan 20 2020, 11:03:41)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.19.0 -- An enhanced Interactive Python. Type '?' for help.
Welcome to iMalcolm.
self.mri_list:
['mypc-ML-MOT-01:COUNTERX', 'mypc-ML-MOT-01:COUNTERY', 'mypc-ML-MOT-01', 'mypc-ML-DET-01', 'mypc-ML-DET-02:DRV', 'mypc-ML-DET-02:STAT', 'mypc-ML-DET-02:POS', 'mypc-ML-DET-02:HDF5', 'mypc-ML-DET-02', 'mypc-ML-SCAN-01', 'mypc:WEB', 'mypc:PVA']
# To create a view of an existing Block
block = self.block_view("")
# To create a proxy of a Block in another Malcolm
self.make_proxy("", "")
block = self.block_view("")
# To view state of Blocks in a GUI
!firefox localhost:8008
In [1]:
This time we will configure from the commandline. You may have some of these
lines in your history from earlier tutorials. Note you will need to replace
'mypc' with the name of your pc::
In [1]: from scanpointgenerator import LineGenerator, CompoundGenerator
In [2]: scan = self.block_view("mypc-ML-SCAN-01")
In [3]: yline = LineGenerator("y", "mm", -1, 0, 6)
In [4]: xline = LineGenerator("x", "mm", 4, 5, 5, alternate=True)
In [5]: generator = CompoundGenerator([yline, xline], [], [], duration=0.5)
In [6]: scan.configure(generator, "/tmp")
After configure, the detector will also report the datasets that it is about
to write in the ``datasets`` Attribute::
In [7]: from annotypes import json_encode
In [8]: print(json_encode(scan.datasets.value, indent=4))
{
"typeid": "malcolm:core/Table:1.0",
"name": [
"INTERFERENCE.data",
"INTERFERENCE.sum",
"y.value_set",
"x.value_set",
"RAMP.data",
"RAMP.sum",
"y.value_set",
"x.value_set"
],
"filename": [
"INTERFERENCE.h5",
"INTERFERENCE.h5",
"INTERFERENCE.h5",
"INTERFERENCE.h5",
"RAMP.h5",
"RAMP.h5",
"RAMP.h5",
"RAMP.h5"
],
"type": [
"primary",
"secondary",
"position_set",
"position_set",
"primary",
"secondary",
"position_set",
"position_set"
],
"rank": [
4,
4,
1,
1,
4,
4,
1,
1
],
"path": [
"/entry/data",
"/entry/sum",
"/entry/y_set",
"/entry/x_set",
"/entry/detector/detector",
"/entry/sum/sum",
"/entry/detector/y_set",
"/entry/detector/x_set"
],
"uniqueid": [
"/entry/uid",
"/entry/uid",
"",
"",
"/entry/NDAttributes/NDArrayUniqueId",
"/entry/NDAttributes/NDArrayUniqueId",
"",
""
]
}
This is very similar to the `scanning_tutorial`, but now datasets are reported
from both detectors. Their setpoints are also reported for every scannable in
every file. This is to allow a triggering scheme where a detector produces
multiple frames for each scan point (explained in a future tutorial).
Now that you have the files open, you can use the `h5watch`_ command to monitor
the dataset and see it grow::
[me@mypc pymalcolm]$ h5watch /tmp/INTERFERENCE.h5/entry/uid
Opened "/tmp/INTERFERENCE.h5" with sec2 driver.
Monitoring dataset /entry/uid...
You will be able to run a the same h5watch command on
``/tmp/RAMP.h5/entry/NDAttributes/NDArrayUniqueId`` to see the areaDetector
dataset grow, but only when the scan has started as the HDF writer can't write
the datasets until it knows the size of the first detector frame.
You can open the web GUI again to inspect the state of the various objects,
and you will see that both the ``RAMP`` and ``INTERFERENCE`` detector objects
are in state ``Armed``, as is the ``SCAN``. You can then run a scan, either from
the web GUI or the commandline. Other than h5watch, the commandline tools are
not SWMR aware, so a reset is required in order to read the updated files::
In [9]: scan.run()
In [10]: scan.reset()
This will write 30 frames of data to ``/tmp/INTERFERENCE.h5`` directly, and
supervise the writing of 30 frames of data to ``/tmp/RAMP.h5`` via areaDetector.
You can take a look at the `HDF5`_ files to see what has been written::
[me@mypc pymalcolm]$ module load hdf5/1-10-4
[me@mypc pymalcolm]$ h5dump -n /tmp/RAMP.h5
HDF5 "/tmp/RAMP.h5" {
FILE_CONTENTS {
group /
group /entry
group /entry/NDAttributes
dataset /entry/NDAttributes/ColorMode
dataset /entry/NDAttributes/NDArrayEpicsTSSec
dataset /entry/NDAttributes/NDArrayEpicsTSnSec
dataset /entry/NDAttributes/NDArrayTimeStamp
dataset /entry/NDAttributes/NDArrayUniqueId
dataset /entry/NDAttributes/d0
dataset /entry/NDAttributes/d1
dataset /entry/NDAttributes/timestamp
group /entry/detector
dataset /entry/detector/detector
dataset /entry/detector/x_set
dataset /entry/detector/y_set
group /entry/sum
dataset /entry/sum/sum
dataset /entry/sum/x_set -> /entry/detector/x_set
dataset /entry/sum/y_set -> /entry/detector/y_set
}
}
This corresponds to the dataset table that the Block reported before run() was
called. You can examine the uniqueid dataset to see the order that the frames
were written::
[me@mypc pymalcolm]$ h5dump -d /entry/NDAttributes/NDArrayUniqueId /tmp/RAMP.h5
HDF5 "/tmp/RAMP.h5" {
DATASET "/entry/NDAttributes/NDArrayUniqueId" {
DATATYPE H5T_STD_I32LE
DATASPACE SIMPLE { ( 6, 5, 1, 1 ) / ( H5S_UNLIMITED, H5S_UNLIMITED, 1, 1 ) }
DATA {
(0,0,0,0): 1,
(0,1,0,0): 2,
(0,2,0,0): 3,
(0,3,0,0): 4,
(0,4,0,0): 5,
(1,0,0,0): 10,
(1,1,0,0): 9,
(1,2,0,0): 8,
(1,3,0,0): 7,
(1,4,0,0): 6,
(2,0,0,0): 11,
(2,1,0,0): 12,
(2,2,0,0): 13,
(2,3,0,0): 14,
(2,4,0,0): 15,
(3,0,0,0): 20,
(3,1,0,0): 19,
(3,2,0,0): 18,
(3,3,0,0): 17,
(3,4,0,0): 16,
(4,0,0,0): 21,
(4,1,0,0): 22,
(4,2,0,0): 23,
(4,3,0,0): 24,
(4,4,0,0): 25,
(5,0,0,0): 30,
(5,1,0,0): 29,
(5,2,0,0): 28,
(5,3,0,0): 27,
(5,4,0,0): 26
}
...
This tells us that it was written in a snake fashion, with the first row
written 1-5 left-to-right, the second row 6-10 right-to-left, etc.
The detector will always increment the uniqueid number when it writes a new
frame, so if you try pausing and setting the Completed Steps Attribute, you
will see the uniqueID number jump where you overwrite existing frames with new
frames with a greater uniqueID. The two detectors will end up with matching
uniqueID datasets.
Conclusion
----------
This tutorial has given us an understanding of how `areaDetector`_ plugin
chains can be controlled in Malcolm, and how multiple detectors interface into
a scan and can be paused and rewound together. The next tutorial will focus
on using real hardware to perform a continuous scan.
.. _simDetector:
http://cars.uchicago.edu/software/epics/simDetectorDoc.html
.. _plugin chain:
http://cars.uchicago.edu/software/epics/pluginDoc.html
.. _NDArrays:
http://cars.uchicago.edu/software/epics/areaDetectorDoc.html#NDArray
.. _NDPosPlugin:
http://cars.uchicago.edu/software/epics/NDPosPlugin.html
.. _NDPluginStats:
http://cars.uchicago.edu/software/epics/NDPluginStats.html
.. _NDFileHDF5:
http://cars.uchicago.edu/software/epics/NDFileHDF5.html
.. _h5watch:
https://support.hdfgroup.org/HDF5/doc/RM/Tools/h5watch.htm