6. Easy GUI Generator (egg) - Spinmob/spinmob GitHub Wiki

Spinmob now includes a simplified "graphical user interface" (GUI) builder based on pyqt and pyqtgraph. The idea is to expose only the most commonly-used pyqt objects and features to new users so they do not need to sift through long lists of methods & properties or re-invent several commonly-used scientific wheels. Basically, pyqt is fantastic, but far too flexible / low-level for me to remember, especially if I haven't looked at my code for a few months.

Importantly, however, pyqt objects are still readily integrated into egg projects, and the full underlying functionality to all egg objects is available for advanced users.

To import:

>>> from spinmob import egg

Then see what's available. The typical workflow is to:

  • create an egg.gui.Window(),
  • position objects via the window's place_object() method (which accepts our simplified egg.gui objects, pyqt widgets, or pyqtgraph widgets),
  • define functions and link them to widget signals via the window's connect() method, then
  • show the window to the user via the window's show() method

The best way to learn this library is by playing with examples (found in site-packages/spinmob/egg/examples/), looking at auto-completion tips on the command line, and reading the help() for objects and methods. The examples are intentionally not programmed using classes to facilitate those new to python. Making a class will help organize your code, but is not necessary to access the full functionality of this library.

The best way to learn about pyqtgraph is to run the following code:

>>> import pyqtgraph.examples
>>> pyqtgraph.examples.run()

Below is an illustrative tutorial that will show you how to build a simple data acquisition. It is delivered from the command line to allow you to mess around with the various objects in real time, and a scripted version of this GUI can be found in spinmob/egg/examples/example_from_wiki.py. Enjoy!

A basic GUI for acquiring, plotting, saving, & loading fake data

1. Create a window and show it

After importing the egg library (see above), create an instance of egg's "Window" object and show it.

>>> w = egg.gui.Window("Hey, mammal.")
>>> w.show()

The second command should pop up a blank window titled "Hey, mammal."

Hey, mammal.

Like all egg objects, the egg window is an übersimplified version of a common Qt object. Typing "w." you should see a list of just the most common methods and attributes, such as w.set_position(). Like the rest of spinmob, everything not starting with an underscore '_' is designed for you to play with. Let's try moving the window by specifying new coordinates (in pixels)

>>> w.set_position([17,0])

After this command, the window should have moved to the top of your screen, 17 pixels from the left edge. Unlike Qt, all egg objects are designed so that internal parameters are modified by methods beginning with set_; this greatly reduces the amount of memorization required. Try some of the other w.set_ methods. It's super fun.

Notes for hard-core users:

  • The egg window is based on QWindow, QWidget, and QGridLayout. The rest of the qt functionality can be accessed via w._window, w._widget, and w._layout, respectively.
  • This is an instance of a larger pattern in the design of egg: The Qt widget instance is always located in *._widget, and the layout (if relevant) is always in *._layout.

2. Add a button

The most common egg window method is w.place_object(). Before we can add an object, however, we have to create one. Let's create a button and place it in the window.

>>> b = egg.gui.Button("Fire!")
>>> w.place_object(b)
<spinmob.egg._gui.Button instance at 0x06A29418>

Now you should see a button:

Tanzenhosen.

Note that the return value of w.place_object() is the button instance itself, so this process can be written in a single line. For example,

>>> b = w.place_object(egg.gui.Button("Fire!"))

will accomplish the same thing. This is another instance of a larger pattern in egg: unless there is a specific return value associated with an egg method, it will always return the object under consideration. This allows you to "daisy-chain" commands; for example, try:

>>> b.set_width(70).set_text("Fire Once!")
<spinmob.egg._gui.Button instance at 0x06A29558>

The window should update accordingly.

3. Make the button do something

As you probably noticed, the button can be clicked (it responds to being pressed and everything), but clicking it doesn't accomplish anything. Let's define a function to catch the click "signal" from the button (remember you have to press enter twice to end the function):

>>> def f(*a): print "Oooooh, received", a

This function just prints the arguments it receives. So let's connect it to the button and see what happens:

>>> w.connect(b.signal_clicked, f)

Many widgets have more than one signal (e.g. when a value changes, etc), and by convention, all signals in egg start with signal_ so they are easy to find. Note that the above command is synonymous with b.signal_clicked.connect(f), but you'll notice a rat's nest of options after b.signal_clicked. making it hard to find. That being said, I usually do it the second way because it looks more logical to me.

Anyway, now the command line should print something when you click the button:

Oooooh, received (False,)

Apparently clicking the button sends one argument to the connected function, namely "False". You can read the Qt documentation about what values are sent by what signals (often they send useful information!), or you can ignore them. In this case, the "clicked" signal the "logical state" of the button just after it is clicked. When would this be important? Well, try running this command:

>>> b.set_checkable(True)

Now the button can be toggled, and the argument sent by the signal should alternate. Let's return the button to its original state:

>>> b.set_checkable(False)

4. Add a databox with load/save buttons

Ultimately we want to have this button do something useful, e.g. take some fake data, but we need a place to store this data. We are of course free to use any data management tools provided by python, but egg comes standard with a few controls involving (of course) databoxes. Let's create the simplest databox GUI object and place it in the window:

>>> d = egg.gui.DataboxSaveLoad(autosettings_path='d.cfg')
>>> w.place_object(d)

Databox Instance
  header elements: 0
  columns of data: 0

At this point, you should see a "load" and "save" button floating in the middle of nowhere. These do exactly what you might anticipate. Notice also that d "looks" like a normal databox from a programming standpoint. It is -- it inherited all of the databox functionality -- but egg has grafted on some extra graphical functionality surrounding the two buttons. The keyword argument autosettings_path='d.cfg' specifies that the DataboxSaveLoad will store its control settings (i.e., the combo box selection in this case) in the file ./gui_settings/d.cfg, and automatically load this next time it runs. Most egg.gui objects have this functionality.

If you know how to use a databox, you already know how to use most of this object. For example, we could manually add a few columns of data, just like normal:

>>> d['x'] = [1,2,3]
>>> d['y'] = [1,2,1]
>>> d

Databox Instance
  header elements: 0
  columns of data: 2

After this, there is clearly some data in the databox, but the save button is still "greyed out" in the GUI. This is because d[] = is using built-in databox functionality that is currently not linked to the GUI. Someday we may find a way to make this a bit smoother, but for now as a coder you are responsible for managing the save button:

>>> d.enable_save()

Ahhh, there we go. Try clicking the save button! :+1:

5. Next row: add a pyqtgraph plot

You'll notice that as we add objects, they are automatically placed in a single row. To make a new row,

>>> w.new_autorow()

New objects will now be place below the first two objects. Let's add a pyqtgraph plot:

>>> p = w.place_object(egg.pyqtgraph.PlotWidget())

This should position the plot as shown below. All pyqtgraph objects can be found under egg.pyqtgraph. Definitely read about all the things pyqtgraph can do (or see for yourself with import pyqtgraph.examples; pyqtgraph.examples.run() -- Do not try this in our current python shell, however!).

Ja.

Notice the weird position of the databox buttons. The reason for this is all objects in egg windows live on an underlying auto-resizing grid (advanced users: this is an instance of QGridLayout). Thus far we have been letting egg auto-position all of our new objects, and it has chosen to put the button at grid position (0,0), the databox at (1,0), and the graph at (0,1). If we do not specify coordinates when placing an object, egg will simply continue incrementing the horizontal position.

But the current positioning drives me up a wall, so let's improve it a bit:

>>> w.place_object(p, 0,1, column_span=2)

This command places the plot at position (0,1) spanning two columns. This looks a little better, but the window still has ugly empty space on the right, so

>>> w.place_object(p, 0,1, column_span=2, alignment=0)

That's better:

Ahhhhhhh...

Note the the default alignmnent is "1" (left justified). "0" means "fill the whole grid space" and "2" means "right justified". The button and the databox buttons are both left justified, and their grid spaces are the same size. Since I'm a bit OC, this bothers me too. Luckily, we can set the relative "stretch" of the egg window's second column to be larger than that of the first:

>>> w.set_column_stretch(1, 10)

Good stuff. The first argument is the column index, the second is a relative "stretch" number. The default value is zero, so the above command makes column 2 fill as much space as possible.

Another positioning solution is to "nest" grids. Try adding egg.gui.GridLayout()'s to your window. They behave the same as a window, minus the window. This allows me to avoid worrying about updating all of my column_span's, etc, as I add new objects to my GUI's.

6. Make the button "record" & plot fake data

Okay, let's finally make this thing do something fake and useful. First, as per pyqtgraph's examples, the plot needs a curve item:

>>> c = egg.pyqtgraph.PlotCurveItem()
>>> p.addItem(c)

Now define a function to generate and plot fake data, and connect it to the button:

>>> import numpy as _n
>>> def get_fake_data(*a):
...   d['x'] = _n.linspace(0,10,100)
...   d['y'] = _n.cos(d['x']) + 0.1*_n.random.rand(100)
...   c.setData(d['x'], d['y'])
... 
>>> b.signal_clicked.connect(get_fake_data)

Start pushing the button, and off you go!

w00t

Notice that the button click is still connected to the original function f. Apparently, signals can be connected to multiple functions.

The way we have structured this (via the databox), saving and loading should work as expected :+1:, but loading will not automatically plot the data. One way to accomplish this is by overwriting d's "post_load" function, which is called directly after loading:

>>> def my_post_load(): c.setData(d['x'], d['y'])
...
>>> d.post_load = my_post_load

The DataboxSaveLoad object has overwritable functions pre_load, post_load, pre_save, and post_save to help you perform automated tasks surrounding these operations. This is a great way to automatically save / load your data taker's settings via the databox's header functionality. Want to view some old data or reproduce the data-taking conditions? Press the load button!

Some higher-level objects

Now that you know some of the basics and low-level stuff, let's look at some of the higher-level / compound objects our lab uses on a regular basis. Start a new command line (or script), and make a window, as before, but then add two tab areas, with one tab each:

from spinmob import egg

# create the window and two tab areas
w     = egg.gui.Window('Hey Mammal II',autosettings_path='w')
tabs1 = w.place_object(egg.gui.TabArea(autosettings_path='tabs1'))
tabs2 = w.place_object(egg.gui.TabArea(autosettings_path='tabs2'), alignment=0)

# add some tabs
t_settings  = tabs1.add_tab("Settings")
t_raw       = tabs2.add_tab("Raw")
t_processed = tabs2.add_tab("Processed")

# show it!
w.show()

Settings!

Well, this is boring so far, although here I've specified autosettings_path for the window and tab areas. In so doing, their settings (i.e. window size, location, active tabs) will be automatically saved to these files whenever their states change, and these settings will be automatically loaded next time the same autosettings_path is specified. Be sure they all have unique names! The settings files all reside in a gui_settings folder of the current working directory.

Also notice the tabs all have little close buttons on them. Clicking these does nothing other than generating the signal signal_tab_close_requested that you can link to your own functionality.

Tree Dictionaries

Let's add a fancy "tree dictionary" to the first tab. This is a simplified interface to pyqtgraph's ParameterTree() object, with databox headers in mind:

settings = t_settings.place_object(egg.gui.TreeDictionary(autosettings_path='settings'))

The tree dictionary behaves a lot like a python dictionary, except for one intentional rigidity: prior to accessing or changing values, you must first explicitly create an entry. Also, if autosettings_path is a unique string, it will remember your settings from run to run. Let's add a "DAQ" setting:

settings.add_parameter('DAQ/Rate', 1000, type='float', step=1000, limits=[1,1e5], suffix='Hz', siPrefix=True)

You should see the following:

More settings!

Notice how the / in the key is interpreted. The key is split by /, "DAQ" is interpreted as a "branch" of the tree, and "Rate" is interpreted as a "leaf". You can nest leaves and branches as you wish using multiple /'s. Play with this!

Also note the keyword arguments. The data type, the step increments, suffix, limits, and whether to display the SI prefixes can be specified. These keyword arguments are just sent to pyqtgraph's ParameterTree() object when creating the leaf. See pyqtgraph's documentation and / or settings._widget for more information and access to low-level commands.

Another common data type we use is a pull-down list, e.g.

settings.add_parameter('DAQ/Pants', type='list', values=['required','optional'])

This should also appear in the window, and hopefully behave in a way that makes sense.

TreeDictionary also has functionality to automatically determine the parameter type, so the same could be achieved with

settings.add_parameter('DAQ/Rate', 1000.0, step=1000, limits=[1,1e5], suffix='Hz', siPrefix=True)
settings.add_parameter('DAQ/Pants', ['required','optional'])

Notice the .0 on 1000.0. Without this, DAQ/Rate would have been an integer.

As alluded to above, once you have created elements in this tree, you can access or set their values by treating settings as a dictionary. For example, from the command line, you could try:

>>> settings['DAQ/Pants'] = 'optional'
>>> settings['DAQ/Pants']
'optional'
>>> settings['DAQ/Rate'] = 500

The window should update as you do this. Another commonly-used data type is a string:

settings.add_parameter('Other/Function', 'cos(t)')

Databox Plotter

Next let's add a "Databox Plotter" object to the second tab area:

d_raw = t_raw.place_object(egg.gui.DataboxPlot('*.raw', autosettings_path='d_raw'), alignment=0)

This object behaves identically to the databox object (see [2. Data Handling](2. Data Handling)), except that there is GUI functionality added on top. So, for example, you can add columns of data and header information just as before:

d_raw['t']  = [1,2,3,4,5]
d_raw['V1'] = [1,2,1,2,1]
d_raw['V2'] = [2,1,2,1,2]
d_raw.h(Time="Pants O'Clock", Amp_Gain="32")

but you can also immediately see the data with the plot() command:

d_raw.plot()

which should produce this:

The thing we ALWAYS use in our lab.

Also notice the two arguments when initially creating the object. The first argument is the default extension for saved / loaded files (see below), and the second is autosettings_path for remembering the widget's settings (behaves identically to that of the window).

On-The-Fly Analysis

Notice how it automatically assumes the first column is the x-data for the remaining columns by default. To see how it arrived at this conclusion, click the "script" button and review the default script box that pops up. This is an area where you can put arbitrary python code to be executed prior to plotting. The only requirements for the script are that

  • It must be a valid python script.
  • It must produce two lists of data arrays, x and y (and optionally ey for error bars), with shapes nominally matched to each other (though you can get away with lazypants tricks like having one x-data set, as shown, or using None for any data set, which just plots versus element index). You can have as many or few plots as you like, regardless of column number. You can run for loops and whatnot.

If there is an error, the script just turns pink and stop working, and showing the error below. The default script was automatically generated from the existing columns of data, but you can also write your own by selecting the edit option in the combo box and start scripting! For example:

Scripted plot.

Notice you can either plot the data or actually manipulate / edit the data in the DataboxPlotter. Stated briefly, anything you can do with the DataboxPlot instance you can do with d in the script. Notice also that I've disabled the Multi button to put all curves on the same plot.

Notes:

  • Scripts also know all about numpy objects and scipy.special function, as shown in the above example.
  • Running these plot scripts will not modify the underlying data, unless you tell it to, as in the first line in the above example.
  • "Link X" controls whether the x-axes of a multi-plot are linked
  • d.plot() will do nothing unless the "Enabled" button is lit

Happy analyzing!

Regions of Interest (ROI's)

When DataboxPlot draws its plots, it also checks an internal variable ROIs, which can, if you so choose, contain a list of pyqtgraph ROIs. Each element in the list will be linked to the corresponding plot (in order), and you can use nested lists to include more than one per plot. So, for example, we could add three ROIs using the following code:

# On some systems, you must add this code BEFORE the first plot() call for some reason.
r1 = egg.pyqtgraph.LinearRegionItem([1.5,3.5])
r2 = egg.pyqtgraph.InfiniteLine(4.2, movable=True)
r3 = egg.pyqtgraph.InfiniteLine(4.2, movable=True)
d_raw.ROIs = [[r1,r2], r3]

which results in the following:

Regions of interest.

IMPORTANT: Currently you must add these ROIs prior to the first plot() call. We're looking into this.

Dark Theme

Work in a laser lab? Monitor headaches? Spinmob now has a dark theme that can greatly reduce eye strain. To switch to dark mode, you need to run the following command once:

s.settings['dark_theme_qt'] = True

Restart the terminal and off you go:

Dark Theme

(This example is the ADALM2000 interface in the McPhysics library.)

Note if you also wish for your plots to have a dark theme, you can use

s.settings['dark_theme_figures'] = True

as well.

Saving / Loading Data

The data can be saved programmatically in the same way as for a databox (see [2. Data Handling](2. Data Handling)), or you can use the buttons. Typically, we also want to stuff all of our settings information into the databox header whenever we take some data (i.e. so that the settings are always linked to the data we take). To do this we use the following command:

settings.send_to_databox_header(d_raw)

usually placed right after we stick the newly-acquired data into d_raw. Conversely, whenever we load file, we also often want to update settings with the loaded header information, for our own information and to prepare the program to take the same kind of data again. To do this we must overload a "dummy" function that is called after every load:

def after_load_file(d): s.update(d.headers, ignore_errors=True)
d_raw.after_load_file = after_load_file

Try implementing these and see what gets dumped to the header. It's fun, we swear.

Finally, you can easily write your program to automatically save the data by enabling the "Auto" button. This will bring up a file save dialog, allowing you to choose a directory and file name. This initially does nothing, but while "Auto" is lit, calling

d.autosave()

in your program will result in the current databox to dump to the file you selected, but with a prepended "counter" number (to the right of the "Auto" button) that increments with each save. Typically we place the autosave() call right after the plot() call (i.e. once the data and header are transferred).

A nice workflow

We find that it is highly effective to layout our programs this way, with several tabs for different stages of real-time analysis, starting from raw data, through FFT's and peak detections, fits, etc. We also find that we do not take a performance hit by simply leaving the plot() calls enabled on all the tabs. Only the visible one seems to actually consume any CPU.

Additionally, it is straightforward to embed any GUI you create with egg (or any Qt widget!) in another GUI using the .add() function of any egg object. For example, you could create a window with several tabs to control several different instruments. Once each instrument has been written, it takes only one line of code to include in your "super" program.

Good times!

Where to go from here

  • At this point I highly recommend looking through the spinmob/egg directory at all the files named "example_..." to see how to do various nifty things. Most of these examples are highly simplified versions of what we use in our lab. mcphysics.instruments also contains some more complicated real-world examples.
  • Note that w.show(block_command_line=True) will prevent you from interacting with the objects from the command line, which is useful when making a script that is double-clicked to run.