Writing Drivers¶
Examples¶
Overview¶
An Instrumental driver is a high-level Python interface to a hardware device. These can be implemented in a number of ways, but usually fall into one of two categories: message-based drivers and foreign function interface (FFI)-based drivers.
Many lab instruments—whether they use GPIB, RS-232, TCPIP, or USB—communicate using text-based messaging protocols. In this case, we can use PyVISA to interface with the hardware, and focus on providing a high-level pythonic API. See Writing VISA-based Drivers for more details.
Otherwise, the instrument is likely controlled via a library (DLL) which is designed to be used by an application written in C. In this case, we can use NiceLib to greatly simplify the wrapping of the library. See Writing NiceLib-Based Drivers for more details.
Generally a driver module should correspond to a single API/library which is being wrapped. For example, there are two separate drivers for Thorlabs cameras, instrumental.drivers.cameras.uc480
and instrumental.drivers.cameras.tsi
, each corresponding to a separate library.
By subclassing Instrument
, your class gets a number of features for free:
Auto-closing on program exit (just provide a
close()
method)A context manager which automatically closes the instrument
Saving of instruments via
save_instrument()
Integration with
ParamSet
Integration with
Facet
Writing VISA-based Drivers¶
To control instruments using message-based protocols, you should use PyVISA, by making your driver class inherit from VisaMixin
. You can then use MessageFacet()
or SCPI_Facet()
to easily implement a lot of common functionality (see Facets for more information). VisaMixin
provides a resource
property as well as write()
and query()
methods for your class.
If you’re implementing _instrument()
and need to open/access the VISA instrument/resource, you should use instrumental.drivers._get_visa_instrument()
to take advantage of caching.
For a walkthough of writing a VISA-based driver, check out the VISA Driver Example.
Writing NiceLib-Based Drivers¶
If you need to wrap a library or SDK with a C-style interface (most DLLs), you will probably want to use NiceLib, which simplifies the process. You’ll first write some code to generate mid-level bindings for the library, then write your high-level bindings as a separate class which inherits from the appropriate Instrument
subclass. See the NiceLib documentation for details on how to use it, and check out other NiceLib-based drivers to see how to integrate with Instrumental.
For a walkthough of writing a NiceLib-based driver, check out the NiceLib Driver Example.
Integrating Your Driver with Instrumental¶
To make your driver module integrate nicely with Instrumental, there are a few patterns that you should follow.
Special Driver Variables¶
Note
Some old drivers use special variables that are defined on the module level, just below the imports. This method is now deprecated in favor of class-level variables. See Special Driver Variables (deprecated method) for info on the old variables.
When an instrument is created via list_instruments()
, Instrumental must find the proper driver to use. To avoid importing every driver module to check for the instrument, we use the statically generated file driver_info.py
. To register your driver in this file, do not edit it directly, but instead define special class attributes within your driver class:
_INST_PARAMS_
A list of strings indicating the parameter names which can be used to construct the instruments that this driver class provides. The
ParamSet
objects returned bylist_instruments()
should provide each of these parameters. Usually VISA instruments just set_INST_PARAMS_ = ['visa_address']
._INST_VISA_INFO_
(Optional, only used for VISA instruments) A tuple
(manufac, models)
, to be checked against the result of an*IDN?
query.manufac
is the manufacturer string, andmodels
is a list of model strings._INST_PRIORITY_
(Optional) An int (nominally 0-9) denoting the driver’s priority. Lower-numbered drivers will be tried first. This is useful because some drivers are either slower, less reliable, or less commonly used than others, and should therefore be tried only after all other options are exhausted.
To re-generate driver_info.py
, run python -m instrumental.parse_modules
. This will parse all of the driver code and look for classes defining the class attribute _INST_PARAMS_
, adding them to its list of known drivers. The generated file contains all driver modules, driver classes, parameters, and required imports. For instanc:
driver_info = OrderedDict([
('motion._kinesis.isc', {
'params': ['serial'],
'classes': ['K10CR1'],
'imports': ['cffi', 'nicelib'],
}),
('scopes.tektronix', {
'params': ['visa_address'],
'classes': ['MSO_DPO_2000', 'MSO_DPO_3000', 'MSO_DPO_4000', 'TDS_1000', 'TDS_200', 'TDS_2000', 'TDS_3000', 'TDS_7000'],
'imports': ['pyvisa', 'visa'],
'visa_info': {
'MSO_DPO_2000': ('TEKTRONIX', ['MSO2012', 'MSO2014', 'MSO2024', 'DPO2012', 'DPO2014', 'DPO2024']),
},
}),
])
Special Driver Variables (deprecated method)¶
Note that these old variable names lacked the trailing underscore.
_INST_PARAMS
A list of strings indicating the parameter names which can be used to construct the instruments that this driver provides. The
ParamSet
objects returned bylist_instruments()
should provide each of these parameters._INST_CLASSES
(Not required for VISA-based drivers) A list of strings indicating the names of all
Instrument
subclasses the driver module provides (typically only one). This allows you to avoid writing a driver-specific_instrument()
function in most cases._INST_VISA_INFO
(Optional, only used for VISA instruments) A dict mapping instrument class names to a tuple
(manufac, models)
, to be checked against the result of an*IDN?
query.manufac
is the manufacturer string, andmodels
is a list of model strings.For instruments that support the
*IDN?
query, this allows us to directly find the correct driver and class to use._INST_PRIORITY
(Optional) An int (nominally 0-9) denoting the driver’s priority. Lower-numbered drivers will be tried first. This is useful because some drivers are either slower, less reliable, or less commonly used than others, and should therefore be tried only after all other options are exhausted.
Special Driver Functions¶
These functions, if implemented, should be defined at the module level.
list_instruments()
(Optional for VISA-based drivers) This must return a list of
ParamSet
s which correspond to each available device that this driver sees attached. EachParamSet
should contain all of the params listed in this driver’s_INST_PARAMS
._instrument(paramset)
(Optional) Must find and return the device corresponding to
paramset
. If this function is defined,instrumental.instrument()
will use it to open instruments. Otherwise, the appropriate driver class is instantiated directly._check_visa_support(visa_rsrc)
(Optional, only applies to VISA-based drivers) Must return the name of the
Instrument
subclass to use ifvisa_rsrc
is a device that is supported by this driver, andNone
if it is not supported.visa_rsrc
is apyvisa.resources.Resource
object. This function is only needed for VISA-based drivers where the device does not support the*IDN?
query, and instead implements its own message-based protocol.
Writing Your Instrument
Subclass¶
Each driver subpackage (e.g. instrumental.drivers.motion
) defines its own subclass of Instrument
, which you should use as the base class of your new instrument. For instance, all motion control instruments should inherit from instrumental.drivers.motion.Motion
.
Writing _initialize()
¶
Instrument
subclasses should implement an _initialize()
method to perform any required initialization (instead of __init__
). For convenience, the special settings parameter is unpacked (using **
) into this initializer. Any optional settings you support should be given default values in the function signature. No other arguments are passed to _initialize()
.
_paramset
and other mixin-related attributes (e.g. resource
for subclasses of :class`~instrumental.drivers.VisaMixin`) are already set before _initialize()
is called, so you may access them if you need to.
Special Methods¶
There are also some special methods you may provide, all of which are optional.
close()
Close the instrument. Useful for cleaning up per-instrument resources. This automatically gets called for each instrument upon program exit. The default implementation does nothing.
_fill_out_paramset()
Flesh out the
ParamSet
that the user provided. Usually you’d only reimplement this to provide a more efficient implementation than the default. The input params can be accessed and modified viaself._paramset
.The default implementation first checks which parameters were provided. If the user provided all parameters listed in the module’s
_INST_PARAMS
, the params are considered complete. Otherwise, the driver’sinstrumental.drivers.list_instruments()
is called, and the the first matching set of params is used to fill in any missing entries in the input params.
Driver Parameters¶
A ParamSet
is a set of identifying information, like serial number or name, that is used to find and identify an instrument. These ParamSet
s are used heavily by instrument()
and list_instruments()
. There are some specially-handled parameters in addition to the ordinary ones, as described below.
You can customize how an Instrument
’s paramset is filled out by overriding the _fill_out_paramset()
method. The default implementation uses instrumental.drivers.list_instruments()
to find a matching paramset, and updates the original paramset with any fields that are missing.
Special params¶
There are a few parameters that are treated specially. These include:
- module
The name of the driver module, relative to the
drivers
package, e.g.scopes.tektronix
.- classname
The name of the class to which these parameters apply.
- server
The address of an instrument server which should be used to open the remote instrument.
- settings
A dict of extra settings which get passed as arguments to the instrument’s constructor. These settings are separated from the other parameters because they are not considered identifying information, but simply configuration information. More specifically, changing the
settings
should never change which instrument the givenParamSet
will open.- visa_address
The address string of a VISA instrument. If this is given, Instrumental will assume the parameters refer to a VISA instrument, and will try to open it with one of the VISA-based drivers.
Common params¶
Driver-defined parameters can be named pretty much anything (other than the special names given above). However, they should typically fall into a small set of commonly shared names to make the user’s life easier. Some commonly-used names you should consider using include:
serial
model
number
id
name
port
In general, don’t use vendor-specific names like newport_id
(also avoid including underscores, for reasons that will become clear). Convenient vendor-specific parameters are automatically supported by instrument()
. Say for example that the driver instrumental.drivers.cameras.tsi
supports a :param:`serial` parameter. Then you can use any of the parameters serial
, tsi_serial
, tsi_cam_serial
, and cam_serial
to open the camera. The parameter name is split by underscores, then used to filter which modules are checked.
Note that cam_serial
(vs cameras_serial
) is not a typo. Each section is matched by substring, so you can even use something like tsi_cam_ser
.
Useful Utilities¶
Instrumental provides some commonly-used utilities for helping you to write drivers, including decorators and functions for helping to handle unitful arguments and enums.
- instrumental.drivers.util.check_units(*pos, **named)
Decorator to enforce the dimensionality of input args and return values.
Allows strings and anything that can be passed as a single arg to
pint.Quantity
.@check_units(value='V') def set_voltage(value): pass # `value` will be a pint.Quantity with Volt-like units
- instrumental.drivers.util.unit_mag(*pos, **named)
Decorator to extract the magnitudes of input args and return values.
Allows strings and anything that can be passed as a single arg to
pint.Quantity
.@unit_mag(value='V') def set_voltage(value): pass # The input must be in Volt-like units and `value` will be a raw number # expressing the magnitude in Volts
- instrumental.drivers.util.check_enums(**kw_args)¶
Decorator to type-check input arguments as enums.
Allows strings and anything that can be passed to
as_enum
.@check_enums(mode=SampleMode) def set_mode(mode): pass # `mode` will be of type SampleMode
- Noindex:
- instrumental.drivers.util.as_enum(enum_type, arg)
Check if arg is an instance or key of enum_type, and return that enum
- instrumental.drivers.util.visa_timeout_context(resource, timeout)
Context manager for temporarily setting a visa resource’s timeout.
with visa_timeout_context(rsrc, 100): ... # `rsrc` will have a timeout of 100 ms within this block
Driver-Writing Checklist¶
There are a few things that should be done to make a driver integrate really nicely with Instrumental:
Add any Special Driver Variables your driver needs at the top of the driver module
Implement any Special Driver Functions you need
Implement a
close()
method if appropriateImplement any required methods from the base class
Some other important things to keep in mind:
Use Pint Units in your API
Ensure Python 3 compatibility
Add documentation
Add supported device(s) to the list in Package Overview
Document methods using numpy-style docstrings
Add extra docs to show common usage patterns, if applicable
List dependencies following a template (both Python packages and external libraries)