Device Backends

A series of short working examples here illustrate the use of labbench Device classes for experiment automation. The python programming interface is in the module of the same name, but it is convenient to import it as lb for shorthand.

import labbench as lb
lb.show_messages('debug')

Laboratory automation wrappers are implemented as classes derived from lb.Device. All of them share common basic types features designed to make their usage discoverable and convenient. The goal here is to show how to navigate these objects to get started quickly automating lab tasks.

Wrappers for specific instruments are not included with labbench, only low-level python plumbing and utility functions to streamline lab automation. Specific implementation is left for other libraries.

Overview

The Device class and subclasses represent in a sense only a definition with instructions for automating a specified type of lab tool. To bring these to life and control objects in the lab, the most general steps are to

  1. construct an object from the class,

  2. open a connection, and then

  3. use the object’s attributes to perform automation tasks as needed.

Let’s start with a simple automation demo for a simple 2 instrument experiment.

import labbench as lb
import numpy as np
from sim_visa import PowerSupply, SpectrumAnalyzer

# VISA Devices take a standard address string to create a resource
spectrum_analyzer = SpectrumAnalyzer('GPIB::15::INSTR')
supply = PowerSupply('USB::0x1111::0x2222::0x2468::INSTR')

# show SCPI traffic
lb.show_messages('debug')

# `with` blocks open the devices, then closes them afterward
with supply, spectrum_analyzer: 
    print(supply.backend, supply._rm, repr(supply.read_termination))
    supply.voltage = 5
    supply.output_enabled = True

    trace_dB = 10*np.log10(spectrum_analyzer.fetch_trace())
trace_dB.plot();
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
Cell In[2], line 3
      1 import labbench as lb
      2 import numpy as np
----> 3 from sim_visa import PowerSupply, SpectrumAnalyzer
      5 # VISA Devices take a standard address string to create a resource
      6 spectrum_analyzer = SpectrumAnalyzer('GPIB::15::INSTR')

ModuleNotFoundError: No module named 'sim_visa'

These instruments are emulated - under the hood they are pyvisa-sim instruments, configured in [sim_visa.yaml], which act as simple value stores for a few fake SCPI commands and sources of “canned” arrays of data. The demo labbench Device classes that control them are implemented in [sim_visa.py] (subclassed from lb.Device -> lb.VISADevice -> lb.SimulatedVISADevice).

Workflow

Constructing objects

These Device classes (like other VISA instruments) need a VISA address in order to point to a specific instrument. To discover information about this and other available initialization parameters, use python help() or the ‘?’ magic in ipython or jupyter:

SpectrumAnalyzer?

Other options are also available here, such as the transport settings read_termination and read_termination, or the number of traces to acquire in calls to fetch_trace.

These can also be set or changed after object construction by setting the value attributes, for example spectrum_analyzer.resource = 'GPIB::15::INSTR' or supply.resource = 'USB::0x1111::0x2222::0x2468::INSTR'. The complete list of these parameters is shown under “Value Attributes”, which also lists read-only values that can’t be changed and are not constructor arguments.

Opening device connections

In automation scripts, it is good practice to use a context block (that with statement) to open connections. This ensures all of the devices open and close together, even when exceptions are raised.

For interactive use on the python/ipython/jupyter prompt, this is less convenient. For this purpose, device objects also expose explicit open and close methods. As an example, a simple check for instrument response to automation could look like this,

>>> supply.open()
>>> print(supply.output_enabled)
False
>>> # (...look at the instrument to verify output is disabled)
>>> supply.output_enabled = True
>>> # (...verify instrument output is enabled)

This type of exploration is a good way to learn the capabilities of a device interactively.

Automating with open devices

Python’s introspection tools give more opportunities to discover the API exposed by a device object. This is important because the methods and other attributes vary from one type of Device class to another. The below uses dir to show the list of all public attributes (those that don’t start with '_').

# filter by name
attrs = [
    name
    for name in dir(SpectrumAnalyzer)
    if not name.startswith('_')
]

print(f'public attributes of SpectrumAnalyzer: {attrs}\n')

# discover the 'query' method common to VISA all devices
SpectrumAnalyzer.query?

Trait attributes that cast to python types with validation are definitions in classes, but become interactive values in device objects:

print(f'class: SpectrumAnalyzer.sweeps == {SpectrumAnalyzer.sweeps}')
print(f'object: spectrum_analyzer.sweeps == {signal_analyzer.sweeps}')
signal_analyzer.open
SpectrumAnalyzer.open

Generalizing from the example

Different subclasses expose different method functions and attribute variables to wrap the underling low-level API. Still, several characteristics are standardized:

  • connection management through with block or open/close methods

  • an isopen property to indicate connection status

  • resource is accepted by the constructor, and may be changed afterward as a class attribute

  • hooks are available for data loggers and UIs to observe automation calls

Device subclasses for different types of instruments and software differ in

  • the types of resource and configuration information

  • the specific resource of the class provided to control the device

This gets more complicated when handling multiple devices, because connection failures leave a combination of open and closed:

try:
    base.open()
    visa.open() # fails because its resource doesn't exist on the host
    
    # we don't get this far after visa.open() raises an exception
    print("doing useful automation here")
    visa.close()
    base.close()
except:    
    # we're left with a mixture of connection states
    assert base.isopen==True and visa.isopen==False

    # ...so we have to clean up the stray connection manually :(
    base.close()

Context management is easier and more clear. Everything inside the with block executes only if all devices open successfully, and ensures cleanup so that all devices are closed afterward.

try:
    with base, visa: # does both base.open() and visa.open()
        print('we never get in here, because visa.open() fails!')
except:
    # context management ensured a base.close() after visa.open() failed, 
    assert base.isopen==False and visa.isopen==False

data logging, type checking,and numerical bounds validation.

These features are common to all Device classes (and derived classes). To get started, provide by minimum working examples. Examples will use we’ll look into the more specialized capabilities provided by other Device subclasses included labbench for often-used backend APIs like serial and VISA.

Example

Here are very fake functions that just use time.sleep to block. They simulate longer instrument calls (such as triggering or acquisition) that take some time to complete.

Notice that do_something_3 takes 3 arguments (and returns them), and that do_something_4 raises an exception.

import labbench as lb

Here is the simplest example, where we call functions do_something_1 and do_something_2 that take no arguments and raise no exceptions:

from labbench import concurrently

results = concurrently(do_something_1, do_something_2)
results
results
do_something_1.__name__

We can also pass functions by wrapping the functions in Call(), which is a class designed for this purpose:

from labbench import concurrently, Call

results = concurrently(do_something_1, Call(do_something_3, 1,2,c=3))
results

More than one of the functions running concurrently may raise exceptions. Tracebacks print to the screen, and by default ConcurrentException is also raised:

from labbench import concurrently, Call

results = concurrently(do_something_4, do_something_5)
results

the catch flag changes concurrent exception handling behavior to return values of functions that did not raise exceptions (instead of raising ConcurrentException). The return dictionary only includes keys for functions that did not raise exceptions.

from labbench import concurrently, Call

results = concurrently(do_something_4, do_something_1, catch=True)
results