Tutorial
This is a thorough introduction to the basics of puzzlepiece. You can read through this Notebook here, but it’s probably nicer to run it yourself! You can download it from https://github.com/jdranczewski/puzzlepiece/blob/main/docs/source/tutorial.ipynb
You need puzzlepiece and some version of Qt (PySide6 or PyQt6 for example) installed to run this. Note that Anaconda installation comes with PyQt5 already!
pip install puzzlepiece
Other requirements include numpy
and tqdm
.
Docs are at https://puzzlepiece.readthedocs.io/en/stable/index.html - you can also press shift-tab when your cursor is “inside” at method (for example pzp.param.spinb|ox
or pzp.param.spinbox(|
) to bring up the help text for that specific function. Good luck!
[1]:
# Enable the GUI integration for this Notebook
%gui qt
[2]:
# Main GUI framework
import puzzlepiece as pzp
# Plotting framework
import pyqtgraph as pg
# Progress bar library
from tqdm import tqdm
# A way to access Qt Widgets (independent of whether the user has PyQt or PySide installed)
from qtpy import QtWidgets
# Other libraries
import numpy as np
import time
Our first Piece
This one is pretty boring and empty.
[3]:
class Piece(pzp.Piece):
pass
Let’s add it to a Puzzle. A tiny new window should appear. You can close this window when moving on to the next section.
[4]:
puzzle = pzp.Puzzle()
# The Pieces are added on a grid
puzzle.add_piece("piece_name", Piece, row=0, column=0)
# In Qt you need to show a Widget for it to appear
puzzle.show()
Adding params
This Piece will show a text box that you can edit
[5]:
class Piece(pzp.Piece):
def define_params(self):
# You will define your params in here
# The (None) indicates that this param has no getter or setter (we'll get to these)
pzp.param.text(self, "name", "Jakub")(None)
puzzle = pzp.Puzzle()
puzzle.add_piece("piece_name", Piece, 0, 0)
puzzle.show()
Params with getters
Some params can call a function to get a value (intensity from a powermeter, say).
Click the “refresh” button to get a value.
[6]:
class Piece(pzp.Piece):
def define_params(self):
pzp.param.text(self, "name", "Jakub")(None)
# A spinbox is a number input
pzp.param.spinbox(self, "born", 1999)(None)
pzp.param.spinbox(self, "now", 2024)(None)
# This param has a getter - a function to obtain its value
# This is achieved by using `readout` as a "decorator" on a function (spot the @, and lack of (None))
@pzp.param.readout(self, "age")
def age(self):
# This method accesses the other two params to compute an age
return self["now"].value - self["born"].value
puzzle = pzp.Puzzle()
puzzle.add_piece("piece_name", Piece, 0, 0)
puzzle.show()
Params with setters
Some params call a function to set a value (for example the integration time).
Note that the text field gets red when you edit it - this indicates that the text in the box changed, but the setter has not yet been called.
[7]:
class Piece(pzp.Piece):
def define_params(self):
# Notice that we're using `text` as a decorator again - whether this makes the method below a getter or a setter depends on the input type.
# Text boxes and spinboxes have setters by default, readouts and arrays have getters. Check https://puzzlepiece.readthedocs.io/en/stable/puzzlepiece.param.html
# for all available input decorators and their default behaviour
@pzp.param.text(self, "name", "Jakub")
def name(self, value):
# `value` is the new value of the param
print("The user's name is now", value)
pzp.param.spinbox(self, "born", 1999)(None)
pzp.param.spinbox(self, "now", 2024)(None)
@pzp.param.readout(self, "age")
def age(self):
return self["now"].value - self["born"].value
puzzle = pzp.Puzzle()
puzzle.add_piece("piece_name", Piece, 0, 0)
puzzle.show()
The user's name is now Jakub
Params with getters and setters
Some params may have a getter and a setter simultaneously (you can set an integration time, but you can also ask the device what it is).
[8]:
class Piece(pzp.Piece):
def define_params(self):
@pzp.param.text(self, "name", "Jakub")
def name(self, value):
print("The user's name is now", value)
# We need to return the value here to acknowledge that we've set it, otherwise the getter will be called
# to double check it. See https://puzzlepiece.readthedocs.io/en/stable/puzzlepiece.param.html#puzzlepiece.param.BaseParam.set_value
# for the details of this logic.
return value
# Here, we're using the `set_getter` method of the name param to add a getter to it (it already has a setter)
@name.set_getter(self)
def name(self):
return np.random.choice(['James', 'John', 'Robert', 'Michael', 'William', 'David'])
pzp.param.spinbox(self, "born", 1999)(None)
pzp.param.spinbox(self, "now", 2024)(None)
@pzp.param.readout(self, "age")
def age(self):
return self["now"].value - self["born"].value
puzzle = pzp.Puzzle()
puzzle.add_piece("piece_name", Piece, 0, 0)
puzzle.show()
The user's name is now William
Actions
Sometimes you need to do something, like save an image from a camera.
Note that the gretting prints “Hello None” until the name is explicitly set - params with setters hold no value internally until the setter is called.
[9]:
class Piece(pzp.Piece):
def define_params(self):
@pzp.param.text(self, "name", "Jakub")
def name(self, value):
print("The user's name is now", value)
return value
@name.set_getter(self)
def name(self):
return np.random.choice(['James', 'John', 'Robert', 'Michael', 'William', 'David'])
pzp.param.spinbox(self, "born", 1999)(None)
pzp.param.spinbox(self, "now", 2024)(None)
@pzp.param.readout(self, "age")
def age(self):
return self["now"].value - self["born"].value
def define_actions(self):
# we define our actions here, using decorators on the functions
@pzp.action.define(self, "Greet")
def greet(self):
# Note the difference between .value and .get_value()
# .value accesses the interally stored param value, not calling the getter (which would return a random name here)
# .get_value calls the getter if there's one (in this case to calculate the age)
print("Hello", self["name"].value, "your age is", self["age"].get_value())
puzzle = pzp.Puzzle()
puzzle.add_piece("piece_name", Piece, 0, 0)
puzzle.show()
Hello None your age is 25
The user's name is now Jakub
Hello Jakub your age is 25
Accessing params and actions from code
You’ve seen glimpses of this already, but there’s two ways to interact with a Piece. We can click through the GUI, or we can use the API from code to set, get, and run actions.
Keep the Puzzle created below open while you run the subsequent cells and observe how it changes.
[10]:
puzzle = pzp.Puzzle()
puzzle.add_piece("piece1", Piece, 0, 0)
puzzle.add_piece("piece2", Piece, 0, column=1)
puzzle.show()
[11]:
# Note that this will also return the new value
puzzle["piece1"]["name"].set_value("James")
The user's name is now James
[11]:
'James'
[12]:
puzzle["piece2"]["name"].set_value("John")
The user's name is now John
[12]:
'John'
[13]:
# doesn't call the getter
puzzle["piece1"]["name"].value
[13]:
'James'
[14]:
# does call the getter
puzzle["piece1"]["name"].get_value()
[14]:
'William'
[15]:
puzzle["piece2"]["born"].get_value()
[15]:
1999
[16]:
puzzle["piece2"]["born"].set_value(1900)
puzzle["piece2"]["age"].get_value()
[16]:
124
[17]:
for year in range(1900, 1961, 10):
puzzle["piece2"]["born"].set_value(year)
print(puzzle["piece2"]["age"].get_value())
124
114
104
94
84
74
64
[18]:
for year in range(1900, 1961, 10):
puzzle["piece2"]["born"].set_value(year)
print(puzzle["piece2"]["age"].get_value())
# Note that while a function or Notebook cell is running, the Puzzle will only
# update the GUI if you explicitly tell it too
puzzle.process_events()
# delay added to make changes visible
time.sleep(.1)
124
114
104
94
84
74
64
[19]:
puzzle["piece1"].actions["Greet"]()
puzzle["piece2"].actions["Greet"]()
Hello William your age is 25
Hello John your age is 64
Custom layouts
You can make any Qt Layout appear within your Piece. https://www.pythonguis.com/ is a decent primer on how these work. Here’s a TL;DR:
every GUI component in Qt (a button, a text box, a label) is a ‘Widget’
Widgets go into Layouts - the Layout describes how the Widgets are laid out
a Widget is actually a very general concept - any element of your app that’s on screen is probably a Widget. For example, a form can be a Widget that contains multiple input box Widgets. It all nests into multiple layers of Widgets containing other Widgets
a Widget can contain a Layout as well, which is how this nesting is achieved. So a Widget has a Layout, and other Widgets are placed within this Layout
nearly everything in puzzlepiece is secretly a Widget - for example the param objects are Widgets so that they can be displayed inside a Piece
Widgets can have Signals and you can ‘connect’ functions to those signals - the function is then called when the Signal is ‘emitted’. For example, the params in puzzlepiece have a ‘changed’ Signal, which is emitted whenever the param value changes. You can connect functions to this Signal so that they are called each time the param value changes.
[20]:
class RandomPiece(pzp.Piece):
def define_params(self):
pzp.param.spinbox(self, "N", 100)(None)
@pzp.param.array(self, "random")
def random(self):
return np.random.random(self["N"].value)
def custom_layout(self):
# this method should return a QT Layout that will be placed inside the Piece
layout = QtWidgets.QVBoxLayout()
# We create a plot Widget (from pyqtgraph) and add it to the Layout
pw = pg.PlotWidget()
layout.addWidget(pw)
# pyqtgraph thinks of things as "Items" - the plot is an item, the lines within it are Items,
# images are ImageItems, etc - for a list see https://pyqtgraph.readthedocs.io/en/latest/api_reference/graphicsItems/index.html
self.plot = pw.getPlotItem()
# Add an empty line to the plot
self.plot_line = self.plot.plot([], [], symbol='o', symbolSize=3)
def update_plot():
self.plot_line.setData(self["random"].value)
# We connect `update_plot` to a `Signal` here - whenever the value of the `random`
# param changes, update_plot is called to update the plot.
# Click the refresh button next to `random` to see it happen, and change N to see what happens.
self["random"].changed.connect(update_plot)
# for bonus points, we should really do
# self["random"].changed.connect(pzp.threads.CallLater(update_plot))
# which would only update the plot once when the GUI refreshes
return layout
puzzle = pzp.Puzzle()
puzzle.add_piece("piece_name", RandomPiece, 0, 0)
puzzle.show()
Developing your measurement
You can of course just develop your measurement as a Python method to be run from a Notebook. Notice how the GUI updates only once the measurement is done - we’d need to add a puzzle.process_events()
to refresh it explicitly.
[21]:
puzzle = pzp.Puzzle()
puzzle.add_piece("random_numbers", RandomPiece, 0, 0)
puzzle.show()
[22]:
def measure(M):
a = []
for i in tqdm(range(M)):
a.append(puzzle["random_numbers"]["random"].get_value())
time.sleep(.1)
return np.asarray(a)
[23]:
measure(12).shape
100%|██████████████████████████████████████████████████████████████████████████████████| 12/12 [00:01<00:00, 9.69it/s]
[23]:
(12, 100)
You can alternatively put your measurement into a Piece, or have a bunch of Pieces to perform various functions:
[24]:
class Measurement(pzp.Piece):
def define_params(self):
pzp.param.spinbox(self, "M", 500)(None)
pzp.param.checkbox(self, "gui_update", 1)(None)
pzp.param.progress(self, "progress")(None)
pzp.param.text(self, "filename", "")(None)
pzp.param.array(self, "result")(None)
def define_actions(self):
@pzp.action.define(self, "Measure")
def measure(self):
a = []
# Reset the stop flag
self.stop = False
# Indicate progress by using the bar's `iter` method
for i in self["progress"].iter(range(self["M"].value)):
a.append(puzzle["random_numbers"]["random"].get_value())
# Break is STOP pressed
if self.stop:
break
# Update the GUI if set to do that
if self["gui_update"].value:
puzzle.process_events()
result = self["result"].set_value(a)
return result
@pzp.action.define(self, "Save")
def save(self):
# Use `format` to include metadata in the filename
fname = pzp.parse.format(self["filename"].value, self.puzzle)
np.savetxt(
fname,
self["result"].value
)
puzzle.run("prompt:File saved as " + fname)
class Metadata(pzp.Piece):
# By making a Metadata Piece, you decouple the exact metadata you want to save (in the filename
# or wherever) from the Measurement.
def define_params(self):
pzp.param.dropdown(self, "sample", "A")(["A", "B", "C"])
pzp.param.spinbox(self, "angle", 0, v_step=10)(None)
class FilenameHelper(pzp.Piece):
def define_params(self):
@pzp.param.text(self, "filename", "")
def filename(self, value):
self.puzzle["measurement"]["filename"].set_value(value)
puzzle = pzp.Puzzle()
puzzle.add_piece("random_numbers", RandomPiece, 0, 0, rowspan=2)
puzzle.add_piece("measurement", Measurement, 0, 1)
puzzle.add_piece("metadata", Metadata, 1, 1)
puzzle.add_piece("filename", FilenameHelper, 2, 0, colspan=2)
puzzle.show()
puzzle["filename"]["filename"].set_value("test_data_sample{metadata:sample}_{metadata:angle}deg.csv")
[24]:
'test_data_sample{metadata:sample}_{metadata:angle}deg.csv'
Try to Measure and then Save.
Note how
pzp.parse.format
is used to replace the{}
expressions in the filename with values from the metadata PieceThe filename Piece is there mostly to give us a wider textfield compared to the tiny one in the measurement Piece.
Notice how
self["progress"].iter
wraps therange
in the measurement iteration - similar to howtqdm
can normally be used for progress bars (https://tqdm.github.io/)Note how
self.stop
is used to integrate with the built-in STOP button. A result is still saved to theresult
param if you press STOP!Notice how
puzzle.process_events()
is used to make the plot and progress bar update every iteration - the GUI could freeze without that, but the measurement would run a bit faster. Try either option by toggling thegui_update
checkbox before measuring.
My advice generally would be to use simple Notebook functions during the development, where the exact measurement you want to do is not clear-cut and you may want to change things about how exactly it works.
Once you have a well-defined measurement, you can put it in a Piece for repeat use!