Detector Tutorial

You should already know how to create a Part that can be instantiated multiple times in a ManagerController with each instance creating a Block in the Device Layer below. In the example we exposed a simple interface with a couple of Methods to simulate a Motion Controller. We will now build an interface that looks like a Detector, taking a scan specification, and writing data to an HDF file. To do this, we will introduce a new configure/run interface that will make using these detectors in a scan more straightforward. The configure stage is where as much setup as possible is done, then the run stage is a supervisory stage where the scan is performed.

Specifying Scan Points

If this Detector Block is going to simulate running a scan, we need to learn how to specify a scan. There are a number of pieces of information about each point in a scan that are needed by Malcolm:

  • The demand positions of a number of actuators representing where they should be at the mid-point of a detector frame. This is needed for step scans and continuous scans.

  • The demand positions of those actuators at the upper and lower bounds (start and end) of that detector frame. This is only needed for continuous scans where each detector frame is taken while the actuators were moving rather than a step scan where they are static.

  • The index in the data file that the frame should be stored. For grid based scans (like a snake scan) these will have the same dimensions as the demand positions. For non grid based scans (like a spiral scan) these will have less dimensions because the datapoints do not fit onto a regular grid.

  • The duration of the frame. This is needed for continuous scans and is the time taken to get from the lower to the upper bound.

The size of each index dimension and units for each actuator are also needed for file writing.

Rather than passing all this information in one large structure, a separate project called Scan Point Generator has been setup to create parameterized generators which work together to generate multi-dimensional scan paths. We will make our Detector Block understand these generators.

Creating Runnable Device Blocks

Let’s take a look at the Process Definition ./malcolm/modules/demo/DEMO-DETECTOR.yaml:

# Define a directory to store config in
- builtin.defines.tmp_dir:
    name: config_dir

# Create some Blocks
- demo.blocks.detector_block:
    mri: DETECTOR
    config_dir: $(config_dir)

# Add a webserver
- web.blocks.web_server_block:
    mri: WEB

Again, apart from the web server there is just one Block, specified in ./malcolm/modules/demo/blocks/detector_block.yaml:

- builtin.parameters.string:
    name: mri
    description: MRI for created block

- builtin.parameters.string:
    name: config_dir
    description: Where to store saved configs

- builtin.parameters.int32:
    name: width
    description: Width of the produced image
    default: 160

- builtin.parameters.int32:
    name: height
    description: Height of the produced image
    default: 120

- builtin.parameters.string:
    name: label
    description: Beamline specific label for the detector
    default: DemoDetector

- builtin.parameters.string:
    name: readout_time
    description: Readout time of the detector
    default: 0.001

- builtin.defines.docstring:
    value: |
      This block uses a demo FileWriter and exposes a Detector like interface
      that can be controlled with a DetectorChildPart

- scanning.controllers.RunnableController:
    mri: $(mri)
    config_dir: $(config_dir)
    description: |
      Demo detector that writes HDF files for an x, y scan

- builtin.parts.LabelPart:
    value: $(label)

- scanning.parts.DatasetTablePart:
    name: DSET

- scanning.parts.ExposureDeadtimePart:
    name: EXPOSURE
    readout_time: $(readout_time)
    min_exposure: 0.0001

- demo.parts.FileWritePart:
    name: FW
    width: $(width)
    height: $(height)

We instantiate a DatasetTablePart to report the datasets that we will write (see Datasets and Detectors), a FileWritePart to write some dummy data to an HDF file, and then use a RunnableController to construct our Block.

We also instantiate a LabelPart which simply allows us to give this detector a human readable label (a couple of words long) that is suitable for display on GUIs.

This produces a single DETECTOR Device Block.

Note

Unlike the previous example, there are no Child Blocks in the Hardware Layer. This is because we are making a cut down demo, in a real example like in the AreaDetector Tutorial we would have child Blocks and multiple parts to control them.

Controller for Runnable Device Blocks

As well as controlling child Blocks, our Block needs to respond to a configure()/run() interface. A RunnableController implements this interface. It inherits ManagerController, so has all the Attributes and Methods we introduced in the Motion Tutorial, as well as the following Attributes:

Attribute

Description

completedSteps

Readback of the last completed ScanPointGenerator step number in the current scan. Also accepts Put when configured to seek to a different step in the scan

configuredSteps

The last configured step that will be complete when run() returns successfully

totalSteps

The total number of steps contained in the current ScanPointGenerator. This will be different to configuredSteps if Malcolm is not being asked to move all the axes in the generator

And the following Methods:

Method

Description

validate

Check that a set of scan parameters are valid for a run

configure

Configure the device ready for a run

run

Run a device where configure has already been called

abort

Abort the current operation

pause

Pause the current Run, reconfiguring around the last good step

resume

Resume a paused run

It implements the following complicated looking state machine:

digraph { newrank=true; // Sensible ranking of clusters bgcolor=transparent compound=true rankdir=LR node [fontname=Arial fontsize=10 shape=rect style=filled fillcolor="#8BC4E9"] graph [fontname=Arial fontsize=11] edge [fontname=Arial fontsize=10 arrowhead=vee] Fault [fillcolor="#F03232"] Disabled [fillcolor="#AAAAAA"] subgraph cluster_normal { subgraph cluster_abortable { Ready [fillcolor="#BBE7BB"] Ready -> Configuring [label="configure()" weight=30] Ready -> Saving [label="save()"] Saving -> Ready [weight=0] Ready -> Loading [label="put\ndesign"] Loading -> Ready [weight=0] Armed [fillcolor="#BBE7BB"] Configuring -> Armed Armed -> Running [label="run()"] Armed -> Seeking [label="put\nsteps"] Running -> PostRun Running -> Seeking [label="pause()"] PostRun -> Finished PostRun -> Armed PostRun -> Seeking [label="pause()"] Finished [fillcolor="#BBE7BB"] Finished -> Seeking [label="pause()"] Finished -> Configuring [label="configure()" weight=30] Seeking -> Armed Seeking -> Paused Paused -> Seeking [label="put\nsteps"] Paused -> Running [label="resume()"] } Aborted [fillcolor="#FFBE89"] Resetting -> Ready Seeking -> Aborting [ltail=cluster_abortable label="abort()"] Aborting -> Aborted Aborted -> Resetting [label="reset()"] Armed -> Resetting [label="reset()"] Finished -> Resetting [label="reset()"] } Aborted -> Disabling [ltail=cluster_normal label="disable()"] Aborted -> Fault [ltail=cluster_normal label="on_error"] Fault -> Resetting [label="reset()"] Fault -> Disabling [label="disable()"] Disabling -> Fault [label="on_error"] Disabling -> Disabled Disabled -> Resetting [label="reset()"] {rank=min Ready Resetting Fault} {rank=same Loading Saving Configuring} {rank=same Armed Seeking Finished Aborted Disabling} {rank=max Paused Running PostRun Aborting Disabled} label="Unlabelled transitions take place in response to internal actions\n Labelled transitions are triggered externally."; labelloc=bottom; }

The main thing to get from this diagram is that the Block passes through a number of states while it is being configure()d and run(), and these states control the Methods that can be run at any time.

Adding functionality to a RunnableController

Now let’s see some of the Methods and Attributes that are created in our example:

digraph detector_controllers_and_parts { newrank=true; // Sensible ranking of clusters bgcolor=transparent node [fontname=Arial fontsize=10 shape=rect style=filled fillcolor="#8BC4E9"] graph [fontname=Arial fontsize=11] edge [fontname=Arial fontsize=10 arrowhead=none] controller [label=<RunnableController<BR/>mri: 'DETECTOR'>] detpart [label=<FileWritePart<BR/>name: 'FW'>] dspart [label=<DatasetTablePart<BR/>name: 'DSET'>] subgraph cluster_control { label="Control" labelloc="b" controller -> detpart controller -> dspart } block [label=<Block<BR/>mri: 'DETECTOR'>] datasets [label=<Attribute<BR/>name: 'datasets'>] configure [label=<Method<BR/>name: 'configure'>] run [label=<Method<BR/>name: 'run'>] subgraph cluster_view { label="View" labelloc="b" block -> datasets block -> configure block -> run } {rank=same;controller block} {rank=same;dspart detpart datasets configure run} dspart -> datasets [style=dashed] controller -> configure [style=dashed] controller -> run [style=dashed] controller -> block [arrowhead=vee dir=from style=dashed label=produces] }

The RunnableController contributes the configure and run Methods in a similar way to previous examples, and the DatasetTablePart contributes a datasets Attribute, but the FileWritePart doesn’t contribute any Attributes or Methods to the Block. Instead, it registers functions with Hooks on the Controller to execute arbitrary logic at the correct stage of RunnableController.configure or RunnableController.run.

Hooking into configure()

We mentioned earlier that a Part can register hook functions. Each hook function is called at at a specific phase of a specific Method provided by the Controller. Lets take a look at the first part of ./malcolm/modules/demo/parts/filewritepart.py to see how this works:

import os
import time

import h5py
import numpy as np
from annotypes import Anno, add_call_types
from scanpointgenerator import Point

from malcolm.core import APartName, Part, PartRegistrar
from malcolm.modules import builtin, scanning

from ..util import interesting_pattern, make_gaussian_blob

with Anno("Width of detector image"):
    AWidth = int
with Anno("Height of detector image"):
    AHeight = int


# Datasets where we will write our data
DATA_PATH = "/entry/data"
SUM_PATH = "/entry/sum"
UID_PATH = "/entry/uid"
SET_PATH = "/entry/%s_set"

# How often we flush in seconds
FLUSH_PERIOD = 1


class FileWritePart(Part):
    """Minimal interface demonstrating a file writing detector part"""

    def __init__(self, name: APartName, width: AWidth, height: AHeight) -> None:
        super().__init__(name)
        # Store input arguments
        self._width = width
        self._height = height
        # The detector image we will modify for each image (0..255 range)
        self._blob = make_gaussian_blob(width, height) * 255
        # The hdf file we will write
        self._hdf: h5py.File = None
        # Configure args and progress info
        self._exposure = 0.0
        self._generator: scanning.hooks.AGenerator = None
        self._completed_steps = 0
        self._steps_to_do = 0
        # How much to offset uid value from generator point
        self._uid_offset = 0

    def setup(self, registrar: PartRegistrar) -> None:
        super().setup(registrar)
        # Hooks
        registrar.hook(scanning.hooks.ConfigureHook, self.on_configure)
        registrar.hook(
            (scanning.hooks.PostRunArmedHook, scanning.hooks.SeekHook), self.on_seek
        )
        registrar.hook(scanning.hooks.RunHook, self.on_run)
        registrar.hook(
            (scanning.hooks.AbortHook, builtin.hooks.ResetHook), self.on_reset
        )
        # Tell the controller to expose some extra configure parameters
        registrar.report(scanning.hooks.ConfigureHook.create_info(self.on_configure))

    # Allow CamelCase as these parameters will be serialized
    # noinspection PyPep8Naming
    @add_call_types
    def on_configure(
        self,
        completed_steps: scanning.hooks.ACompletedSteps,
        steps_to_do: scanning.hooks.AStepsToDo,
        generator: scanning.hooks.AGenerator,
        fileDir: scanning.hooks.AFileDir,
        exposure: scanning.hooks.AExposure = 0.0,
        formatName: scanning.hooks.AFormatName = "det",
        fileTemplate: scanning.hooks.AFileTemplate = "%s.h5",
    ) -> scanning.hooks.UInfos:
        """On `ConfigureHook` create HDF file with datasets"""
        # Store args
        self._completed_steps = completed_steps
        self._steps_to_do = steps_to_do
        self._generator = generator
        self._uid_offset = 0
        self._exposure = exposure
        # Work out where to write the file
        filename = fileTemplate % formatName
        filepath = os.path.join(fileDir, filename)
        # Make the HDF file
        self._hdf = self._create_hdf(filepath)
        # Tell everyone what we're going to make
        infos = list(self._create_infos(formatName, filename))
        return infos

Again we override __init__, but after initializing some protected variables we have some Hook statements in the setup() function. These call hook() to register a function to be run with one or more Hook classes. A Controller defines a a number of Hooks that define what methods of a Part will be run during a particular Method. For example, we are hooking our on_configure() method to the ConfigureHook.

Let’s take a look at its documentation:

class malcolm.modules.scanning.hooks.ConfigureHook(part: Anno(name='APart', typ=<class 'malcolm.core.part.Part'>, description='The part that has attached to the Hook'), context: Anno(name='AContext', typ=<class 'malcolm.core.context.Context'>, description='Context that should be used to perform operations on child blocks'), completed_steps: Anno(name='ACompletedSteps', typ=<class 'int'>, description='Number of steps already completed'), steps_to_do: Anno(name='AStepsToDo', typ=<class 'int'>, description='Number of steps we should configure for'), part_info: Anno(name='APartInfo', typ=(<class 'str'>, annotypes._array.Array[malcolm.core.info.Info]), description='The Infos returned from other Parts'), generator: Anno(name='AGenerator', typ=<class 'scanpointgenerator.core.compoundgenerator.CompoundGenerator'>, description='Generator instance providing specification for scan'), axesToMove: Anno(name='AAxesToMove', typ=<class 'str'>, description='List of axes in inner dimension of generator that should be moved'), breakpoints: Anno(name='ABreakpoints', typ=<class 'numpy.int32'>, description='List of points at which the run will return in Armed state'), **kwargs: Any)[source]

Called at configure() to configure child block for a run

Parameters
  • part (Part) – The part that has attached to the Hook

  • context (Context) – Context that should be used to perform operations on child blocks

  • completed_steps (int) – Number of steps already completed

  • steps_to_do (int) – Number of steps we should configure for

  • part_info – The Infos returned from other Parts

  • generator (CompoundGenerator) – Generator instance providing specification for scan

  • axesToMove (str) – List of axes in inner dimension of generator that should be moved

  • breakpoints (int32) – List of points at which the run will return in Armed state

What happens in practice is that when DETECTOR.configure() is called, all the functions hooked to ConfigureHook will be called concurrently. They will each be called with the arguments that they ask for (as long as its name appears in the documentation for the Hook).

See also

State Sets and Hooks contains more information about which Hooks are run in each state transition

Passing Infos back to the Controller

You may have noticed that on_configure() takes extra fileDir, formatName and fileTemplate arguments that are not documented in the Hook. How does the Controller know to ask for them at configure and pass them down to us? Well in setup() we report an Info. This is a way of telling our parent Controller something about us, either in response to a Hook, or asynchronously using PartRegistrar.report. In this case, we call ConfigureHook.create_info which scans our on_configure method for extra arguments and puts them in a ConfigureParamsInfo object that we can report() back. The docstring for this info explains what the Controller will do with this:

class malcolm.modules.scanning.infos.ConfigureParamsInfo(metas: Dict[str, malcolm.core.models.VMeta], required: List[str], defaults: Dict[str, Any])[source]

Info about the parameters that should be passed to the Part in configure. The Controller will validate these when Block.configure() is called, and pass them to all Parts that have registered interest in them.

Parameters
  • metas – Metas for the extra parameters

  • required – List of required parameters

  • defaults – Default values for parameters

After we have created our HDF file in configure, we make some more infos in _create_infos:

    def _create_infos(self, detector_name, filename):
        # Main dataset
        yield scanning.infos.DatasetProducedInfo(
            name=f"{detector_name}.data",
            filename=filename,
            type=scanning.util.DatasetType.PRIMARY,
            rank=len(self._generator.shape) + 2,
            path=DATA_PATH,
            uniqueid=UID_PATH,
        )
        # Sum
        yield scanning.infos.DatasetProducedInfo(
            name=f"{detector_name}.sum",
            filename=filename,
            type=scanning.util.DatasetType.SECONDARY,
            rank=len(self._generator.shape) + 2,
            path=SUM_PATH,
            uniqueid=UID_PATH,
        )
        # Add an axis for each setpoint
        for dim in self._generator.axes:
            yield scanning.infos.DatasetProducedInfo(
                name=f"{dim}.value_set",
                filename=filename,
                type=scanning.util.DatasetType.POSITION_SET,
                rank=1,
                path=SET_PATH % dim,
                uniqueid="",
            )

This time they describe the datasets that we are going to produce. These will be used by the DatasetTablePart to put them in an Attribute that can be read by the client expecting a scan. The docstring for this info explains this:

class malcolm.modules.scanning.infos.DatasetProducedInfo(name: str, filename: str, type: malcolm.modules.scanning.infos.DatasetType, rank: int, path: str, uniqueid: str)[source]

Declare that we will write the following dataset to file

Parameters
  • name – Dataset name

  • filename – Filename relative to the fileDir we were given

  • type – What NeXuS dataset type it produces

  • rank – The rank of the dataset including generator dims

  • path – The path of the dataset within the file

  • uniqueid – The path of the UniqueID dataset within the file

In our case we will produce a main dataset, with a detector of the dimensions given when we instantiated the part, a sum dataset which will provide a suitable single point for each detector frame to live visualize the scan, and the axis setpoints of everything that moves in the scan.

Hooking into run()

We also hooked our on_run() method in __init__. Let’s take a look at what it does:

    @add_call_types
    def on_run(self, context: scanning.hooks.AContext) -> None:
        """On `RunHook` record where to next take data"""
        # Start time so everything is relative
        end_of_exposure = time.time() + self._exposure
        last_flush = end_of_exposure
        assert self.registrar, "Part has no registrar"
        for i in range(
            self._completed_steps, self._completed_steps + self._steps_to_do
        ):
            # Get the point we are meant to be scanning
            point = self._generator.get_point(i)
            # Simulate waiting for an exposure and writing the data
            wait_time = end_of_exposure - time.time()
            context.sleep(wait_time)
            self.log.debug(f"Writing data for point {i}")
            self._write_data(point, i)
            # Flush the datasets if it is time to
            if time.time() - last_flush > FLUSH_PERIOD:
                last_flush = time.time()
                self._flush_datasets()
            # Schedule the end of the next exposure
            end_of_exposure += point.duration
            # Update the point as being complete
            self.registrar.report(scanning.infos.RunProgressInfo(i + 1))
        # Do one last flush and then we're done
        self._flush_datasets()

This is hooked to the RunHook. Let’s take a look at its documentation:

class malcolm.modules.scanning.hooks.RunHook(part: Anno(name='APart', typ=<class 'malcolm.core.part.Part'>, description='The part that has attached to the Hook'), context: Anno(name='AContext', typ=<class 'malcolm.core.context.Context'>, description='Context that should be used to perform operations on child blocks'), **kwargs: Any)[source]

Called at run() to start the configured steps running

Parameters
  • part (Part) – The part that has attached to the Hook

  • context (Context) – Context that should be used to perform operations on child blocks

Walking through the code we can see that we are iterating through each of the step indexes that we need to produce, getting a scanpointgenerator.Point object for each one. We then write some suitable data based on this point to our file. After this we work out how long we need to wait until the next position is to be produced, then do an interruptable sleep. It is important that we use the context parameter to do this, because if the Controller is aborted, it will tell every Context it passed out to hooked functions to abort. Finally we report() a RunProgressInfo with the current step number so the client knows how much of the scan is complete.

Important

Step numbers in Malcolm are 1-indexed, so a value of 0 means no steps completed.

The Controller will use all of the RunProgressInfo instances to work out how far the actual scan has progressed, and report it in the Block’s currentStep Attribute.

Hooking into seek(), abort() and reset()

The on_seek() Method stores completed_steps, and steps_to_do, then calculates a new UID offset so that any new frames are guaranteed to have an ID that has never been written before:

    @add_call_types
    def on_seek(
        self,
        completed_steps: scanning.hooks.ACompletedSteps,
        steps_to_do: scanning.hooks.AStepsToDo,
    ) -> None:
        """On `SeekHook`, `PostRunArmedHook` record where to next take data"""
        # Skip the uid so it is guaranteed to be unique
        self._uid_offset += self._completed_steps + self._steps_to_do - completed_steps
        self._completed_steps = completed_steps
        self._steps_to_do = steps_to_do

It is hooked into two Hooks:

class malcolm.modules.scanning.hooks.SeekHook(part: Anno(name='APart', typ=<class 'malcolm.core.part.Part'>, description='The part that has attached to the Hook'), context: Anno(name='AContext', typ=<class 'malcolm.core.context.Context'>, description='Context that should be used to perform operations on child blocks'), completed_steps: Anno(name='ACompletedSteps', typ=<class 'int'>, description='Number of steps already completed'), steps_to_do: Anno(name='AStepsToDo', typ=<class 'int'>, description='Number of steps we should configure for'), part_info: Anno(name='APartInfo', typ=(<class 'str'>, annotypes._array.Array[malcolm.core.info.Info]), description='The Infos returned from other Parts'), generator: Anno(name='AGenerator', typ=<class 'scanpointgenerator.core.compoundgenerator.CompoundGenerator'>, description='Generator instance providing specification for scan'), axesToMove: Anno(name='AAxesToMove', typ=<class 'str'>, description='List of axes in inner dimension of generator that should be moved'), **kwargs: Any)[source]

Called at seek() or at the end of pause() to reconfigure for a different number of completed_steps

Parameters
  • part (Part) – The part that has attached to the Hook

  • context (Context) – Context that should be used to perform operations on child blocks

  • completed_steps (int) – Number of steps already completed

  • steps_to_do (int) – Number of steps we should configure for

  • part_info – The Infos returned from other Parts

  • generator (CompoundGenerator) – Generator instance providing specification for scan

  • axesToMove (str) – List of axes in inner dimension of generator that should be moved

class malcolm.modules.scanning.hooks.PostRunArmedHook(part: Anno(name='APart', typ=<class 'malcolm.core.part.Part'>, description='The part that has attached to the Hook'), context: Anno(name='AContext', typ=<class 'malcolm.core.context.Context'>, description='Context that should be used to perform operations on child blocks'), completed_steps: Anno(name='ACompletedSteps', typ=<class 'int'>, description='Number of steps already completed'), steps_to_do: Anno(name='AStepsToDo', typ=<class 'int'>, description='Number of steps we should configure for'), part_info: Union[Anno(name='APartInfo', typ=(<class 'str'>, annotypes._array.Array[malcolm.core.info.Info]), description='The Infos returned from other Parts'), Mapping[str, Sequence[malcolm.core.info.Info]]], generator: Anno(name='AGenerator', typ=<class 'scanpointgenerator.core.compoundgenerator.CompoundGenerator'>, description='Generator instance providing specification for scan'), axesToMove: Anno(name='AAxesToMove', typ=<class 'str'>, description='List of axes in inner dimension of generator that should be moved'), **kwargs: Any)[source]

Called at the end of run() when there are more steps to be run

Parameters
  • part (Part) – The part that has attached to the Hook

  • context (Context) – Context that should be used to perform operations on child blocks

  • completed_steps (int) – Number of steps already completed

  • steps_to_do (int) – Number of steps we should configure for

  • part_info – The Infos returned from other Parts

  • generator (CompoundGenerator) – Generator instance providing specification for scan

  • axesToMove (str) – List of axes in inner dimension of generator that should be moved

Which means it will be called at when pause() is called, and when Malcolm has been asked to setup the next phase of a scan where it only moves a subset of the axes in the generator.

The on_reset() Method just closes the HDF file if it isn’t already closed:

    @add_call_types
    def on_reset(self) -> None:
        """On `AbortHook`, `ResetHook` close HDF file if it exists"""
        if self._hdf:
            self._hdf.close()
            self._hdf = None

It again is hooked into two Hooks:

class malcolm.modules.scanning.hooks.AbortHook(part: Anno(name='APart', typ=<class 'malcolm.core.part.Part'>, description='The part that has attached to the Hook'), context: Anno(name='AContext', typ=<class 'malcolm.core.context.Context'>, description='Context that should be used to perform operations on child blocks'), **kwargs: Any)[source]

Called at abort() to stop the current scan

Parameters
  • part (Part) – The part that has attached to the Hook

  • context (Context) – Context that should be used to perform operations on child blocks

class malcolm.modules.builtin.hooks.ResetHook(part: Anno(name='APart', typ=<class 'malcolm.core.part.Part'>, description='The part that has attached to the Hook'), context: Anno(name='AContext', typ=<class 'malcolm.core.context.Context'>, description='Context that should be used to perform operations on child blocks'), **kwargs: Any)[source]

Called at reset() to reset all parts to a known good state

Parameters
  • part (Part) – The part that has attached to the Hook

  • context (Context) – Context that should be used to perform operations on child blocks

These will be called when reset() or abort() is called on the Block.

Running the demo

Let’s run up the example and give it a go:

[me@mypc pymalcolm]$ pipenv run imalcolm malcolm/modules/demo/DEMO-DETECTOR.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:
    ['COUNTERX', 'COUNTERY', 'TICKER', 'WEB']

# To create a view of an existing Block
block = self.block_view("<mri>")

# To create a proxy of a Block in another Malcolm
self.make_proxy("<client_comms_mri>", "<mri>")
block = self.block_view("<mri>")

# To view state of Blocks in a GUI
!firefox localhost:8008

In [1]:

Then enter:

In [1]: from scanpointgenerator import LineGenerator, CompoundGenerator

In [2]: from scanpointgenerator.plotgenerator import plot_generator

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)

We can then see what this generator looks like:

In [6]: plot_generator(generator)
../_images/detector_0.png

What we have done here is set up a scan that is 6 rows in y and 5 columns in x. The x value will snake forwards and backwards, and the y value will increase at the end of each x row. We have told it that each scan point should last for 0.5 seconds, which should give us enough time to see the ticks. If this is the scan that we wanted to do then we can either configure from the terminal, or dump the JSON so we can use the GUI. Let’s do the latter:

In [7]: from annotypes import json_encode

In [8]: json_encode(generator)
Out[8]: '{"typeid": "scanpointgenerator:generator/CompoundGenerator:1.0", "generators": [{"typeid": "scanpointgenerator:generator/LineGenerator:1.0", "axes": ["y"], "units": ["mm"], "start": [0.0], "stop": [1.0], "size": 6, "alternate": false}, {"typeid": "scanpointgenerator:generator/LineGenerator:1.0", "axes": ["x"], "units": ["mm"], "start": [0.0], "stop": [1.0], "size": 5, "alternate": true}], "excluders": [], "mutators": [], "duration": 0.5, "continuous": true}'

Then we can open http://localhost:8008/gui/DETECTOR to see the DETECTOR Block on the left. If we expand the Configure method and click Edit by the Generator field we can paste in our JSON:

../_images/detector_1.png

If we set fileDir to “/tmp”, then click Configure, we will see the State change to Armed. We can then click on the “VIEW” button next to “Datasets” to see what the detector will write. Here we see that it will make a primary dataset in (containing detector data) /entry/data, and a secondary dataset (containing summary data calculated from the detector data) /entry/sum. It will also write position_set datasets for x and y containing the generator positions to /entry/x_set and /entry/y_set. All of these will be written to a file called det.h5 in the fileDir:

../_images/detector_2.png

If we click on the info icon next to the “Completed Steps”, and then select the Plot tab, then click the “Run” button on the left pane we will see the scan be performed:

../_images/detector_3.png

We can then view the data that the detector wrote from our iPython terminal. We read the HDF file we have produced, copy the dataset, close the file, and check the shape of the dataset to see that it does indeed have rank 4:

In [13]: import h5py

In [10]: with h5py.File("/tmp/det.h5") as f:
    ...:     im = f["/entry/sum"][:]
    ...:

In [11]: im.shape
Out[11]: (6, 5, 1, 1)

The sum dataset has the same rank as the detector data, so we need to squash those last two dimensions down so that we can plot it. We do that using the reshape function, then we show the resulting (6, 5) dataset:

In [12]: from pylab import imshow, show

In [13]: imshow(im.reshape(im.shape[:2]))
Out[13]: <matplotlib.image.AxesImage at 0x7f11a300b090>

In [14]: show()
../_images/detector_4.png

From here you can try modifying the generator to make a bigger, faster scan, pressing Configure then Run again.

Conclusion

This tutorial has given us an understanding of how scans are specified in Malcolm, the configure/run State Machine that Malcolm implements, and how Parts can register code to run at different phases of a Controller. In the next tutorial we will see how to create a Block in the Scan Layer to co-ordinate a detector and a motion controller.