Writing a matplotlib/PyQt Graphing Calculator


I'll never forget the first time I saw the TI-89 Titanium graphing calculator. It was at summer camp, I think, and a crowd had gathered around some lucky α-nerd who was demonstrating this new gadget that could do derivatives and integrals and trig analytically, and spit out functions as answers, or expressions with roots and e and π. It was the embodiment of its CISC Motorola 68000 brain, at its zenith. Needless to say, my TI-86 couldn't do that. My heart fluttered and my stomach dropped- it was love at first sight. So when I got home I immediately started making all sorts of (not very) subtle inquiries, only to learn that this Nimbus 2001 currently retailed for A Hundred and Fifty Dollars! And I already had a Nimbus 2000. So I took a job cleaning tables at a local cafe, and a couple days of (sweat+tears+blood+elbow grease) later, I had my very own TI-89 Ti! The world was my oyster. I still remember the first thing I ever typed in:


ii
	e-π/2
	.20788
(try that one on for size...)

Anyways, you can go ahead and cue the sappy music for the obligatory montage: victorious Mathlete competitions, high-stakes final exams, late night pillow talk, yada yada yada...
And then one day Wolfram Alpha hit the scene, and graphing calculators became nearly obsolete overnight. That is, until WA slowly clamped down on allocation of computation time for its nonpaying users. But by the time that the college crowd was starting to get annoyed with the constriction, I already had a svelte institutional subscription to Mathematica... until I didn't. I still have my TI-89 though, sitting on my desk, more than ten years later; I'm fairly certain that it's the single oldest electronic device that I own.


But once you have Matplotlib embedded in PyQt, with a trivial investment of effort you can make any graphing calculator ever manufactured obsolete, without paying a dime... as long as you have a computer. And you can use the mpl/PyQt combo to make your graphing calculator almost arbitrarily powerful (it will hold up to pretty much any end-use except rapid display-rates or 3D rendering, in both of which cases everything is going to start naturally migrating C-wards).

And one thing that it is important to appreciate when writing a graphing calculator app, is that there are as many ways of going about it as there are mathematical snowflakes in Russia... a whole lot. So I am not going to show you how to make an especially ornate or robust graphing calculator; rather, I merely want to provide a skeleton key which you can adopt as a template/inspiration for whatever creative functionality or design suits your imagination, needs, and whims.

Thus, the simplest relevant example will be a single, 2D line plot with the vertical axis as the dependent variable and the horizontal axis independent. Once you have this one down, it will be trivial to expand to radial, parametric, multivariable, or even general analytic-form equations. You could solve systems of the above, or enable multiple selectable/deletable inputs, extend the interface to integro-differential equations, or evaluate and solve regressions from uploadable static or streamed datasets... anything
But before we learn to drive a spaceship, first we have to learn how to float.

Function Entry

The first thing that our app will need is some means of typing in the function that we want to display. And because we read (computer code) from left to right, it would make sense to put our input section on the left, and our output section on the right of the application. So let's add a second layout for our left-side input widgets, and then initialize a container box to store both halves. We will use a QGridLayout stored in an abstract container QWidget for the inputs, for reasons that will become clear momentarily. In this case, our layout section would look like this:

 
        leftBox = QtGui.QGridLayout()
        self.dumbWidget = QtGui.QWidget()
        self.dumbWidget.setFixedWidth(300)
        self.dumbWidget.setLayout(leftBox)
        
        rightBox = QtGui.QVBoxLayout()
        rightBox.addWidget(self.canvas)  # the matplotlib canvas
        rightBox.addWidget(self.ntb)
        
        leftRight = QtGui.QHBoxLayout()
        leftRight.addLayout(self.dumbWidget)
        leftRight.addLayout(rightBox)
        self.main_frame.setLayout(leftRight)
        self.setCentralWidget(self.main_frame)
We still need to define a widget for typing in the function to be graphed though. A QLineEdit should work just fine:
 
	self.funcLabel = QtGui.QLabel("Y(x) = ")
	self.funcEntry = QtGui.QLineEdit("Type your function here")
	self.funcEntry.setCursorPosition(0)

	self.goButton = QtGui.QPushButton("Display!")
It would be nice to also let the user specify the range of x-values over which they want the function to execute, and the number of data points to calculate. These too can be adequately specified via QLineEdits:
 
	self.rangeLoLabel = QtGui.QLabel("From: ")
	self.rangeLoEntry = QtGui.QLineEdit("0.0")
	self.rangeLoEntry.setCursorPosition(0)

	self.rangeHiLabel = QtGui.QLabel(" To: ")
	self.rangeHiEntry = QtGui.QLineEdit("1.0")
	self.rangeHiEntry.setCursorPosition(0)

	self.numSampsLabel = QtGui.QLabel("#Points = ")
	self.numSampsEntry = QtGui.QLineEdit("200")
We still need to lay all of these nice widgets out. There are two reasons that we will be using a wrapped QGridLayout to do this. For starters, it will preserve vertical alignment among laterally positioned widgets, which would not be the case if we used a VBoxLayout with nested HBoxLayouts. To understand the second reason for our choice, we should first appreciate that Qt has to manage a very difficult task in responding to window resize events, since some programmers are going to want very specific behavior in terms of which widgets and layouts get resized in response and which do not, and which retain viewport focus and which do not, whereas other users will just want a magic black box that takes care of all these things automatically in a vaguely reasonable way. And so the layout managers tend to err on the side of "black-boxy", because you can always forego them and specify absolute widget positions explicitly if you are so inclined (though this invariably results in clunky resize-responses...) And so by telling Qt that you want everything to be layed out in a grid, you can essentially divide the layout space into equivalent sectors, ensuring that everything looks more or less like you want it to, and also communicate how much of the grid you want each widget to occupy (the last two arguments to the .addWidget() method); in the one dimensional layouts it takes more effort to keep semi-related buttons from being spread sparsely to "cover the turf" (much like Latex's default handling of short text lines...), or extended to grotesquely wide lengths via the same rationale. By wrapping our QGridLayout in a fixed-size QWidget, on the other hand, we can control our "unit" dimensions more explicitly. So here's an example grid assignment that we could use for our entry widgets:
 
        leftBox = QtGui.QGridLayout()
        leftBox.setHorizontalSpacing(10)
        leftBox.addWidget(self.funcLabel, 0, 0,1 ,1)
        leftBox.addWidget(self.funcEntry, 0, 1, 1, 3)
        leftBox.addWidget(self.rangeLoLabel, 1, 0,1,1)
        leftBox.addWidget(self.rangeLoEntry, 1, 1,1,1)
        leftBox.addWidget(self.rangeHiLabel, 1, 2,1,1)
        leftBox.addWidget(self.rangeHiEntry, 1, 3,1,1)
        leftBox.addWidget(self.numSampsLabel, 2, 0,1,1)
        leftBox.addWidget(self.numSampsEntry, 2, 1,1,1)
        leftBox.addWidget(self.goButton, 3, 0,1,1)

Interpreting the Input

Something it's important to be aware of, is that in order to evaluate an arbitrary string that is typed into self.funcEntry as interpretable code (so that the corresponding Y-value can be calculated), we will need to make use of the eval() method. And so what happens if the user submits something unintelligible like n9a34ga934a3490g03, or even something malicious like os.system("rm -r /") from self.funcEntry ? Clearly, we are going to need some sort of parsing and validation routine to determine the space of possible actions that a "Display!" click can trigger. We can do this by implementing a sort of "bouncer" method, which decides whether a given string is or is not acceptable for evaluation. A fundamental idealogical decision will be whether to default to "friendly", and check for a list of illegal characteristics, or to default to "unfriendly", and only accept inputs meeting some combination of legality criteria. You will have much more freedom with the former approach, and also be much less protected from transcribing any creative viral code injection. On the other hand, a malicious user can't do anything from an eval-exposed GUI that they couldn't do from the command line at the same computer, except maybe crash the GUI in creative fashion, so as long as you're not trying to run this as a web app (for which you would use Javascript, not PyQt), or maybe from a public terminal in a library or something, it's probably not worth your time to obsess over an extremely rigorous/paranoid validation scheme.

That being said, we are not trying to write a systems utility here, we are trying to write a graphing calculator. And so we probably don't want to allow commands making use of libraries which we haven't approved (eg, "math" or "numpy"). We should probably also make various keywords illegal, like for example [break, class, def, del, eval, exec, global, import, open]. And we may want to disallow assignment operators, since we will be using an eval() statement, not an exec(); though we may like to enable comparisons such as '>=' or '=='. Again, how intense you want to be about this really just depends on your disposition, and your projected end-use for your script. In this example, we'll stick with the basics: disallowing unapproved library requests, unapproved assignment operators, and certain reserved keywords.

 
"""Does the longString start with the subString?"""
def startsWith(longString, subString):
    compLength = len(subString)
    if compLength > len(longString):
        return False
    if longString[:compLength] == subString:
        return True
    else:
        return False
"""Does the longString end with the subString?"""
def endsWith(longString, subString):
    compLength = len(subString)
    if compLength > len(longString):
        return False
    if longString[-1*compLength:] == subString:
        return True
    else:
        return False

def makeGraph(self):
    #begin validation
    ValidEval = True
    errorFound = ""
    okLibs=["math", "np"]
    okParams=['key'] # maybe the user will want to use a sort() in their function, which may require a specified key to a lambda fn
    badWords=[" break", " class", " def", " del", " eval", " exec", " global", " import", " open"]
    rawStatement=str(self.funcEntry.text()).strip()
    
    if len(rawStatement) == 0:
        ValidEval = False
        errorFound="no Entry"
    for epos, echar in enumerate(rawStatement):
	#bad library check
        if echar=='.' and epos > 0:
            checkchar=rawStatement[epos-1]
            if checkchar.isalpha():
                libVerified = False
                checkThis = rawStatement[:epos].split(' ')[-1]
                for eLib in okLibs:
                    checkLib = endsWith(checkThis, eLib)
                    if checkLib:
                        libVerified = True
                    
                if not libVerified:
                    ValidEval = False
                    errorFound="Illegal Library request: "+ checkThis
	#bad assignment check
        if echar=='=':
            if (epos == 0 or (epos==len(rawStatement)-1)):
                ValidEval = False
                errorFound = "Bad Syntax, can't use peripheral '='"
            else:    
                checkchar = rawStatement[epos-1]
                if not (  (checkchar=='>') or (checkchar=='<') or (checkchar=='!') or (checkchar=='=') or rawStatement[epos+1]=='='):
                    checkThis = rawStatement[:epos].strip(' ')
                    paramVerified = False
                    for eParam in okParams:
                        checkParam = endsWith(checkThis, eParam)
                        if checkParam:
                            paramVerified = True
                    if not paramVerified:
                        ValidEval = False
                        errorFound = "Illegal assignment operator"
	#bad keyword check
    for badWord in badWords:
        if badWord in rawStatement or startsWith(rawStatement, badWord[1:]):
            ValidEval = False
            errorFound = "Illegal phrase used: " + badWord + " --watch yourself!"

We also need to validate the inputs to the boundary QLineEdits, which we can do by attempting to convert them to ints and then floats. We can then store verified values in a dictionary that we will read from when it comes time to instantiate the Numpy arrays that mpl uses to form our graph. If the creation routine works, then we should display the graph in the same manner as our previous example, and otherwise it would be courteous to provide a MessageBox to the user explaining why their attempt failed. (This is why we track both a ValidEval boolean, and an errorFound empty string, which has the advantage of being equivalent to a boolean False, but also being amenable to len() calls, with the capacity to store arbitrarily informative error messages.

Another (slightly unorthodox) step we can take to empower our user is to assign global scope to our iterated variable (x in this case). This should not represent any sort of security flaw, since any value that x could ever take must first be validated as a legitimate numeric type. The only caveat that accompanies this decision, is that we must remember that x is now global, and avoid using this variable in any other context while a calculation loop is running. The hidden benefit of this approach is that it enables Numpy to iterate over functions of arrays that incorporate x, empowering the user to calculate sums and products over sequences of terms dependent on x, or even determine scalars associated with algorithmically enumerable matrices and tensors. And so, without further ado, the code for our basic example (which can also be obtained from GitHub, in the graphingCalculator tree):

 
from PyQt4 import QtCore, QtGui, Qt
import sys
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt4agg import NavigationToolbar2QTAgg as NavigationToolbar
from matplotlib.backend_bases import key_press_handler
import math
import numpy as np




def startsWith(longString, subString):
    compLength = len(subString)
    if compLength > len(longString):
        return False
    if longString[:compLength] == subString:
        return True
    else:
        return False

def endsWith(longString, subString):
    compLength = len(subString)
    if compLength > len(longString):
        return False
    if longString[-1*compLength:] == subString:
        return True
    else:
        return False


class ApplicationWindow(QtGui.QMainWindow):
    def __init__(self):
        QtGui.QMainWindow.__init__(self)        
        self.initUI()
    def initUI(self):
        self.main_frame = QtGui.QWidget()        
        self.setWindowTitle("Basic mpl/PyQt Graphing Calculator")
        
# Entry widgets and logic        
        self.funcLabel = QtGui.QLabel("y(x) = ")
        self.funcEntry = QtGui.QLineEdit("Type your function here")
        self.funcEntry.setCursorPosition(0)

        self.rangeLoLabel = QtGui.QLabel("From: ")
        self.rangeLoEntry = QtGui.QLineEdit("0.0")
        self.rangeLoEntry.setCursorPosition(0)

        self.rangeHiLabel = QtGui.QLabel(" To: ")
        self.rangeHiEntry = QtGui.QLineEdit("1.0")
        self.rangeHiEntry.setCursorPosition(0)

        self.numSampsLabel = QtGui.QLabel("#Points = ")
        self.numSampsEntry = QtGui.QLineEdit("200")        

        self.goButton = QtGui.QPushButton("Display!")
        self.goButton.clicked.connect(self.makeGraph)

#Output-display widgets and logic        
        self.fig=Figure() 
        self.axes = self.fig.add_subplot(111)
        self.x = np.arange(0.0, 1.0, 0.01)
        self.y = np.cos(2*np.pi*self.x + 5) + 2
        self.axes.plot(self.x, self.y)
        self.canvas=FigureCanvas(self.fig)
        self.canvas.setParent(self.main_frame)
        self.canvas.setFocusPolicy(QtCore.Qt.StrongFocus)
        self.canvas.setFocus()
        self.ntb = NavigationToolbar(self.canvas, self.main_frame)
        self.canvas.mpl_connect('key_press_event', self.on_key_press)


#Layout logic

        leftBox = QtGui.QGridLayout()
        leftBox.setHorizontalSpacing(10)
        leftBox.addWidget(self.funcLabel, 0, 0,1 ,1)
        leftBox.addWidget(self.funcEntry, 0, 1, 1, 3)
        leftBox.addWidget(self.rangeLoLabel, 1, 0,1,1)
        leftBox.addWidget(self.rangeLoEntry, 1, 1,1,1)
        leftBox.addWidget(self.rangeHiLabel, 1, 2,1,1)
        leftBox.addWidget(self.rangeHiEntry, 1, 3,1,1)
        leftBox.addWidget(self.numSampsLabel, 2, 0,1,1)
        leftBox.addWidget(self.numSampsEntry, 2, 1,1,1)
        leftBox.addWidget(self.goButton, 3, 0,1,1)

        self.dumbWidget = QtGui.QWidget()
        self.dumbWidget.setFixedWidth(300)
        self.dumbWidget.setLayout(leftBox)
        
        rightBox = QtGui.QVBoxLayout()
        rightBox.addWidget(self.canvas)  # the matplotlib canvas
        rightBox.addWidget(self.ntb)
        
        leftRight = QtGui.QHBoxLayout()
        leftRight.addWidget(self.dumbWidget)
        leftRight.addLayout(rightBox)
        self.main_frame.setLayout(leftRight)
        self.setCentralWidget(self.main_frame)

    def on_key_press(self, event):
        print('you pressed', event.key)
        key_press_handler(event, self.canvas, self.ntb)
        
        
    """
    Overview of the method: responds to clicks of the 'Display!' button
    1) validates the string in self.funcEntry
    2) validates the Lo, Hi, and Samps Entries
    3) attempts to eval the string within a for-loop over the range defined by domain specs
    
    """
    def makeGraph(self):
        #begin validation
        ValidEval = True
        errorFound = ""
        okLibs=["math"]
        okParams=['key']
        badWords=[" break", " class", " def", " del", " eval", " exec", " global", " import", " open"]
        rawStatement=str(self.funcEntry.text()).strip()
        
        if len(rawStatement) == 0:
            ValidEval = False
            errorFound="no Entry"
        for epos, echar in enumerate(rawStatement):
            if echar=='.' and epos > 0:
                checkchar=rawStatement[epos-1]
                if checkchar.isalpha():
                    libVerified = False
                    checkThis = rawStatement[:epos].split(' ')[-1]
                    for eLib in okLibs:
                        checkLib = endsWith(checkThis, eLib)
                        if checkLib:
                            libVerified = True
                        
                    if not libVerified:
                        ValidEval = False
                        errorFound="Illegal Library request: "+ checkThis
    
            if echar=='=':
                if (epos == 0 or (epos==len(rawStatement)-1)):
                    ValidEval = False
                    errorFound = "Bad Syntax, can't use peripheral '='"
                else:    
                    checkchar = rawStatement[epos-1]
                    if not (  (checkchar=='>') or (checkchar=='<') or (checkchar=='!') or (checkchar=='=') or rawStatement[epos+1]=='='):
                        checkThis = rawStatement[:epos].strip(' ')
                        paramVerified = False
                        for eParam in okParams:
                            checkParam = endsWith(checkThis, eParam)
                            if checkParam:
                                paramVerified = True
                        if not paramVerified:
                            ValidEval = False
                            errorFound = "Illegal assignment operator"
        for badWord in badWords:
            if badWord in rawStatement or startsWith(rawStatement, badWord[1:]):
                ValidEval = False
                errorFound = "Illegal phrase used: " + badWord + " --watch yourself!"

        numType = 'int'
        #OK now we can try to scrape the parameters...
        if ValidEval:
            boundKeys = ["rangeLo", "rangeHi", "numSamples"]
            boundWidgets=[self.rangeLoEntry, self.rangeHiEntry, self.numSampsEntry]
            self.boundDict = {}
            for epos, ewidget in enumerate(boundWidgets):
                rawVal = str(ewidget.text()).strip()
                try:
                    rawInt = int(rawVal)
                    self.boundDict[boundKeys[epos]] = rawInt
                except:
                    try:
                        rawFloat = float(rawVal)
                        self.boundDict[boundKeys[epos]] = rawFloat
                        numType = 'float'
                    except:
                        ValidEval = False
                        errorFound = "illegal non-numeric parameter: " + rawVal
        if ValidEval and not errorFound:
            print "ValidEval for entry:"
            print rawStatement    
            print "boundDict: "
            print self.boundDict
            try:
                self.x = np.linspace(self.boundDict['rangeLo'], self.boundDict['rangeHi'], self.boundDict['numSamples'])
                self.y = np.zeros((self.boundDict['numSamples'],))
                goodEvals = 0
                print 'initial config ok'
		global x
                for ePos, eVal in enumerate(self.x):
                    try:
                        x=eVal
                        self.y[ePos]=eval(rawStatement)
                        goodEvals += 1
                    except:
                        self.y[ePos] = np.nan
                        #Function has to evaluate correctly for the lower bound, 
                        #in order to guard against wasting massive cycles on a syntax error
                        if ePos == 0:
                            print "first eval fails. breaking loop"
                            break
                
                if goodEvals >= 1:
                    print("good Evaluation: "+ str(goodEvals) )
                    self.axes.cla()
                    self.axes.plot(self.x, self.y)                
                    self.canvas.draw()
                else:
                    errorFound = "unconclusive eval statement. check your syntax" 
            except:
                errorFound = "execution error"
                print errorFound
        else:
            print "entry Error:"
            print errorFound
        if errorFound:
            print "generating QMessageBox"
            errorString ='"Cause every little thing; is gonna be alright"... someday. But not today, sorry. \nPlease try a better function.\n Immune response rejection status(es):\n' + errorFound  
            ermg = QtGui.QMessageBox(QtGui.QMessageBox.NoIcon, 'No bueno!', errorString)
            ermg.exec_()
            
            
        
def main():

    qApp = QtGui.QApplication(sys.argv)
    a=ApplicationWindow()
    a.show()
    sys.exit(qApp.exec_())
if __name__ == '__main__':
    main() 
And the result will look something like this!

Adding some Freedom of Choice

When designing a GUI, a nice objective is to give the user as much freedom as possible. For instance, a user may not want to be confined to specifying np.linspaces as their x-dataset. Perhaps they want a fixed spacing between their sample points instead, or they do not want to have to specify an upper bound. We can use our self.boundDict container for boundary condition inputs to make our program "smarter", by adding an entry field for fixed sample spacing, and then autodetecting whether sufficient information has been provided to form a Numpy array. Any three of (self.rangeLoEntry, self.rangeHiEntry, self.numSampsEntry, self.sampSpaceEntry) will suffice to define an x-axis array, but we like to keep the rangeLoEntry fixed so that we can test validated eval() requests for (legal) bad syntax, without wasting cycles as we iterate over the x-axis. And so with the following slight modifications, we can allow our user to specify boundary conditions using any two of the three other parameters that they like!
 
.
.
.
        self.sampSpaceLabel = QtGui.QLabel("Step Size: ")
        self.sampSpaceEntry = QtGui.QLineEdit("")
.
.
.
        leftBox.addWidget(self.sampSpaceLabel, 3, 0,1,1)
        leftBox.addWidget(self.sampSpaceEntry, 3, 1,1,1)
        leftBox.addWidget(self.goButton, 4, 0,1,1)
.
.
.

        #OK now we can try to scrape the parameters...
        numType = 'int'
        if ValidEval:
            boundKeys = ["rangeLo", "rangeHi", "numSamples", "sampleSpacing"]
            boundWidgets=[self.rangeLoEntry, self.rangeHiEntry, self.numSampsEntry, self.sampSpaceEntry]
            self.boundDict = {}
            
            numStrikes = 0
            maxStrikes = 2
            numVerified = 0
            maxVerified = 3
            for epos, ewidget in enumerate(boundWidgets):
                rawVal = str(ewidget.text()).strip()
                try:
                    rawInt = int(rawVal)
                    self.boundDict[boundKeys[epos]] = rawInt
                    numVerified += 1
                except:
                    try:
                        rawFloat = float(rawVal)
                        self.boundDict[boundKeys[epos]] = rawFloat
                        numVerified += 1
                        numType = 'float'
                    except:
                        numStrikes += 1
                        if epos == 0 or numStrikes >= maxStrikes:
                            ValidEval = False
                            errorFound = "Too Many illegal boundary conditions"
                if numVerified >= maxVerified:
                    break

        if ValidEval and not errorFound:
            print "ValidEval for entry:"
            print rawStatement    
            print "boundDict: "
            print self.boundDict
            try:
                
                if "sampleSpacing" in self.boundDict.keys():
                    if "rangeHi" in self.boundDict.keys():
                        self.x = np.arange(self.boundDict['rangeLo'], self.boundDict['rangeHi'], self.boundDict['sampleSpacing'], dtype = numType)
                    elif "numSamples" in self.boundDict.keys():
                        self.x = np.full((self.boundDict['numSamples'],), self.boundDict['rangeLo'], dtype = numType)
                        
                        stepSize = self.boundDict['sampleSpacing']                        
                        for i in range(1,self.x.size):
                            self.x[i] = self.x[i-1] + stepSize
                    else:
                        raise LookupError
                            
                elif "rangeHi" in self.boundDict.keys() and "numSamples" in self.boundDict.keys():
                    self.x = np.linspace(self.boundDict['rangeLo'], self.boundDict['rangeHi'], self.boundDict['numSamples'])
                else:
                    raise LookupError
.
.
.

Auto-generating Data Tables

Last but not least, we would like to be able to generate a table and/or delimited text file containing the raw data corresponding to the mpl plot in our application. This will be invaluable if we need to examine individual data points, or wish to analyze the data at some point in the future. There are two distinct approaches by which we can generate display tables: either we can utilize a tabbed monolithic system, or each table can take the form of an undocked, distinct widget output. While the first option is very useful for certain tasks (and I intend to cover a sample implementation of the scheme in a future tutorial), the second is much more convenient in the situation where we would like to be able to generate lots of data, but don't need the graphical display to persist over the course of multiple "GO!" commands. So we will do our tables "Free Bird"-style for this example. Our strategy consists of defining a new subclass of QWidget, which compounds a QTableWidget and a QPushButton into a single vertical layout, and accepts a Numpy array as an initialization argument. We then need only to stack and transpose our calculated self.x and self.y vectors (since they are stored as a single row, but we would like to display them as columns), and add an instantiation call for our freeTableWidget to every successfully completed makeGraph() call:

 
.
.
.
		if goodEvals >= 1:
                    print("good Evaluation: "+ str(goodEvals) )
                    self.axes.cla()
                    self.axes.plot(self.x, self.y)                
                    self.canvas.draw()
                    
                    print "creating newFreeTable"
                    NPdata = np.vstack((self.x,self.y))
                    NPdata=np.transpose(NPdata)
                    print "data reformatted"
                    newFreeTable = freeTableWidget(NPdata, self)
                    self.liveTables.append(newFreeTable)
                    newFreeTable.show()        
.
.
.
class freeTableWidget(QtGui.QWidget):
    def __init__(self, NPdata, parent=None):
        QtGui.QWidget.__init__(self)
        self.initUI(NPdata, parent)

    def initUI(self, NPdata, parent):
        print "freeTableWidget initialized"
        if parent is not None:
            self.parent = parent
            
        self.NPdata = NPdata
        self.tableSize = self.NPdata.shape

        self.mainTable=QtGui.QTableWidget()
        self.mainTable.setRowCount(self.tableSize[0])
        self.mainTable.setColumnCount(self.tableSize[1])
        print "preparing to fill table"
        for i in range(self.tableSize[1]):
            for j in range(self.tableSize[0]):
                thisVal = self.NPdata[j][i]
                qtwi = QtGui.QTableWidgetItem(str(thisVal))
                self.mainTable.setItem(j, i, qtwi)
        print "table filled"
        self.saveButton = QtGui.QPushButton('Save As')
        self.saveButton.setFixedSize(100,50)
        self.saveButton.clicked.connect(self.saveTable)
        
        vbox = QtGui.QVBoxLayout()
        vbox.addWidget(self.mainTable)
        vbox.addWidget(self.saveButton)
        self.setLayout(vbox)

The mpl navigation window automatically comes with an icon for saving any graph view as an image file (convenient, right?!), but we would also like to be able to save the raw data we have generated as a delimited text file, so that we can load it into any other application that we have written or downloaded. Luckily, Python/PyQt makes this super straightforward to do.

 
    def saveTable(self):
        saveFile=QtGui.QFileDialog.getSaveFileName()
        f=open(saveFile, 'a')
        for erow in range(self.tableSize[0]):
            outstring = str(self.NPdata[erow][0]) + ';'
            for ecol in range(1, self.tableSize[1]):
                outstring += str(self.NPdata[erow][ecol])
                outstring += ';'
            outstring += '\n'
            f.write(outstring)
        f.close()        
Our complete script, again available from the mplEmbed GiHub repository, then, is:

from PyQt4 import QtCore, QtGui, Qt
import sys
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt4agg import NavigationToolbar2QTAgg as NavigationToolbar
from matplotlib.backend_bases import key_press_handler
import math
import numpy as np




def startsWith(longString, subString):
    compLength = len(subString)
    if compLength > len(longString):
        return False
    if longString[:compLength] == subString:
        return True
    else:
        return False

def endsWith(longString, subString):
    compLength = len(subString)
    if compLength > len(longString):
        return False
    if longString[-1*compLength:] == subString:
        return True
    else:
        return False


class ApplicationWindow(QtGui.QMainWindow):
    def __init__(self):
        QtGui.QMainWindow.__init__(self)        
        self.initUI()
    def initUI(self):
        self.main_frame = QtGui.QWidget()        
        self.setWindowTitle("mpl/PyQt Graphing Calculator with data tables!")
        
# Entry widgets and logic        
        self.funcLabel = QtGui.QLabel("y(x) = ")
        self.funcEntry = QtGui.QLineEdit("Type your function here")
        self.funcEntry.setFixedWidth(280)
        self.funcEntry.setCursorPosition(0)

        self.rangeLoLabel = QtGui.QLabel("From: ")
        self.rangeLoEntry = QtGui.QLineEdit("0.0")
        self.rangeLoEntry.setCursorPosition(0)

        self.rangeHiLabel = QtGui.QLabel(" To: ")
        self.rangeHiEntry = QtGui.QLineEdit("1.0")
        self.rangeHiEntry.setCursorPosition(0)

        self.numSampsLabel = QtGui.QLabel("#Points = ")
        self.numSampsEntry = QtGui.QLineEdit("200")     
        
        self.sampSpaceLabel = QtGui.QLabel("Step Size: ")
        self.sampSpaceEntry = QtGui.QLineEdit("")

        self.goButton = QtGui.QPushButton("Display!")
        self.goButton.clicked.connect(self.makeGraph)

#Output-display widgets and logic        
        self.fig=Figure() 
        self.axes = self.fig.add_subplot(111)
        self.x = np.arange(0.0, 1.0, 0.01)
        self.y = np.cos(2*np.pi*self.x + 5) + 2
        self.axes.plot(self.x, self.y)
        self.canvas=FigureCanvas(self.fig)
        self.canvas.setParent(self.main_frame)
        self.canvas.setFocusPolicy(QtCore.Qt.StrongFocus)
        self.canvas.setFocus()
        self.ntb = NavigationToolbar(self.canvas, self.main_frame)
        self.canvas.mpl_connect('key_press_event', self.on_key_press)


#Layout logic

        leftBox = QtGui.QGridLayout()
        leftBox.setHorizontalSpacing(10)
        #self.pushLabel = QtGui.QSpacerItem(200,200)
        leftBox.addWidget(self.funcLabel, 0, 0,1 ,1)
        leftBox.addWidget(self.funcEntry, 0, 1, 1, 3)
        #leftBox.addItem(self.pushLabel, 0,4,3,4)
        leftBox.addWidget(self.rangeLoLabel, 1, 0,1,1)
        leftBox.addWidget(self.rangeLoEntry, 1, 1,1,1)
        leftBox.addWidget(self.rangeHiLabel, 1, 2,1,1)
        leftBox.addWidget(self.rangeHiEntry, 1, 3,1,1)
        leftBox.addWidget(self.numSampsLabel, 2, 0,1,1)
        leftBox.addWidget(self.numSampsEntry, 2, 1,1,1)
        leftBox.addWidget(self.sampSpaceLabel, 3, 0,1,1)
        leftBox.addWidget(self.sampSpaceEntry, 3, 1,1,1)
        leftBox.addWidget(self.goButton, 4, 0,1,1)

        self.dumbWidget = QtGui.QWidget()
        self.dumbWidget.setFixedWidth(400)
        self.dumbWidget.setLayout(leftBox)
        
        rightBox = QtGui.QVBoxLayout()
        rightBox.addWidget(self.canvas)  # the matplotlib canvas
        rightBox.addWidget(self.ntb)
        
        leftRight = QtGui.QHBoxLayout()
        leftRight.addWidget(self.dumbWidget)
        leftRight.addLayout(rightBox)
        self.main_frame.setLayout(leftRight)
        self.setCentralWidget(self.main_frame)
        
        self.liveTables = []

    def on_key_press(self, event):
        print('you pressed', event.key)
        key_press_handler(event, self.canvas, self.ntb)
        
        
    """
    Overview of the method: responds to clicks of the 'Display!' button
    1) validates the string in self.funcEntry
    2) validates the Lo, Hi, and Samps Entries
    3) attempts to eval the string within a for-loop over the range defined by domain specs
    
    """
    def makeGraph(self):
#begin validation
        ValidEval = True
        errorFound = ""
        okLibs=["math", "np"]
        okParams=['key']
        badWords=[" break", " class", " def", " del", " eval", " exec", " global", " import", " open"]
        rawStatement=str(self.funcEntry.text()).strip()
        
        if len(rawStatement) == 0:
            ValidEval = False
            errorFound="no Entry"
        for epos, echar in enumerate(rawStatement):
            if echar=='.' and epos > 0:
                checkchar=rawStatement[epos-1]
                if checkchar.isalpha():
                    libVerified = False
                    checkThis = rawStatement[:epos].split(' ')[-1]
                    for eLib in okLibs:
                        checkLib = endsWith(checkThis, eLib)
                        if checkLib:
                            libVerified = True
                        
                    if not libVerified:
                        ValidEval = False
                        errorFound="Illegal Library request: "+ checkThis

            if echar=='=':
                if (epos == 0 or (epos==len(rawStatement)-1)):
                    ValidEval = False
                    errorFound = "Bad Syntax, can't use peripheral '='"
                else:    
                    checkchar = rawStatement[epos-1]
                    if not (  (checkchar=='>') or (checkchar=='<') or (checkchar=='!') or (checkchar=='=') or rawStatement[epos+1]=='='):
                        checkThis = rawStatement[:epos].strip(' ')
                        paramVerified = False
                        for eParam in okParams:
                            checkParam = endsWith(checkThis, eParam)
                            if checkParam:
                                paramVerified = True
                        if not paramVerified:
                            ValidEval = False
                            errorFound = "Illegal assignment operator"
        for badWord in badWords:
            if badWord in rawStatement or startsWith(rawStatement, badWord[1:]):
                ValidEval = False
                errorFound = "Illegal phrase used: " + badWord + " --watch yourself!"


        #OK now we can try to scrape the parameters...
        numType = 'int'
        if ValidEval:
            boundKeys = ["rangeLo", "rangeHi", "numSamples", "sampleSpacing"]
            boundWidgets=[self.rangeLoEntry, self.rangeHiEntry, self.numSampsEntry, self.sampSpaceEntry]
            self.boundDict = {}
            
            numStrikes = 0
            maxStrikes = 2
            numVerified = 0
            maxVerified = 3
            for epos, ewidget in enumerate(boundWidgets):
                rawVal = str(ewidget.text()).strip()
                try:
                    rawInt = int(rawVal)
                    self.boundDict[boundKeys[epos]] = rawInt
                    numVerified += 1
                except:
                    try:
                        rawFloat = float(rawVal)
                        self.boundDict[boundKeys[epos]] = rawFloat
                        numVerified += 1
                        numType = 'float'
                    except:
                        numStrikes += 1
                        if epos == 0 or numStrikes >= maxStrikes:
                            ValidEval = False
                            errorFound = "Too Many illegal boundary conditions"
                if numVerified >= maxVerified:
                    break

        if ValidEval and not errorFound:
            print "ValidEval for entry:"
            print rawStatement    
            print "boundDict: "
            print self.boundDict
            try:
                
                if "sampleSpacing" in self.boundDict.keys():
                    if "rangeHi" in self.boundDict.keys():
                        self.x = np.arange(self.boundDict['rangeLo'], self.boundDict['rangeHi'], self.boundDict['sampleSpacing'], dtype = numType)
                    elif "numSamples" in self.boundDict.keys():
                        self.x = np.full((self.boundDict['numSamples'],), self.boundDict['rangeLo'], dtype = numType)
                        
                        stepSize = self.boundDict['sampleSpacing']                        
                        for i in range(1,self.x.size):
                            self.x[i] = self.x[i-1] + stepSize
                    else:
                        raise LookupError
                            
                elif "rangeHi" in self.boundDict.keys() and "numSamples" in self.boundDict.keys():
                    self.x = np.linspace(self.boundDict['rangeLo'], self.boundDict['rangeHi'], self.boundDict['numSamples'])
                else:
                    raise LookupError
                
                self.y = np.zeros((self.x.size,))
                goodEvals = 0
                print 'initial config ok'
		global x
                for ePos, eVal in enumerate(self.x):
                    try:
                        x=eVal
                        self.y[ePos]=eval(rawStatement)
                        goodEvals += 1
                    except:
                        self.y[ePos] = np.nan
                        #Function has to evaluate correctly for the lower bound, 
                        #in order to guard against wasting massive cycles on a syntax error
                        if ePos == 0:
                            print "first eval fails. breaking loop"
                            break
                
                if goodEvals >= 1:
                    print("good Evaluation: "+ str(goodEvals) )
                    self.axes.cla()
                    self.axes.plot(self.x, self.y)                
                    self.canvas.draw()
                    
                    print "creating newFreeTable"
                    NPdata = np.vstack((self.x,self.y))
                    NPdata=np.transpose(NPdata)
                    print "data reformatted"
                    newFreeTable = freeTableWidget(NPdata, self)
                    self.liveTables.append(newFreeTable)
                    newFreeTable.show()                    
                    
                else:
                    errorFound = "unconclusive eval statement. check your syntax" 
            except:
                errorFound = "execution error"
                print errorFound
        else:
            print "entry Error:"
            print errorFound
        if errorFound:
            print "generating QMessageBox"
            errorString ='"Cause every little thing; is gonna be alright"... someday. But not today, sorry. \nPlease try a better function.\n Immune response rejection status(es):\n' + errorFound  
            ermg = QtGui.QMessageBox(QtGui.QMessageBox.NoIcon, 'No bueno!', errorString)
            ermg.exec_()
            
            
class freeTableWidget(QtGui.QWidget):
    def __init__(self, NPdata, parent=None):
        QtGui.QWidget.__init__(self)
        self.initUI(NPdata, parent)

    def initUI(self, NPdata, parent):
        print "freeTableWidget initialized"
        if parent is not None:
            self.parent = parent
            
        self.NPdata = NPdata
        self.tableSize = self.NPdata.shape

        self.mainTable=QtGui.QTableWidget()
        self.mainTable.setRowCount(self.tableSize[0])
        self.mainTable.setColumnCount(self.tableSize[1])
        print "preparing to fill table"
        for i in range(self.tableSize[1]):
            for j in range(self.tableSize[0]):
                thisVal = self.NPdata[j][i]
                qtwi = QtGui.QTableWidgetItem(str(thisVal))
                self.mainTable.setItem(j, i, qtwi)
        print "table filled"
        self.saveButton = QtGui.QPushButton('Save As')
        self.saveButton.setFixedSize(100,50)
        self.saveButton.clicked.connect(self.saveTable)
        
        vbox = QtGui.QVBoxLayout()
        vbox.addWidget(self.mainTable)
        vbox.addWidget(self.saveButton)
        self.setLayout(vbox)

    def saveTable(self):
        saveFile=QtGui.QFileDialog.getSaveFileName()
        f=open(saveFile, 'a')
        for erow in range(self.tableSize[0]):
            outstring = str(self.NPdata[erow][0]) + ';'
            for ecol in range(1, self.tableSize[1]):
                outstring += str(self.NPdata[erow][ecol])
                outstring += ';'
            outstring += '\n'
            f.write(outstring)
        f.close()
        

        
def main():

    qApp = QtGui.QApplication(sys.argv)
    a=ApplicationWindow()
    a.show()
    sys.exit(qApp.exec_())
if __name__ == '__main__':
    main()  


As demonstrated below, we can use this application to obtain a graphic representation of the first hundred terms of a Weierstrass function (an infamous little monkey wrench in the history of real analysis and number theory closely related to the Fourier series, which also indirectly contributed to the evolution of transfinite set theory and fractal chaos due to its remarkable property of being everywhere continuous yet nowhere differentiable). To call up such a beast for ourselves, we need merely recite the following one-liner to self.funcEntry:

 
np.sum(np.power(0.5,n)*np.cos(np.pi*x*np.power(8,n)) for n in np.arange(0,100,1))


Running it, we can generate the following outputs:


So now that we have our basic functionality firing on all cylinders (ie, we can display a line plot of any single-parameter, single-line function using the math and Numpy libraries that we are sufficiently creative to concoct), we are free to make whatever mods and extensions we like!
In my case, I used this skeleton as a template for a data-streaming app with a real-time graphic display, which provided a great opportunity to learn firsthand just where the throughput performance limits of matplotlib actually lie... I'll probably post a tutorial for it whenever I get around to writing the documentation for my PyDuino module. You're of course more than welcome to fork either template from Github ...

Best of Luck, and Happy Hacking!


Back to Previous
Back to Code
Go Home