Run UDFs on Merlin live streams

This example shows how to run LiberTEM user-defined functions (UDFs) on Merlin Medipix live data streams. It shows how to customize plotting, and how to integrate LiberTEM-live into your experimental setup.

  • Make sure to adjust the NAV_SHAPE below to match the scan of the data source!

  • This notebook requires the bqplot extra of LiberTEM: pip install libertem[bqplot]

Usage with the simulator

If you want to use this with the simulated data source, run a simple Merlin simulator in the background that replays an MIB dataset:

libertem-live-mib-sim ~/Data/default.hdr --cached=MEM --wait-trigger

The --wait-trigger option is important for this notebook to function correctly since that allows to drain the data socket before an acquisition like it is necessary for a real-world Merlin detector.

A suitable MIB dataset can be downloaded at https://zenodo.org/record/5113449.

On Linux, MEMFD is also supported as a cache. Use NONE to deactivate the cache.

[1]:
# Uncomment to use Matplotlib-based plots
# This requires ipympl and allows to capture Matplotlib plots as ipywidgets.
# %matplotlib widget
[2]:
# set this to the host/port where the merlin data server is listening:
MERLIN_DATA_SOCKET = ('127.0.0.1', 6342)
MERLIN_CONTROL_SOCKET = ('127.0.0.1', 6341)
NAV_SHAPE = (128, 128)
[3]:
import logging
import time
import concurrent.futures

import numpy as np
import ipywidgets
from contextlib import contextmanager
[4]:
logging.basicConfig(level=logging.INFO)
[5]:
# Sum all detector frames, result is a map of the detector
from libertem.udf.sum import SumUDF
# Sum up each detector frame, result is a bright field STEM image of the scan area
from libertem.udf.sumsigudf import SumSigUDF

# ImageGL-accelerated plot for fast live display
from libertem.viz.bqp import BQLive2DPlot
# Alternatively a version that uses the slower, but more mature Matplotlib
from libertem.viz.mpl import MPLLive2DPlot
INFO:empyre:Imported EMPyRe V-0.3.1 GIT-e85a58daa6bbd861c3aa1fe26e1d609f376f1adc
[6]:
from libertem_live.api import LiveContext, Hooks
from libertem_live.detectors.merlin import MerlinControl
from libertem_live.udf.monitor import SignalMonitorUDF
[7]:
ctx = LiveContext()
INFO:numba.cuda.cudadrv.driver:init

Camera setup routines

Different from offline processing, the shape, type and content of a dataset is not predetermined in live processing. Instead, the data source has to be configured to supply the desired data. The set_nav() function at the bottom accepts an acquisition object as a parameter to make it easier to configure a matching scan resolution.

[8]:
def merlin_setup(c: MerlinControl, dwell_time=1e-3, depth=6, save_path=None):
    print("Setting Merlin acquisition parameters")
    # Here go commands to control the camera and the rest of the setup
    # to perform an acquisition.

    # The Merlin simulator currently accepts all kinds of commands
    # and doesn't respond like a real Merlin detector.
    c.set('CONTINUOUSRW', 1)
    c.set('ACQUISITIONTIME' , dwell_time * 1e3)  # Time in miliseconds
    c.set('COUNTERDEPTH', depth)

    # Soft trigger for testing
    # For a real STEM acquisition the trigger setup has to be adapted for the given instrument.
    # See the MerlinEM User Manual for more details on trigger setup
    c.set('TRIGGERSTART', 5)

    c.set('RUNHEADLESS', 1)
    c.set('FILEFORMAT', 2)  # 0 binary, 2 raw binary

    if save_path is not None:
        c.set('IMAGESPERFILE', 256)
        c.set('FILEENABLE', 1)
        c.set('USETIMESTAMPING', 0)  # raw format with timestamping is buggy, we need to do it ourselves
        c.set('FILEFORMAT', 2)  # raw format, less overhead?
        c.set('FILEDIRECTORY', save_path)
    else:
        c.set('FILEENABLE', 0)

    print("Finished Merlin setup.")

def microscope_setup(dwell_time=1e-3):
    # Here go instructions to set dwell time and
    # other scan parameters
    # microscope.set_dwell_time(dwell_time)
    pass

def set_nav(c: MerlinControl, aq):
    height, width = aq.shape.nav
    print("Setting resolution...")
    c.set('NUMFRAMESTOACQUIRE', height * width)
    # Only one trigger for the whole scan with SOFTTRIGGER
    # This has to be adapted to the real trigger setup.
    # Set to `width` for line trigger and to `1` for pixel trigger.
    c.set('NUMFRAMESPERTRIGGER', height * width)

    # microscope.configure_scan(shape=aq.shape.nav)

Integration Hooks

A LiberTEM Live acquisition object can include a hooks object, so that LiberTEM Live can set off the acquisition as soon as it has connected to the camera and is ready to receive data. The on_ready_for_data function receives an environment as argument, from which you can access the current acquisition object as the attribute aq.

[10]:
class MerlinHooks(Hooks):
    def __init__(self):
        self.trigger_result = None
        self.pool = concurrent.futures.ThreadPoolExecutor(1)

    def on_ready_for_data(self, env):
        aq = env.aq

        print("Arming Merlin...")
        # c is a MerlinControl, will be created in the cell that runs the scan
        # below. This arms the detector and sends the acquisition headers.
        with c:
            c.cmd('STARTACQUISITION')
        # microscope.start_scanning()

        print("Merlin ready for trigger.")
        height, width = aq.shape.nav

        # Real-world example: Function call to trigger the scan engine
        # that triggers the detector with a hardware trigger to match the scan of the beam.
        # This function is blocking until the scan is complete.
        # do_scan = lambda: ceos.call.acquireScan(width=width, height=height+1, imageName="test")

        # Testing: Use soft trigger
        # The emulator can trigger on the 'SOFTTRIGGER' command like the Merlin detector.
        def do_scan():
            '''
            Emulated blocking scan function using the Merlin simulator.

            This function doesn't actually block, but it could!
            '''
            print("Triggering! (do_scan)")
            with c:
                c.cmd('SOFTTRIGGER')

            time.sleep(1)  # microscopes can block here
            return "stuff"  # this result can be queried, once the scan has finished (see last cell)

        # The real-world scan function might be blocking. We run it in a thread pool here
        # so that `trigger()` returns and the acquisition can start.
        fut = self.pool.submit(do_scan)
        self.trigger_result = fut
[11]:
conn = ctx.make_connection('merlin').open(
    data_host=MERLIN_DATA_SOCKET[0],
    data_port=MERLIN_DATA_SOCKET[1],
    api_host=MERLIN_CONTROL_SOCKET[0],
    api_port=MERLIN_CONTROL_SOCKET[1],
)
[12]:
hooks = MerlinHooks()
aq = ctx.make_acquisition(
    conn=conn,
    hooks=hooks,
    nav_shape=NAV_SHAPE,

    frames_per_partition=800,
)
[13]:
udfs = [SumUDF(), SumSigUDF(), SignalMonitorUDF()]
[14]:
LivePlot = BQLive2DPlot
# Uncomment below to use Matplotlib-based plotting
# See also the top of the notebook to select the correct matplotlib backend
# LivePlot = MPLLive2DPlot

p0 = LivePlot(aq, udfs[0])
p1 = LivePlot(aq, udfs[1])
p2 = LivePlot(aq, udfs[2])
[15]:
# NBVAL_IGNORE_OUTPUT
# (output is ignored in nbval run because it somehow doesn't play nice with bqplot)

outputs = []

for p in [p0, p1, p2]:
    # Capture the plots to display them in a grid later
    output = ipywidgets.Output()
    with output:
        p.display()
        # Some plot-specific tweaks for grid display
        if isinstance(p, BQLive2DPlot):
            p.figure.fig_margin={'top': 50, 'bottom': 0, 'left': 25, 'right': 25}
            p.figure.layout.width = '300px'
            p.figure.layout.height = '300px'
        elif isinstance(p, MPLLive2DPlot):
            p.fig.tight_layout()
            p.fig.set_size_inches((3, 3))
            p.fig.canvas.toolbar_position = 'bottom'
    outputs.append(output)
[16]:
# Show the plot grid
ipywidgets.HBox(outputs)

Sample output

The plots are not preserved when saving the notebook. They look like this:

sample plot

Run one scan

The live plots above are updated with the results

[17]:
c = MerlinControl(*MERLIN_CONTROL_SOCKET)

print("Connecting Merlin control...")
with c:
    merlin_setup(c)
    microscope_setup()

    set_nav(c, aq)
try:
    # This will call the trigger function defined above as soon as
    # LiberTEM-live is ready to receive data.
    ctx.run_udf(dataset=aq, udf=udfs, plots=[p0, p1, p2])
finally:
    try:
        if hooks.trigger_result is not None:
            print("Waiting for blocking scan function...")
            print(f"result = {hooks.trigger_result.result()}")
    finally:
        # Real world:
        # microscope.stop_scanning()
        pass
print("Finished.")
Connecting Merlin control...
Setting Merlin acquisition parameters
Finished Merlin setup.
Setting resolution...
Arming Merlin...
Merlin ready for trigger.
Triggering! (do_scan)
Waiting for blocking scan function...
result = stuff
Finished.
[ ]: