Python Backend Tutorial¶
Overview¶
PLACE is a system for controlling laboratory hardware to perform an experiment. The system is written to be as modular (and as simple) as possible. Each hardware component is viewed as a plugin to the system. Therefore, it is especially important that each plugin adhere to specific guidelines.
To reach a wide audience, the PLACE backend is written entirely in Python. Python is a highly accessible language. Python is also robust and has a wide range of libraries used by the scientific community.
This document will provide a walkthrough for developing a new plugin for PLACE.
Before you begin¶
If you have never written a plugin for PLACE, and you want to begin using a new piece of hardware in your experimental setup, it is highly recommended that you learn to use the new hardware independently of PLACE before you begin writing your PLACE plugin. Remember that at its core, PLACE is automation software and does not replace the need for drivers for your instrument.
If you already know how to control your hardware using the Python interpreter, or by writing short Python scripts, this will make it very easy to write your PLACE plugin.
Necessary files¶
When PLACE runs your plugin, it must be given the Python module name and the Python class name. With these two pieces of information, it will then attempt to perform, essentially, the following code:
from place.plugins.<module_name> import <class_name>
<class_name>.config()
for i in range(update_number):
<class_name>.update()
<class_name>.cleanup()
This is the directory structure for the PLACE source code:
place
|-- elm
| `-- plugins
| `-- helpers
|-- place
| `-- plugins
|-- placeweb
| |-- static
| | `-- placeweb
| | |-- documentation
| | `-- plugins
| `-- templates
| `-- placeweb
`-- sphinx
I’ll quickly explain this directory structure.
The elm directory¶
This contains Elm source. Elm is a programming language designed to generate JavaScript. Elm is highly recommended if you are writing modules for PLACE, but it is not required. PLACE will not look at anything in this directory. Note that Elm files enforce a slightly different naming convention.
The place directory¶
This is the Python backend for PLACE. So, this is where lots of things happen. There is a subdirectory named plugins. Your PLACE backend module goes here. The name of this directory is the semi-official name of your plugin, so make sure you give it a logical name.
Inside this directory is an __init__.py
file. This file should
import anything that you want available to PLACE. Typically, this will
be the instrument classes you write. So, typically, a single line, like
from .new_plugin import InstrumentA, InstrumentB
is all that’s
needed.
Your Python files can really be named anything you like, but generally
the entry point file should have the same name as your plugin - up to
you. Remember that the __init__
file will take care of exposing
stuff to PLACE, so it’s really no big deal what names you use.
Additionally, many modules require a Python driver either provided by the manufacturer or custom written. Files like this should be included with you PLACE module, as well.
The placeweb directory¶
This contains the code for executing the web interface for PLACE. Your module should include a web interface, but if you write it in Elm, we will build this file automatically. Using JavaScript to build PLACE interfaces is not recommended nor supported.
The documentation built by Sphinx will also be put into the directory.
The sphinx directory¶
This contains the Sphinx build files, which are basically .rst
files
that instruct Sphinx how to build the webpages that contain the PLACE
documentation. Your Python source code should contain Sphinx markup in
the docstrings and you will eventually need to add a file in here for
your module, but we will ignore this for now.
Instrument interface¶
In place/place/plugins
you will find a Python file containing an
Instrument interface class. An interface is essentially a class that
names methods that must be implemented by subclasses. By making your
plugin classes a subclass of Instrument
you will ensure that you
have implemented all the required methods used by PLACE during an experiment.
Start your instrument classes like this:
from place.plugins.instrument import Instrument
class MyDevice(Instrument):
# (definition goes here) #
Currently, these are three methods you must implement.
__init__ (self, config, plotter)¶
Okay, technically, there are usually four methods you must implement, and this is the fourth one. This is the standard constructor for Python. We are passed the configuration data for our instrument, which should be a Python dictionary. PLACE will just take it from the web interface and send it to your code - simple as that!
As a subclass, we should ensure that the initializer of the base class is called. There are a number of ways to do this in Python, but using the explicit call to the initializer works fine, I think. Just call it like this:
Instrument.__init__(self, config, plotter)
The Instrument initializer puts JSON data for your hardware into into
self._config
and sets self.priority
to 100 (alhtough you usually
override this). This is done in the Instrument initializer because we need
to ensure that these two things are there for PLACE. All the other class
(self) variables can be determined as you see fit.
The plotter
is also stored for you by the initializer, and accessible to the
instrument as self.plotter
. You can call this to register a plot which is
sent to the web interface. The plotter is an instance of the Plotter object in
place/place/plots.py
, and has a variety of functions to help you easily
create plots of your data.
This method is not required, and if you find that you are just calling
the Instrument.__init__(self, config)
listed above, and that’s it,
then you might as well just omit the method. But typically, you will
find yourself putting something in here.
config(self, metadata, total_updates)¶
This method is called by PLACE at the beginning of the experiment. This is when you should get everything up and running for the instrument.
As a convenience, the module is provided with the total number of times the update method (the next method in this section) will be called for your module.
Additionally, you will receive a metadata
dictionary. This dictionary
holds values measured by devices at the start of an experiment. During
the config
phase, you should add any values you would like to set
for the entire experiment. A common usage might be to record the serial
number and calibration data of the instrument you are using. Please
avoid common names, since the dictionary is shared. Otherwise, you might
clobber data and invalidate an experiment. The data recorded into the
metadata dictionary will be saved into the configuration data for the
experiment, stored as config.json
in the experiment directory.
Note that, as a policy, instruments can only access the metadata before the experiment begins. This is to reenforce the idea that metadata is global for the experiment and known beforehand (a.k.a. not a measurement). Anything that is measured should be recorded into the NumPy array during the update phase.
update(self, update_number, progress)¶
This method is called by PLACE during the experiment. For example, one experiment might take measurements from 100 different places on an object. This means PLACE will call update on your method 100 times. Each time it is called, you will need to do whatever it is your instrument needs to do during that time. If your instrument is moving the object, this is when you do that. If you are taking a measurement, then your instrument needs to do that. PLACE isn’t interested in what your instrument actually does, it’s just telling you that it’s your turn.
You will receive a progress
parameter, which is where the plotter records
plots, which are then returned to the user interface. This could be used by an
advanced user to send arbitrary data back to their web interface, but that use
case has not been developed or explored at this time.
You will also have access to the current update number, so your module can plan accordingly.
cleanup(self, abort=False)¶
This method is called by PLACE at the end of the experiment. It may also be called if there is a problem with the experiment. Unfortunately, there is no guarantee that this method will be called, so do as much as possible to keep resources as free as possible. If this does get called, though, your device should assume the experiment has ended and the code should free all used resources.
If the abort
parameter is set, this indicates that the experiment is
being abandoned, perhaps due to a safety concern, such as a problem with
one of the instruments. In this case, halting all real world activity
should be prioritized, and tasks regarding plotting, software resources
or data integrity can be skipped.
Writing a sample plugin¶
Deciding what the plugin will do¶
The first step in developing your plugin is to decide what needs to be automated. For this example, let automate a function generator in a simple way. Let’s say our function generator outputs a sine wave at a specific frequency and we want to automate this so that each update is performed at a different frequency.
We will start by figuring out what the code we would use if we were not using PLACE. As a general rule, if you can’t figure out how you would code the solution outside of PLACE, then you probably aren’t ready to write a PLACE module. Let’s say we communicate over a typical Linux seral port and the instrument responds to ASCII commands specified in the programmer’s manual for the device. Our code will start at 100 Hz and step by 5 Hz up to 200 Hz.
Our non-PLACE Python script to perform this would probably be something like this:
import serial
with serial.Serial('/dev/ttyS0') as conn:
for freq in range(100, 205, 5):
conn.write(bytes('FREQ {}'.format(freq), 'ascii'))
First round of adjustments¶
So, we have the above script that performs an example of the task we want. The first modification to make is to extract the values that may change, and assign them to values. Later, we will put these values into our webapp so they can be changed by the user. Looking at the above code, I would say that the variables are: serial port path, first frequency, last frequency, and step. So let’s move those out of the code.
import serial
serial_port = '/dev/ttyS0'
first_freq = 100
last_freq = 200
step_freq = 5
end_freq = last_freq + step_freq
with serial.Serial(serial_port) as conn:
for freq in range(first_freq, end_freq, step_freq):
conn.write(bytes('FREQ {}'.format(freq), 'ascii'))
That looks better. Now all the values we may need to change are at the top and will be easy for us to work with in the next steps.
Turn the code into a PLACE instrument class¶
PLACE will reject our module if it isn’t a subclass of the Instrument class built into PLACE. You can look at another module as a template, but this is basically what you need.
import serial
from place.plugins.instrument import Instrument
class XY123FunctionGenerator(Instrument):
def config(self, metadata, total_updates):
serial_port = '/dev/ttyS0'
first_freq = 100
last_freq = 200
step_freq = 5
end_freq = last_freq + step_freq
with serial.Serial(serial_port) as conn:
for freq in range(first_freq, end_freq, step_freq):
conn.write(bytes('FREQ {}'.format(freq), 'ascii'))
def update(self, update_number, progress):
pass
def cleanup(self, abort=False):
pass
This code is actually a fully functional PLACE module (minus a web interface). This would work. Now, it wouldn’t probably work as intended, because everything happens during the config phase at the start of the experiment. But, if it wasn’t interacting with any other instruments, this would do basically the same thing as our original script. Also, notice that we had to name our class, and I chose to include the fictional model number XY123 in the name. This prevents our code from conflicting with other PLACE modules because it is much less likely to have the same name as any other module.
Start leveraging the PLACE tools and information¶
So now that we have a PLACE module on our hands, we need to start
thinking about how to generalize our code to best work with PLACE. One
of the cornerstones of the PLACE software is that it allows users to
choose an arbitrary number of updates. This value is passed to us during
the config phase, and we should respond to it appropriately. In our
case, it means that we either need to fix the last_freq
or the
step_freq
value and calculate the other based on the value of
total_updates
. In this example, we will fix the step_freq
. We
get the following code:
import serial
from place.plugins.instrument import Instrument
class XY123FunctionGenerator(Instrument):
def config(self, metadata, total_updates):
serial_port = '/dev/ttyS0'
first_freq = 100
step_freq = 5
last_freq = first_freq + (step_freq * total_updates)
end_freq = last_freq + step_freq
with serial.Serial(serial_port) as conn:
for freq in range(first_freq, end_freq, step_freq):
conn.write(bytes('FREQ {}'.format(freq), 'ascii'))
def update(self, update_number, progress):
pass
def cleanup(self, abort=False):
pass
While we’re at it, we should talk about the other value we get during
the config phase, the metadata
. The metadata
is a dictionary
which is passed around to all the modules during the config phase and it
is used to record data related to the entire experiment. A common use is
to put information into this dictionary that does not change during the
experiment, but may be needed in the future. One example might be
recording the ambient air temperature once at the start of the
experiment. In our case, we are going to put the ID string returned from
the function generator.
import serial
from place.plugins.instrument import Instrument
class XY123FunctionGenerator(Instrument):
def config(self, metadata, total_updates):
serial_port = '/dev/ttyS0'
first_freq = 100
step_freq = 5
last_freq = first_freq + (step_freq * total_updates)
end_freq = last_freq + step_freq
with serial.Serial(serial_port) as conn:
conn.write(bytes('*IDN?', 'ascii'))
id_string = conn.readline()
metadata['XY123-id-string'] = id_string.decode('ascii').strip()
with serial.Serial(serial_port) as conn:
for freq in range(first_freq, end_freq, step_freq):
conn.write(bytes('FREQ {}'.format(freq), 'ascii'))
def update(self, update_number, progress):
pass
def cleanup(self, abort=False):
pass
The ID string is saved into a key in the dictionary that we select, although it’s important that we choose a unique key. Putting values into the metadata is relatively arbitrary. Think of it as a notepad or journal that will be saved into the experiment data.
Reading PlaceConfig values¶
In our code, we have a value name serial_port
that contains the
string path to find the port that connects to our instrument. This is a
bit of a special value because it is not likely to change very often,
but it is not likely to be the same for every computer. It is for this
reason that PLACE has a configuration API called PlaceConfig. Think of
it as a storage location for setting that shouldn’t be in the webapp,
because they will almost always have the same value.
PLACE manages this file for you. It is always located in your Linux home
directory and is always named .place.cfg
. The PlaceConfig API is
based on the configparser
library, which
is very easy to use.
Watch how we modify our code to store the serial port location in the PLACE config file.
import serial
from place.plugins.instrument import Instrument
from place.config import PlaceConfig
class XY123FunctionGenerator(Instrument):
def config(self, metadata, total_updates):
name = self.__class__.__name__
serial_port = PlaceConfig().get_config_value(name, 'serial_port')
first_freq = 100
step_freq = 5
last_freq = first_freq + (step_freq * total_updates)
end_freq = last_freq + step_freq
with serial.Serial(serial_port) as conn:
conn.write(bytes('*IDN?', 'ascii'))
id_string = conn.readline()
metadata['XY123-id-string'] = id_string.decode('ascii').strip()
with serial.Serial(serial_port) as conn:
for freq in range(first_freq, end_freq, step_freq):
conn.write(bytes('FREQ {}'.format(freq), 'ascii'))
def update(self, update_number, progress):
pass
def cleanup(self, abort=False):
pass
Pretty easy, right? You can read about PlaceConfig
here. Basically, this one
command handles everything for you. If you ever need to change the
value, just edit ~/.place.cfg
and change the approprate value. PLACE
will automatically grab it the next time it runs.
Reading webapp/user data¶
After reading what we can from PlaceConfig, we need to get anything else we need from the user. The web interface module (which we’ll talk about later) should facilitate getting these options from the user to our Python code. Here we will see how that works and, again, it’s really easy. Almost everything happens behind the scenes.
When PLACE initializes your module, all the settings provided by the webapp will
be put into your class. A special dictionary of values called _config
is
included and will contain all the values you need. So, just get the values you
want from there… and at this stage, you can just name them anything you want.
import serial
from place.plugins.instrument import Instrument
from place.config import PlaceConfig
class XY123FunctionGenerator(Instrument):
def config(self, metadata, total_updates):
name = self.__class__.__name__
serial_port = PlaceConfig().get_config_value(name, 'serial_port')
first_freq = self._config['first_freq']
step_freq = self._config['step_freq']
last_freq = first_freq + (step_freq * total_updates)
end_freq = last_freq + step_freq
with serial.Serial(serial_port) as conn:
conn.write(bytes('*IDN?', 'ascii'))
id_string = conn.readline()
metadata['XY123-id-string'] = id_string.decode('ascii').strip()
with serial.Serial(serial_port) as conn:
for freq in range(first_freq, end_freq, step_freq):
conn.write(bytes('FREQ {}'.format(freq), 'ascii'))
def update(self, update_number):
pass
def cleanup(self, abort=False):
pass
Unlike metadata, self._config
is available anywhere in your module,
so it can be used in the update and cleanup phases, too.
Move things into the correct methods¶
Up until now, we’ve put everything into the config method, meaning it
would all run at the beginning of the experiment. But, obviously, in
reality, we want the frequency to change during the update phase, so
that it happens at the correct time in relation to any other instruments
in the experiment. In this step, we will move the code that sets the
current frequency into the update method. We can also use the
update_number
parameter to calculate the correct frequency. All
these changes eliminate the need for our for
loop, as PLACE
automatically calls update once for each update requested by the user.
This is pretty big change to our existing code, so see if you can follow
what happens here.
import serial
from place.plugins.instrument import Instrument
from place.config import PlaceConfig
class XY123FunctionGenerator(Instrument):
def config(self, metadata, total_updates):
name = self.__class__.__name__
self.serial_port = PlaceConfig().get_config_value(name, 'serial_port')
with serial.Serial(self.serial_port) as conn:
conn.write(bytes('*IDN?', 'ascii'))
id_string = conn.readline()
metadata['XY123-id-string'] = id_string.decode('ascii').strip()
def update(self, update_number):
curr_freq = self._config['first_freq'] + (update_number * self._config['step_freq'])
with serial.Serial(self.serial_port) as conn:
conn.write(bytes('FREQ {}'.format(curr_freq), 'ascii'))
def cleanup(self, abort=False):
pass
The first thing that changed was that I added self
onto the front of
serial_port
, making it a class variable and allowing me to access it
from another method. Next I moved the frequency setting code into the
update method and used the value of update_number
to calculate the
frequency for the current update only. This eliminated the need for
many of the variables I had been using to control the for
loop.
Wraping up¶
That’s basically it! We should be basically done. I hope you were able to follow all of that. I promise that after a couple modules it becomes second nature.
The last thing we want to do is make the __init__.py
file for our
module. So we create a new file with that name. In this file, all we
need to do is import the class we created, allowing PLACE to see it. In
our case, the file needs one line, like this:
from .xy123_function_gen import XY123FunctionGenerator
This assumes you named your module file xy123_function_gen.py
.
Alright! That’s it for now.