Lecture 6: Python for Instrument Control (1.5 Hours)

ABSTRACT

In this lecture we will see how we can use Python to make communicating with scientific instruments easier. We will:

  • learn about unit support for calculations in Python via two packages (Quantities and Pint),
  • go deeper into classes in Python and how they can be useful for designing interfaces to communicate to instruments,
  • design a driver for a demo instrument provided in the git repo for this workshop.

Unitful Computing with python-quantities and Pint (15 Minutes)

Before getting to actually controlling instruments, we'll briefly mention how to use Python to perform numerical computations in a unitful way. We'll demonstrate using python-quantities, which provides a quantity data type to represent arrays with associated units (and is how units are handled in IntrumentKit).

For now, let's go on and import python-quantities.

In [1]:
import numpy as np
import quantities as pq

We can now create quantities by specifying both a magnitude and a unit string.

In [2]:
velocity = pq.Quantity(2, 'm / s')
velocity
Out[2]:
array(2) * m/s

Units can also be applied by arithmetically multiplying them with an array from numpy or just a python list. The following two ways of adding units are equivalent:

In [3]:
vader = np.array([1,2,3]) * pq.N
luke = [1,2,3] * pq.N
print(vader) 
print(luke)
[ 1.  2.  3.] N
[ 1.  2.  3.] N

Arithmetic operations on quantities respect units.

In [4]:
acceleration = velocity / pq.second
acceleration
Out[4]:
array(2.0) * m/s**2

Importantly, operations on quantities must be correct for dimensions, but may use different units.

In [5]:
pq.Quantity(1, 'meter') + pq.Quantity(1, 'foot')
Out[5]:
array(1.3048) * m

Now, you may ask what happens when we use dimensionally incorrect units. Look at the below raised error:

In [6]:
pq.Quantity(1, 'meter') + pq.Quantity(1, 'second')
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
/home/skaiser/anaconda3/lib/python3.5/site-packages/quantities/quantity.py in rescale(self, units)
    191         try:
--> 192             cf = get_conversion_factor(from_u, to_u)
    193         except AssertionError:

/home/skaiser/anaconda3/lib/python3.5/site-packages/quantities/quantity.py in get_conversion_factor(from_u, to_u)
     52     to_u = to_u._reference
---> 53     assert from_u.dimensionality == to_u.dimensionality
     54     return from_u.magnitude / to_u.magnitude

AssertionError: 

During handling of the above exception, another exception occurred:

ValueError                                Traceback (most recent call last)
<ipython-input-6-3c7bda4e7ce9> in <module>()
----> 1 pq.Quantity(1, 'meter') + pq.Quantity(1, 'second')

/home/skaiser/anaconda3/lib/python3.5/site-packages/quantities/quantity.py in g(self, other, *args)
     61             other = other.view(type=Quantity)
     62         if other._dimensionality != self._dimensionality:
---> 63             other = other.rescale(self.units)
     64         return f(self, other, *args)
     65     return g

/home/skaiser/anaconda3/lib/python3.5/site-packages/quantities/quantity.py in rescale(self, units)
    194             raise ValueError(
    195                 'Unable to convert between units of "%s" and "%s"'
--> 196                 %(from_u._dimensionality, to_u._dimensionality)
    197             )
    198         return Quantity(cf*self.magnitude, to_u)

ValueError: Unable to convert between units of "s" and "m"

This looks quite long, but there is basically two main places to look for what the error was: at the top ValueError and the bottom: ValueError: Unable to convert between units of "s" and "m". The text in the middle is just tracking where the functions were called from and is often not needed to troubleshoot the problem. We can use python statements try: and except to help us make errors more readable.

In [7]:
try:
    pq.Quantity(1, 'meter') + pq.Quantity(1, 'second')
except ValueError as ex:
    print(ex)
Unable to convert between units of "s" and "m"

Quantities also support transforming between different unit systems. You can do this in one of two ways, you can directly set the attribute .units of the object or use the rescale method.

*NB: if you directly set the attribute by specifying a named module variable (pq.hour) you must use the singular version of the name of the unit (not pq.hours). If you are specifying a unit as a string

In [8]:
love = pq.Quantity(525600.0, 'minutes')
love.units = pq.hour
print(love)
love.rescale('days')
8760.0 h
Out[8]:
array(365.0) * d

Such conversions are also checked for consistent dimensions.

In [9]:
try:
    acceleration.rescale('kg')
except ValueError as ex:
    print(ex)
Unable to convert between units of "m/s**2" and "kg"

Finally, python-quantities also provides a range of useful physical constants that we can use to quickly construct quantities.

In [10]:
Sz = pq.constants.hbar * np.array([[1, 0], [0, -1]]) / 2
H = pq.constants.gamma_p * pq.Quantity(7, 'tesla') * Sz
print(H)
[[ 3.5  0. ]
 [ 0.  -3.5]] gamma_p*(h/(2*pi))*T

To make it easier to compare values, you can use the simplified attribute to convert all the units to a simplified form, and can use the rescale() method to specify particular units.

In [11]:
print(H.simplified)
(H.simplified / (2 * np.pi * pq.constants.hbar)).simplified.rescale('MHz')
[[  9.87424663e-26   0.00000000e+00]
 [  0.00000000e+00  -9.87424663e-26]] kg*m**2/s**2
Out[11]:
array([[ 149.02118732,    0.        ],
       [   0.        , -149.02118732]]) * MHz

Pint Units Package

As we might expect, there is more than one option for a package in Python that can help us handle units. Pint is chronologically a newer package, but with a bit more features than quantities.

First, let's check to see if the package is installed. Try running:

pip show pint

in your shell to see if pip knows about the package. If it returns nothing, then you do not have it installed and you should run:

pip install pint

If if does print out some stuff to the screen, then it will show you where it has the package installed. Let's look at some examples of using the Pint package.

Pints has this concept of a unit registry, that is a list of all the supported units and their relationship.

In [12]:
import pint 
ureg = pint.UnitRegistry()

To use these units you simply multiply the magnitude of the value by an instance of a unit object defined by the unit registry. The variable time then becomes an instance of a quantity object:

In [13]:
time = 8.0 * ureg.second
print(time)
type(time)
8.0 second
Out[13]:
pint.unit.build_quantity_class.<locals>.Quantity

You can also provide the units as a string that Pint will parse:

In [14]:
time = 8.0 * ureg('seconds')

Being an instance of a quantity type means that we can access attributes of time:

In [15]:
print(time.magnitude)
print(time.units)
print(time.dimensionality)
8.0
second
[time]

We can look at how these Quantity objects are represented internally:

In [16]:
print(repr(time))
<Quantity(8.0, 'second')>

This then allows us to be more direct about creating an instance of a Quantity:

In [17]:
distance = ureg.Quantity(3, 'feet')
print(distance)
3 foot

We can perform arithmetic operations with the Quantity objects with no special syntax:

In [18]:
speed = np.absolute( distance / time )
print(speed)
0.375 foot / second

We can easily convert quantities with the .to method, which generates a new object with the new units. Else to change the units associated with the object speed we can use the .ito method.

In [19]:
print(speed.to(ureg.km / ureg.hour))
print(speed)
speed.ito(ureg.km / ureg.hour)
print(speed)
0.41147999999999996 kilometer / hour
0.375 foot / second
0.41147999999999996 kilometer / hour

Similar to quantities, Pint will warn when a particular unit conversion is not possible.

In [20]:
try:
    speed.to(ureg.N)
except pint.errors.DimensionalityError as ex:
    print(ex)
Cannot convert from 'kilometer / hour' ([length] / [time]) to 'newton' ([length] * [mass] / [time] ** 2)

Lastly, if we want to look up what units are defined in the current registry, we can look at the different unit systems the registry knows about. By default calling ureg = UnitRegistry() makes units from all systems available.

In [21]:
dir(ureg.sys)
Out[21]:
['US', 'cgs', 'imperial', 'mks']

To see what units are defined in a specific unit system just look at the attributes of the system:

In [22]:
dir(ureg.sys.imperial)
Out[22]:
['UK_hundredweight',
 'UK_ton',
 'acre_foot',
 'cubic_foot',
 'cubic_inch',
 'cubic_yard',
 'drachm',
 'foot',
 'grain',
 'imperial_barrel',
 'imperial_bushel',
 'imperial_cup',
 'imperial_fluid_drachm',
 'imperial_fluid_ounce',
 'imperial_gallon',
 'imperial_gill',
 'imperial_peck',
 'imperial_pint',
 'imperial_quart',
 'inch',
 'long_hunderweight',
 'long_ton',
 'mile',
 'ounce',
 'pound',
 'quarter',
 'short_hunderdweight',
 'short_ton',
 'square_foot',
 'square_inch',
 'square_mile',
 'square_yard',
 'stone',
 'yard']

More on Class Inheritance (25 Minutes)

Recall that in Python all types are functions which return values of that type. Classes in Python are a way of defining new types. Let's look a little more in-depth at classes and how they can be useful to us for communicating with instruments.

We will use this ExampleClass to explore our understanding of classes. It inherits the attributes of the object class which is the most basic class one can inherit from. Unlike the previous examples we are specifying an __init__ definition. This function is called when an instance of the class is created and allows us to specify input arguments.

In [23]:
class ExampleClass(object):
    '''
    An example of a basic class with attributes and methods.
    '''
    
    def __init__(self, name, value):
        '''
        Initialize the class, set the name, and value for the exponent method.
        '''
        self.name = name
        self.value = value
    
    tally = 0
    
    def test_exponent(self, x):
        self.tally = self.tally + 1
        return x ** self.value
    
    def say_name(self):
        print('My name is {name}.'.format(name = self.name))

You might notice the text enclosed with triple quotes (""" or '''). Triple quotes allow for easily defining a string that includes line breaks, but are otherwise identical to single quotes (' or "). In any case, if the first line of a class or function is a string, then this is called a docstring and is basically documentation attached to that class or function. If you try running the below line, you can see that this text is then displayed in the pop-up.

In [24]:
ExampleClass?

We can then create an instance of this class by choosing a name and an exponent value. We can also check on the value of the attribute tally:

In [25]:
inst = ExampleClass('Bob', 3)
inst.say_name()
print(inst.test_exponent(3))
print(inst.tally)
My name is Bob.
27
1

We can check to see if the attribute tally is keeping track of how many times we have called the test_exponent method.

In [26]:
print(inst.test_exponent(4))
print(inst.tally)
64
2

If we want to look to see what attributes that ExampleClass is inheriting from object we can use the dir function to list attributes:

In [27]:
dir(object)
Out[27]:
['__class__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__le__',
 '__lt__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__']

Inheriting these attributes means that they are also valid attributes of ExampleClass. For example, the __doc__ attribute is used by the help() function and by IPython's and Jupyter's ? commands to provide help. In our case, __doc__ is defined for an instance of our example class cls and returns the docstring we set.

In [28]:
inst.__sizeof__()
Out[28]:
32

We can also make a "subclass" of ExampleClass that overrides one of the methods that we defined in ExampleClass:

In [29]:
class MoreSpecificClass(ExampleClass):
    def say_name(self):
        print('My name is {name}. I raise things to the power of {power}.'.format(name=self.name, power=self.value))
In [30]:
better_inst = MoreSpecificClass("Alice", 2)
better_inst.say_name()
print(better_inst.test_exponent(5))
print(better_inst.tally)
My name is Alice. I raise things to the power of 2.
25
1

Having this inheriting structure for classes makes it easy to share definitions for methods, which can reduce copy/pasting of code and make things more readable. Critically, class inheritance also allows for redefining behavior without having to change how a class is used. In the case of instruments, a base class might represent a particular kind of instrument, such that code using subclasses of that class does not need to know which subclass it is using. We can write code for an entire set of different instruments by abstracting away the interface that our code uses to talk to each instrument. This interface is sometimes known as an application-programming interface, or API, as was made famous recently.

Python lets us represent this process of abstraction with abstract classes (sometimes called abstract base classes, in an effort to arrive at ABCs as an initialism). Unlike the classes we've seen so far, an abstract class is incomplete in the sense that you can't actually make new instances, but instead must make concrete subclasses which fill in the missing parts. Thus, an abstract class lets us write the demand that subclasses fill in and completely specify the behavior for a given interface before they can be considered concrete.

Let's look at a quick example. In Python, support for ABCs is provided by the abc package. A class that can be made abstract must have abc.ABCMeta as its metaclass. While metaclasses are fascinating and wonderfully useful, they are also beyond the scope of this workshop, so for now, we'll note that a metaclass lets us add new concepts to the idea of a class itself, rather than to instances of it. Thus, ABCMeta adds the concept of abstraction to classes. We use the future package to specify metaclasses, since the syntax for this is different in Python 2 or 3. With that aside, then, our wonderfully compelling and not at all contrived example follows.

In [31]:
import abc
from future.utils import with_metaclass
In [32]:
class AbstractFoo(with_metaclass(abc.ABCMeta, object)):
    @abc.abstractmethod
    def bar(self):
        """
        bar should really do something.
        """
        pass

We'll get back to what @abc.abstractmethod is doing in a moment, but for now let's take it for granted that it marks a method as being abstract. Because AbstractFoo has at least one abstract method, it is an abstract class, and we cannot make new instances yet.

In [33]:
try:
    foo = AbstractFoo()
except TypeError as ex:
    print(ex)
Can't instantiate abstract class AbstractFoo with abstract methods bar

If we give AbstractFoo a specification of bar() by using inheritance, then we're in the clear.

In [34]:
class Foo(AbstractFoo):
    def bar(self):
        print("<3")
In [35]:
foo = Foo()
foo.bar()
<3

Below, we'll explore this idea further now by looking at some abstract classes that could describe kinds of instruments we might want to control with Python.

A Brief Aside About Decorators

NB: This part's a bit more advanced. Please don't worry if it doesn't all make sense the first time through, or even the second. It's not that hard in some ways, but it's also a bit different and that takes a lot of getting used to. If you are interested this is a good tutorial

First, though, what was abc.abstractmethod doing above? Notice that the line started with an at-character (@). In Python, this denotes that a function is being used as a decorator, a fancy name for a function that takes a function and returns a function. Let's look at a very simple case, in which the decorator does nothing (is the identity decorator), but has a side effect of printing something.

In [36]:
def loud_identity(fn):
    print("Defining {}...".format(fn))
    return fn

@loud_identity
def bar(x):
    return x ** 2

print(bar(2))
print(bar(3))
Defining <function bar at 0x7f807749f268>...
4
9

We can make more complicated decorators, too. For instance, we could make a decorator to represent applying a function twice, similar to what we did in Lecture 2 to explore that functions are values.

In [37]:
from functools import wraps

def apply_twice(fn):
    @wraps(fn)
    def new_fn(x):
        return fn(fn(x))
    return new_fn

@apply_twice
def bar(x):
    return x * 2 + "!"
In [38]:
bar("ab")
Out[38]:
'abab!abab!!'

Here, the wraps decorator is provided by the Python standard library to copy docstrings, function names and other metadata to a new function; it's a decorator that helps us to write decorators.

NB: Note that wraps takes an argument. This is an example of what's called currying, in that wraps takes a function and returns a decorator. That is, a function from functions to functions from functions to functions.

Back to abc.abstractmethod, then, what does it do?

In [39]:
AbstractFoo.bar.__isabstractmethod__
Out[39]:
True
In [40]:
AbstractFoo.__abstractmethods__
Out[40]:
frozenset({'bar'})

That's the crux of it, really. The abstractmethod decorator sets a new attribute on the method (bar.__isabstractmethod__) and adds the method to a special attribute on the class (AbstractFoo.__abstractmethods__). Python then uses these internally (hence the double-underscore names) to track that bar is abstract. In fact, if we take the wholly inadvisable step of modifying __abstractmethods__, we can even trick Python into letting us make instances of abstract classes!

NB: Don't do this. Seriously.

In [41]:
AbstractFoo.__abstractmethods__ = {}
AbstractFoo()
Out[41]:
<__main__.AbstractFoo at 0x7f80774a07b8>

Let's build up an Instrument and talk to it! (50 Minutes)

To actually put what we have learned now about abstract classes to use, we will use the InstrumentKit Python package to write a class that can talk to a fake instrument. This instrument is also written in Python with a package that allows you to make GUIs (PyQt4). It will not matter though that it is also written in Python as we will only interface with it via text strings, which is the basis for many instrument communication protocols.

To install our "instrument" simply, open a shell and navigate to where you checked out the git repo for this workshop. Once there navigate to the epqis16-demos folder. In this folder, we have a package that you can install to python by running the following command:

$ python setup.py install

Now to actually run the instrument simply run this command in the shell you have open:

$ epqis16 demo_instrument

The shell should now show Waiting for a connection from InstrumentKit on port 8042.. We will leave it like this for now.

The problem

Consider the following situation: your experiment needs constant babysitting to set values and make sure that it functions correctly. You decide that to make your life simpler you will write a program that can set all the measurements needed and save the output data. The device you need to control is a four channel DC power supply. Opening the manual you find the following table for what commands you can send to the device:

SPCI command Example usage Options Description
*IDN? Query return:
EPQIS16 Demonstration Instrument
Find out what the name of the instrument is.
CH<ch> VOLTS? CH1 VOLTS?
CH2 VOLTS?
Query return:
{value in mV}
<ch> = {1, 2, 3, 4} Check what the output voltage is set to (returns mV).
CH<ch> VOLTS <val> CH1 VOLTS 314
CH3 VOLTS 159
<ch> = {1, 2, 3, 4}
<val> = voltage value in mV
Set the voltage output (provided value is mV).
CH<ch> ENABLE <state> CH2 ENABLE OFF
CH4 ENABLE ON
<ch> = {1, 2, 3, 4}
<state> = {ON, OFF}
Default: OFF
Toggle the voltage channel output on/off.
CH<ch> ENABLE? CH1 ENABLE?
CH2 ENABLE?
Query return:
{ON, OFF}
<ch> = {1, 2, 3, 4} Check the output status of a voltage channel.

We can do some initial tests to see if we can send or receive commands from the instrument. Since we saw (intentionally erroneous as you will find in real life) that the manual specified the communication protocol as SPCI. Assuming that is was true, we could consider treating our instrument as a generic SCPI instrument from the InstrumentKit package. Let's start by importing as ik:

In [42]:
import instruments as ik

Now, since we read elsewhere in the manual for this device that to connect to it we use a tcpip protocol and connect to port 8042. All devices will have some section describing how to do this initial connection, but because we are using a demo "instrument" anyway, this is how we have chosen to connect.

The idea now is that we could use the communication protocols that IK has built in to see if we can find and connect to the device. We start by creating an instance of a generic instrument class that only knows how to communicate via SCPI.

In [45]:
ins = ik.generic_scpi.SCPIInstrument.open_tcpip("localhost", 8042)
type(ins)
Out[45]:
instruments.generic_scpi.scpi_instrument.SCPIInstrument

NB: The open_tcpip method above works on classes, not instances of classes, and is hence known as a class method. This is useful to us here, as we don't yet have an instance of a class for open_tcpip to act on. Rather, it provides a way of creating class instances in the first place.

Having done this, a little pop-up window should have appeared on your screen. This is the front panel of our demo device, a 4 channel DC power supply! Now, let's see if we can send/receive sensible things to/from the device using the query and sendcmd methods of the SCPIInstrument class. NB: you could get similar sendcmd/query commands from other python serial communication packages, but we will look at those in IK since we will also use IK for other things.

In [46]:
ins.query("*IDN?")
Out[46]:
'EPQIS16 Demonstration Instrument'
In [47]:
ins.query("CH2 VOLTS?")
Out[47]:
'0.0'
In [48]:
ins.sendcmd("CH2 VOLTS 42")
ins.query("CH2 VOLTS?")
Out[48]:
'42.0'
In [49]:
ins.query("CH2 ENABLE?")
Out[49]:
'OFF'
In [50]:
ins.sendcmd("CH1 ENABLE ON")

This is cool! Go ahead and try turning the channels on and off in the GUI and see if that is correctly reported by the instrument. At this point, we have a decision point. We could write all of our code by just constructing the strings that are the commands we want to send to the device. Or we could try to something more generalized; in that we abstract the communication details specific to this device to a separate file and just have a generalized interface for other code to use the device. The advantages to the latter approach mean that if for some reason you need to switch to another device, you can just swap out the specific driver file, or perhaps you need to communicate to another similar instrument and don't want to risk having copies of the same code floating around.

In principal the startup costs for both of these approaches are the same, in that you have to setup code to do the "translation" to the instrument. However, as we suggest here there is a lot to be gained by using a package such as IK (others are listed at the end of the lecture) to set up abstract instrument classes as well as classes that handle many types of common communication interfaces (GPIB, USB, VISA, Serial, VXI11, etc). All of these templates set up in these packages you could also write yourself, but the question is: "wouldn't you rather be doing your experiment and not coding?" Suffice it to say we will now look at how we could use abstract classes from IK to write a driver for our demo instrument.

The Solution

Now as we have seen with class inheritance, IntrumentKit has a whole host of what are called abstract instruments that set up templates for what attributes and methods would be good to have in a class for a particular kind of instrument. We can take a look at what "templates" InstrumentKit has setup by using the help function:

In [51]:
help(ik.abstract_instruments)
Help on package instruments.abstract_instruments in instruments:

NAME
    instruments.abstract_instruments - Module containing instrument abstract base classes and communication layers

PACKAGE CONTENTS
    comm (package)
    electrometer
    function_generator
    instrument
    multimeter
    oscilloscope
    power_supply
    signal_generator (package)

DATA
    absolute_import = _Feature((2, 5, 0, 'alpha', 1), (3, 0, 0, 'alpha', 0...

FILE
    /home/skaiser/anaconda3/lib/python3.5/site-packages/instruments/abstract_instruments/__init__.py


We can see that there is an abstract instrument called power_supply, so since we are trying to write a driver for one let's look at that in more detail:

In [52]:
help(ik.abstract_instruments.PowerSupply)
Help on class PowerSupply in module instruments.abstract_instruments.power_supply:

class PowerSupply(instruments.abstract_instruments.instrument.Instrument)
 |  Abstract base class for power supply instruments.
 |  
 |  All applicable concrete instruments should inherit from this ABC to
 |  provide a consistent interface to the user.
 |  
 |  Method resolution order:
 |      PowerSupply
 |      instruments.abstract_instruments.instrument.Instrument
 |      builtins.object
 |  
 |  Data descriptors defined here:
 |  
 |  channel
 |      Gets a channel object for the power supply. This should use
 |      `~instruments.util_fns.ProxyList` to achieve this.
 |      
 |      This is an abstract method.
 |      
 |      :rtype: `PowerSupplyChannel`
 |  
 |  current
 |      Gets/sets the output current for all channel on the power supply.
 |      This is an abstract method.
 |      
 |      :type: `~quantities.quantity.Quantity`
 |  
 |  voltage
 |      Gets/sets the output voltage for all channel on the power supply.
 |      This is an abstract method.
 |      
 |      :type: `~quantities.quantity.Quantity`
 |  
 |  ----------------------------------------------------------------------
 |  Data and other attributes defined here:
 |  
 |  __abstractmethods__ = frozenset({'channel', 'current', 'voltage'})
 |  
 |  ----------------------------------------------------------------------
 |  Methods inherited from instruments.abstract_instruments.instrument.Instrument:
 |  
 |  __init__(self, filelike)
 |      Initialize self.  See help(type(self)) for accurate signature.
 |  
 |  binblockread(self, data_width, fmt=None)
 |      "
 |      Read a binary data block from attached instrument.
 |      This requires that the instrument respond in a particular manner
 |      as EOL terminators naturally can not be used in binary transfers.
 |      
 |      The format is as follows:
 |      #{number of following digits:1-9}{num of bytes to be read}{data bytes}
 |      
 |      :param int data_width: Specify the number of bytes wide each data
 |          point is. One of [1,2,4].
 |      
 |      :param str fmt: Format string as specified by the :mod:`struct` module,
 |          or `None` to choose a format automatically based on the data
 |          width. Typically you can just specify `data_width` and leave this
 |          default.
 |  
 |  query(self, cmd, size=-1)
 |      Executes the given query.
 |      
 |      :param str cmd: String containing the query to
 |          execute.
 |      :param int size: Number of bytes to be read. Default is read until
 |          termination character is found.
 |      :return: The result of the query as returned by the
 |          connected instrument.
 |      :rtype: `str`
 |  
 |  read(self, size=-1)
 |      Read the last line.
 |      
 |      :param int size: Number of bytes to be read. Default is read until
 |          termination character is found.
 |      :return: The result of the read as returned by the
 |          connected instrument.
 |      :rtype: `str`
 |  
 |  sendcmd(self, cmd)
 |      Sends a command without waiting for a response.
 |      
 |      :param str cmd: String containing the command to
 |          be sent.
 |  
 |  write(self, msg)
 |      Write data string to the connected instrument. This will call
 |      the write method for the attached filelike object. This will typically
 |      bypass attaching any termination characters or other communication
 |      channel related work.
 |      
 |      .. seealso:: `Instrument.sendcmd` if you wish to send a string to the
 |      instrument, while still having InstrumentKit handle termination
 |      characters and other communication channel related work.
 |      
 |      :param str msg: String that will be written to the filelike object
 |          (`Instrument._file`) attached to this instrument.
 |  
 |  ----------------------------------------------------------------------
 |  Class methods inherited from instruments.abstract_instruments.instrument.Instrument:
 |  
 |  open_file(filename) from abc.ABCMeta
 |      Given a file, treats that file as a character device file that can
 |      be read from and written to in order to communicate with the
 |      instrument. This may be the case, for instance, if the instrument
 |      is connected by the Linux ``usbtmc`` kernel driver.
 |      
 |      :param str filename: Name of the character device to open.
 |      
 |      :rtype: `Instrument`
 |      :return: Object representing the connected instrument.
 |  
 |  open_from_uri(uri) from abc.ABCMeta
 |      Given an instrument URI, opens the instrument named by that URI.
 |      Instrument URIs are formatted with a scheme, such as ``serial://``,
 |      followed by a location that is interpreted differently for each
 |      scheme. The following examples URIs demonstrate the currently supported
 |      schemes and location formats::
 |      
 |          serial://COM3
 |          serial:///dev/ttyACM0
 |          tcpip://192.168.0.10:4100
 |          gpib+usb://COM3/15
 |          gpib+serial://COM3/15
 |          gpib+serial:///dev/ttyACM0/15 # Currently non-functional.
 |          visa://USB::0x0699::0x0401::C0000001::0::INSTR
 |          usbtmc://USB::0x0699::0x0401::C0000001::0::INSTR
 |      
 |      For the ``serial`` URI scheme, baud rates may be explicitly specified
 |      using the query parameter ``baud=``, as in the example
 |      ``serial://COM9?baud=115200``. If not specified, the baud rate
 |      is assumed to be 115200.
 |      
 |      :param str uri: URI for the instrument to be loaded.
 |      :rtype: `Instrument`
 |      
 |      .. seealso::
 |          `PySerial`_ documentation for serial port URI format
 |      
 |      .. _PySerial: http://pyserial.sourceforge.net/
 |  
 |  open_gpibethernet(host, port, gpib_address) from abc.ABCMeta
 |      .. warning:: The GPIB-Ethernet adapter that this connection would
 |          use does not actually exist, and thus this class method should
 |          not be used.
 |  
 |  open_gpibusb(port, gpib_address, timeout=3, write_timeout=3) from abc.ABCMeta
 |      Opens an instrument, connecting via a
 |      `Galvant Industries GPIB-USB adapter`_.
 |      
 |      :param str port: Name of the the port or device file to open a
 |          connection on. Note that because the GI GPIB-USB
 |          adapter identifies as a serial port to the operating system, this
 |          should be the name of a serial port.
 |      :param int gpib_address: Address on the connected GPIB bus assigned to
 |          the instrument.
 |      :param float timeout: Number of seconds to wait when reading from the
 |          instrument before timing out.
 |      :param float write_timeout: Number of seconds to wait when writing to the
 |          instrument before timing out.
 |      
 |      :rtype: `Instrument`
 |      :return: Object representing the connected instrument.
 |      
 |      .. seealso::
 |          `~serial.Serial` for description of `port` and timeouts.
 |      
 |      .. _Galvant Industries GPIB-USB adapter: galvant.ca/#!/store/gpibusb
 |  
 |  open_serial(port=None, baud=9600, vid=None, pid=None, serial_number=None, timeout=3, write_timeout=3) from abc.ABCMeta
 |      Opens an instrument, connecting via a physical or emulated serial port.
 |      Note that many instruments which connect via USB are exposed to the
 |      operating system as serial ports, so this method will very commonly
 |      be used for connecting instruments via USB.
 |      
 |      This method can be called by either supplying a port as a string,
 |      or by specifying vendor and product IDs, and an optional serial
 |      number (used when more than one device with the same IDs is
 |      attached). If both the port and IDs are supplied, the port will
 |      default to the supplied port string, else it will search the
 |      available com ports for a port matching the defined IDs and serial
 |      number.
 |      
 |      :param str port: Name of the the port or device file to open a
 |          connection on. For example, ``"COM10"`` on Windows or
 |          ``"/dev/ttyUSB0"`` on Linux.
 |      :param int baud: The baud rate at which instrument communicates.
 |      :param int vid: the USB port vendor id.
 |      :param int pid: the USB port product id.
 |      :param str serial_number: The USB port serial_number.
 |      :param float timeout: Number of seconds to wait when reading from the
 |          instrument before timing out.
 |      :param float write_timeout: Number of seconds to wait when writing to the
 |          instrument before timing out.
 |      
 |      :rtype: `Instrument`
 |      :return: Object representing the connected instrument.
 |      
 |      .. seealso::
 |          `~serial.Serial` for description of `port`, baud rates and timeouts.
 |  
 |  open_tcpip(host, port) from abc.ABCMeta
 |      Opens an instrument, connecting via TCP/IP to a given host and TCP port.
 |      
 |      :param str host: Name or IP address of the instrument.
 |      :param int port: TCP port on which the insturment is listening.
 |      
 |      :rtype: `Instrument`
 |      :return: Object representing the connected instrument.
 |      
 |      .. seealso::
 |          `~socket.socket.connect` for description of `host` and `port`
 |          parameters in the TCP/IP address family.
 |  
 |  open_test(stdin=None, stdout=None) from abc.ABCMeta
 |      Opens an instrument using a loopback communicator for a test
 |      connection. The primary use case of this is to instantiate a specific
 |      instrument class without requiring an actual physical connection
 |      of any kind. This is also very useful for creating unit tests through
 |      the parameters of this class method.
 |      
 |      :param stdin: The stream of data coming from the instrument
 |      :type stdin: `io.BytesIO` or `None`
 |      :param stdout: Empty data stream that will hold data sent from the
 |          Python class to the loopback communicator. This can then be checked
 |          for the contents.
 |      :type stdout: `io.BytesIO` or `None`
 |      :return: Object representing the virtually-connected instrument
 |  
 |  open_usb(vid, pid) from abc.ABCMeta
 |      Opens an instrument, connecting via a raw USB stream.
 |      
 |      .. note::
 |          Note that raw USB a very uncommon of connecting to instruments,
 |          even for those that are connected by USB. Most will identify as
 |          either serial ports (in which case,
 |          `~instruments.Instrument.open_serial` should be used), or as
 |          USB-TMC devices. On Linux, USB-TMC devices can be connected using
 |          `~instruments.Instrument.open_file`, provided that the ``usbtmc``
 |          kernel module is loaded. On Windows, some such devices can be opened
 |          using the VISA library and the `~instruments.Instrument.open_visa`
 |          method.
 |      
 |      :param str vid: Vendor ID of the USB device to open.
 |      :param int pid: Product ID of the USB device to open.
 |      
 |      :rtype: `Instrument`
 |      :return: Object representing the connected instrument.
 |  
 |  open_usbtmc(*args, **kwargs) from abc.ABCMeta
 |      Opens an instrument, connecting to a USB-TMC device using the Python
 |      `usbtmc` library.
 |      
 |      .. warning:: The operational status of this is unknown. It is suggested
 |          that you connect via the other provided class methods. For Linux,
 |          if you have the ``usbtmc`` kernel module, the
 |          `~instruments.Instrument.open_file` class method will work. On
 |          Windows, using the `~instruments.Instrument.open_visa` class
 |          method along with having the VISA libraries installed will work.
 |      
 |      :return: Object representing the connected instrument
 |  
 |  open_visa(resource_name) from abc.ABCMeta
 |      Opens an instrument, connecting using the VISA library. Note that
 |      `PyVISA`_ and a VISA implementation must both be present and installed
 |      for this method to function.
 |      
 |      :param str resource_name: Name of a VISA resource representing the
 |          given instrument.
 |      
 |      :rtype: `Instrument`
 |      :return: Object representing the connected instrument.
 |      
 |      .. seealso::
 |          `National Instruments help page on VISA resource names
 |          <http://zone.ni.com/reference/en-XX/help/371361J-01/lvinstio/visa_resource_name_generic/>`_.
 |      
 |      .. _PyVISA: http://pyvisa.sourceforge.net/
 |  
 |  open_vxi11(*args, **kwargs) from abc.ABCMeta
 |      Opens a vxi11 enabled instrument, connecting using the python
 |      library `python-vxi11`_. This package must be present and installed
 |      for this method to function.
 |      
 |      :rtype: `Instrument`
 |      :return: Object representing the connected instrument.
 |      
 |      .. _python-vxi11: https://github.com/python-ivi/python-vxi11
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors inherited from instruments.abstract_instruments.instrument.Instrument:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)
 |  
 |  address
 |      Gets/sets the target communication of the instrument.
 |      
 |      This is useful for situations when running straight from a Python shell
 |      and your instrument has enumerated with a different address. An example
 |      when this can happen is if you are using a USB to Serial adapter and
 |      you disconnect/reconnect it.
 |      
 |      :type: `int` for GPIB address, `str` for other
 |  
 |  prompt
 |      Gets/sets the prompt used for communication.
 |      
 |      The prompt refers to a character that is sent back from the instrument
 |      after it has finished processing your last command. Typically this is
 |      used to indicate to an end-user that the device is ready for input when
 |      connected to a serial-terminal interface.
 |      
 |      In IK, the prompt is specified that that it (and its associated
 |      termination character) are read in. The value read in from the device
 |      is also checked against the stored prompt value to make sure that
 |      everything is still in sync.
 |      
 |      :type: `str`
 |  
 |  terminator
 |      Gets/sets the terminator used for communication.
 |      
 |      For communication options where this is applicable, the value
 |      corresponds to the ASCII character used for termination in decimal
 |      format. Example: 10 sets the character to NEWLINE.
 |      
 |      :type: `int`, or `str` for GPIB adapters.
 |  
 |  timeout
 |      Gets/sets the communication timeout for this instrument. Note that
 |      setting this value after opening the connection is not supported for
 |      all connection types.
 |      
 |      :type: `int`
 |  
 |  ----------------------------------------------------------------------
 |  Data and other attributes inherited from instruments.abstract_instruments.instrument.Instrument:
 |  
 |  URI_SCHEMES = ['serial', 'tcpip', 'gpib+usb', 'gpib+serial', 'visa', '...

Now this is a lot of information, but of interest here is the lines starting at class PowerSupply:

class PowerSupply(instruments.abstract_instruments.instrument.Instrument)
     |  Abstract base class for power supply instruments.
     |  
     |  All applicable concrete instruments should inherit from this ABC to
     |  provide a consistent interface to the user.
     |  
     |  Method resolution order:
     |      PowerSupply
     |      instruments.abstract_instruments.instrument.Instrument
     |      builtins.object
     |  
     |  Data descriptors defined here:
     |  
     |  channel
     |      Gets a channel object for the power supply. This should use
     |      `~instruments.util_fns.ProxyList` to achieve this.
     |      
     |      This is an abstract method.
     |      
     |      :rtype: `PowerSupplyChannel`
     |  
     |  current
     |      Gets/sets the output current for all channel on the power supply.
     |      This is an abstract method.
     |      
     |      :type: `~quantities.quantity.Quantity`
     |  
     |  voltage
     |      Gets/sets the output voltage for all channel on the power supply.
     |      This is an abstract method.
     |      
     |      :type: `~quantities.quantity.Quantity`

This tells us that the class PowerSupply has three abstract methods, namely channel, current, and voltage. As we discussed previously, if we want to make a concrete class that inherits from PowerSupply then we have to override these methods and give them a definition before we can actually make an instance of our concrete class.

The class PowerSupplyChannel is mentioned above as the return type for the channel method. Let's look at what the abstract class for a channel of a power supply looks like:

In [53]:
help(ik.abstract_instruments.PowerSupplyChannel)
Help on class PowerSupplyChannel in module instruments.abstract_instruments.power_supply:

class PowerSupplyChannel(builtins.object)
 |  Abstract base class for power supply output channels.
 |  
 |  All applicable concrete instruments should inherit from this ABC to
 |  provide a consistent interface to the user.
 |  
 |  Data descriptors defined here:
 |  
 |  __dict__
 |      dictionary for instance variables (if defined)
 |  
 |  __weakref__
 |      list of weak references to the object (if defined)
 |  
 |  current
 |      Gets/sets the output current for the power supply channel. This is an
 |      abstract method.
 |      
 |      :type: `~quantities.quantity.Quantity`
 |  
 |  mode
 |      Gets/sets the output mode for the power supply channel. This is an
 |      abstract method.
 |      
 |      :type: `~enum.Enum`
 |  
 |  output
 |      Gets/sets the output status for the power supply channel. This is an
 |      abstract method.
 |      
 |      :type: `bool`
 |  
 |  voltage
 |      Gets/sets the output voltage for the power supply channel. This is an
 |      abstract method.
 |      
 |      :type: `~quantities.quantity.Quantity`
 |  
 |  ----------------------------------------------------------------------
 |  Data and other attributes defined here:
 |  
 |  __abstractmethods__ = frozenset({'current', 'mode', 'output', 'voltage...

As an exercise for now, take some time to look at what some concrete classes for instruments look like here. Notably, look at what abstract classes the real instrument classes are inheriting from and what methods they had provide definitions for because of the abstract class.

After looking at some of these concrete instrument classes, have a shot at writing your own for our demo instrument! A starting hint is that the class definition should probably start by inheriting from the PowerSupply class. Don't forget to override the required methods!

In [ ]:
#class EpqisDemoInstrument(ik.abstract_instruments.PowerSupply):
#    class Channel(ik.abstract_instruments.PowerSupplyChannel):
#        pass

You can test your new instrument by using the open_test class method, which specifies that the "instrument" is actually whatever you type into Jupyter Notebook.

In [ ]:
#ins = EpqisDemoInstrument.open_test()
In [ ]:
#ins.query("*IDN?")

If you want to test with the real fake instrument, use the open_tcpip class method instead:

In [ ]:
#ins = EpqisDemoInstrument.open_tcpip('localhost', 8042)

Let's take a look at a possible way you could write this driver file for the demo instrument. This example driver file was a part of the package you imported earlier, so let's import it explicitly now:

In [54]:
from epqis16_demos import EpqisDemoInstrument

Just like with the base instrument class, we start by making an instance of our new instrument class called EpqisDemoInstrument. Since PowerSupply inherits methods from the Instrument class, and since that class has the definition for the open_tcpip method we used before, we can use it again to connect to the instrument.

In [55]:
ik_ins = EpqisDemoInstrument.open_tcpip("localhost", 8042)

Now comes the fun part, just like we have done all along with our trivial example classes, we can just ask for the voltage attribute of a particular channel on our device:

In [56]:
ik_ins.channel[1].voltage
Out[56]:
array(0.0) * V

Similarly, we can set the value of attributes of the channels of the devices, and e:

In [57]:
ik_ins.channel[2].output = True
In [58]:
ik_ins.voltage = 12
ik_ins.channel[1].voltage = pq.Quantity(314, 'mV')
ik_ins.channel[1].voltage
Out[58]:
array(0.314) * V
In [59]:
ik_ins.current
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
<ipython-input-59-9ce8fd8ae7f2> in <module>()
----> 1 ik_ins.current

/home/skaiser/anaconda3/lib/python3.5/site-packages/epqis16_demos-0.1-py3.5.egg/epqis16_demos/ik_driver.py in current(self)
    193         Gets/sets the current for the specified channel.
    194         """
--> 195         raise NotImplementedError('This instrument does not support querying '
    196                                   'or setting the output current.')
    197 

NotImplementedError: This instrument does not support querying or setting the output current.

EXAMPLE SOLUTION


Let's take a look at the source for this driver file (with some comments removed to shorten it), and look at how the required channel, voltage, and current methods are set for the EpqisDemoInstrument class.

#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Provides support for the epqis16 Demonstration Instrument.
"""

# IMPORTS #####################################################################

from __future__ import absolute_import
from __future__ import division

import quantities as pq

from instruments.abstract_instruments import (
    PowerSupply,
    PowerSupplyChannel,
)

from instruments.util_fns import assume_units, ProxyList, bool_property
from instruments.generic_scpi import SCPIInstrument

# CLASSES #####################################################################

class EpqisDemoInstrument(PowerSupply, SCPIInstrument):

    _channel_count = 4

    def __init__(self, filelike):
        super(EpqisDemoInstrument, self).__init__(filelike)

    # PROPERTIES #
    @property
    def channel(self):
        return ProxyList(self, EpqisDemoInstrument.Channel, range(self._channel_count))

    @property
    def voltage(self):
        return [
            self.channel[i].voltage for i in range(self._channel_count)
        ]

    @voltage.setter
    def voltage(self, newval):
        if isinstance(newval, (list, tuple)):
            if len(newval) is not self._channel_count:
                raise ValueError('When specifying the voltage for all channels '
                                 'as a list or tuple, it must be of '
                                 'length {}.'.format(self._channel_count))
            for channel, new_voltage in zip(self.channel, newval):
                channel.voltage = new_voltage
        else:
            for channel in self.channel:
                channel.voltage = newval

    @property
    def current(self):
        raise NotImplementedError('This instrument does not support querying '
                                  'or setting the output current.')

    @current.setter
    def current(self, newval):
        raise NotImplementedError('This instrument does not support querying '
                                  'or setting the output current.')

We can see that the device is defined to have 4 channels with _channel_count. The @property and @__.setter lines are called decorators and basically allow a function to "wrap" the one that is defined right below it. That means that the decorator function is called first with the argument of the function that it is wrapping. For example then for the function voltage(self), it is actually called as property(voltage)(self).

Also defined in the EpqisDemoInstrument is how to interface to a channel for this instrument.

class Channel(PowerSupplyChannel):

        def __init__(self, parent, idx):
            self._parent = parent
            self._idx = idx + 1

        # COMMUNICATION METHODS #

        def sendcmd(self, cmd):
            self._parent.sendcmd("CH{} {}".format(self._idx, cmd))

        def query(self, cmd):
            return self._parent.query("CH{} {}".format(self._idx, cmd))

        # PROPERTIES #
        @property
        def mode(self):
            raise NotImplementedError('This instrument does not support querying '
                                      'or setting the output current.')

        @mode.setter
        def mode(self, newval):
            raise NotImplementedError('This instrument does not support querying '
                                      'or setting the output current.')

        @property
        def voltage(self):
            value = self.query("VOLTS?")
            return assume_units(float(value), pq.millivolt).rescale(pq.volt)

        @voltage.setter
        def voltage(self, newval):
            newval = assume_units(newval, pq.volt).rescale(pq.millivolt).magnitude
            self.sendcmd("VOLTS {}".format(newval))

        @property
        def current(self):
            raise NotImplementedError('This instrument does not support querying '
                                      'or setting the output current.')

        @current.setter
        def current(self, newval):
            raise NotImplementedError('This instrument does not support querying '
                                      'or setting the output current.')

        output = bool_property(
            "ENABLE",
            inst_true="ON",
            inst_false="OFF",
            doc="""
            Sets the outputting status of the specified channel.

            This is a toggle setting that can be ON or OFF. 

            :type: `bool`
            """
        )

Lots of options of starting points, don't reinvent the wheel!

We all have no more valuable resource than our own time, so there is little point to reimplementing functionality that exists in other packages that will work in your Python work flow. As usual, google your instrument to see if there is a Python package that already supports it (or if the manufacturer can supply control code samples!). Here are some popular packages that together cover a lot of instruments.