5

I'm really having a hard time to understand how to use Threads in PyQt. I made a simple example of what I would like to do in my UI. In the code you can see below, I want the user to enter a stock ticker (you can enter "bby", "goog" or "v" for example) and plot the value of the stock over a certain period. The thing is in more complex Ui or for a long period of time the UI freeze while the plot is beeing updated. So I made a "Plotter" class that update the plot when it receives a certain signal(overriding Qthread.run apparently was not the right way you're doing it wrong). I'd like to make this "Plotter" run in another thread than the main.

As soon as I uncomment the thread lines the program stops working. I've tried to move the launch of the new thread and also the "connect" but nothing is working. I think I'm not understanding well how Qthread works even after reading the documentation and looking at the examples on the Qt website.

If any af you know how to do this it would help a lot! (I'm working with Python 3.5 and PyQt5)

from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
from matplotlib.axes._subplots import Axes
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
import sys
from datetime import datetime, timedelta
import time
import quandl


class MyMplCanvas(FigureCanvas):
    """Ultimately, this is a QWidget (as well as a FigureCanvasAgg, etc.)."""
    send_fig = pyqtSignal(Axes, str, name="send_fig")

    def __init__(self, parent=None):
        self.fig = Figure()
        self.axes = self.fig.add_subplot(111)

        # We want the axes cleared every time plot() is called
        self.axes.hold(False)

        FigureCanvas.__init__(self, self.fig)
        self.setParent(parent)

        FigureCanvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding)
        FigureCanvas.updateGeometry(self)

    def update_plot(self, axes):
        self.axes = axes
        self.draw()

class MainWindow(QMainWindow):
    send_fig = pyqtSignal(Axes, str, name="send_fig")

    def __init__(self):
        super().__init__()

        self.main_widget = QWidget(self)
        self.myplot = MyMplCanvas(self.main_widget)
        self.editor = QLineEdit()
        self.display = QLabel("Vide")

        self.layout = QGridLayout(self.main_widget)
        self.layout.addWidget(self.editor)
        self.layout.addWidget(self.display)
        self.layout.addWidget(self.myplot)

        self.main_widget.setFocus()
        self.setCentralWidget(self.main_widget)

        self.move(500, 500)
        self.show()

        self.editor.returnPressed.connect(self.updatePlot)

        self.plotter = Plotter()
        self.send_fig.connect(self.plotter.replot)

        self.plotter.return_fig.connect(self.myplot.update_plot)


    def updatePlot(self):
        ticker = self.editor.text()
        self.editor.clear()
        self.display.setText(ticker)

        # thread = QThread()
        # self.plotter.moveToThread(thread)

        self.send_fig.emit(self.myplot.axes, ticker)

        # thread.start()


class Plotter(QObject):
    return_fig = pyqtSignal(Axes)

    @pyqtSlot(Axes, str)
    def replot(self, axes, ticker):  # A slot takes no params
        print(ticker)
        d = datetime.today() - timedelta(weeks=52)  # data from 1week ago
        data = quandl.get("YAHOO/"+ticker+".6", start_date=d.strftime("%d-%m-%Y"), end_date=time.strftime("%d-%m-%Y"))
        axes.plot(data)
        self.return_fig.emit(axes)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = MainWindow()
    sys.exit(app.exec_())
BillyBoom
  • 191
  • 2
  • 14
  • Your code is not thread safe. You cannot make matplotlib (or any Qt GUI) calls from a secondary thread. You can fetch the data in a thread, but you'll need to send it back to the main thread for plotting by emitting a custom signal (so return the data for plotting rather than the axes object you are returning now) – three_pineapples Dec 15 '16 at 08:55

1 Answers1

3

The first problem is that you lose the reference to thread once it's started. To keep a reference use a class variable, i.e. self.thread instead of thread.

Next, the thread has to be started before doing anything. So you need to put self.thread.start() in front of the signal emission.

Now, it would work already, but a next problem occurs once you want to start a new thread. So, you need to first kill the old one. Since the old Plotter would then be homeless, a solution is to create a new Plotter as well as a new thread each time you want to plot. This is the way the solution below works.
Alternatively, you could also always use the same plotter and thread. The only thing to remember is that there is always exactly one worker (plotter) and one thread, if you delete one of them, the other is sad.

In order to test it, I needed to change some small things, like using PyQt4 instead of 5 and replace the data generation. Here is the working code.

from PyQt4.QtCore import *
from PyQt4.QtGui import *
from matplotlib.axes._subplots import Axes
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
import sys
from datetime import datetime, timedelta
import numpy as np



class MyMplCanvas(FigureCanvas):
    """Ultimately, this is a QWidget (as well as a FigureCanvasAgg, etc.)."""
    send_fig = pyqtSignal(Axes, str, name="send_fig")

    def __init__(self, parent=None):
        self.fig = Figure()
        self.axes = self.fig.add_subplot(111)

        # We want the axes cleared every time plot() is called
        self.axes.hold(False)

        FigureCanvas.__init__(self, self.fig)
        self.setParent(parent)

        FigureCanvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding)
        FigureCanvas.updateGeometry(self)

    def update_plot(self, axes):
        self.axes = axes
        self.draw()

class MainWindow(QMainWindow):
    send_fig = pyqtSignal(Axes, str, name="send_fig")

    def __init__(self):
        super(MainWindow, self).__init__()

        self.main_widget = QWidget(self)
        self.myplot = MyMplCanvas(self.main_widget)
        self.editor = QLineEdit()
        self.display = QLabel("Vide")

        self.layout = QGridLayout(self.main_widget)
        self.layout.addWidget(self.editor)
        self.layout.addWidget(self.display)
        self.layout.addWidget(self.myplot)

        self.main_widget.setFocus()
        self.setCentralWidget(self.main_widget)

        self.move(500, 500)
        self.show()

        self.editor.returnPressed.connect(self.updatePlot)

        # plotter and thread are none at the beginning
        self.plotter = None 
        self.thread = None



    def updatePlot(self):
        ticker = self.editor.text()
        self.editor.clear()
        self.display.setText(ticker)

        # if there is already a thread running, kill it first
        if self.thread != None and self.thread.isRunning():
            self.thread.terminate()

        # initialize plotter and thread
        # since each plotter needs its own thread
        self.plotter = Plotter()
        self.thread = QThread()
        # connect signals
        self.send_fig.connect(self.plotter.replot)
        self.plotter.return_fig.connect(self.myplot.update_plot)
        #move to thread and start
        self.plotter.moveToThread(self.thread)
        self.thread.start()
        # start the plotting
        self.send_fig.emit(self.myplot.axes, ticker)



class Plotter(QObject):
    return_fig = pyqtSignal(Axes)

    @pyqtSlot(Axes, str)
    def replot(self, axes, ticker):  # A slot takes no params
        print(ticker)
        d = datetime.today() - timedelta(weeks=52)  # data from 1week ago
        # do some random task
        data = np.random.rand(10000,10000)
        axes.plot(data.mean(axis=1))
        self.return_fig.emit(axes)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = MainWindow()
    sys.exit(app.exec_())

Here is a solution for the second option mentionned, i.e. create a single worker and a thread and use those throughout the program's runtime.

from PyQt4.QtCore import *
from PyQt4.QtGui import *
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
import sys
import numpy as np



class MyMplCanvas(FigureCanvas):

    def __init__(self, parent=None):
        self.fig = Figure()
        self.axes = self.fig.add_subplot(111)
        # plot empty line 
        self.line, = self.axes.plot([],[], color="orange")

        FigureCanvas.__init__(self, self.fig)
        self.setParent(parent)

        FigureCanvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding)
        FigureCanvas.updateGeometry(self)


class MainWindow(QMainWindow):
    send_fig = pyqtSignal(str)

    def __init__(self):
        super(MainWindow, self).__init__()

        self.main_widget = QWidget(self)
        self.myplot = MyMplCanvas(self.main_widget)
        self.editor = QLineEdit()
        self.display = QLabel("Vide")

        self.layout = QGridLayout(self.main_widget)
        self.layout.addWidget(self.editor)
        self.layout.addWidget(self.display)
        self.layout.addWidget(self.myplot)

        self.main_widget.setFocus()
        self.setCentralWidget(self.main_widget)
        self.show()

        # plotter and thread are none at the beginning
        self.plotter = Plotter()
        self.thread = QThread()

        # connect signals
        self.editor.returnPressed.connect(self.start_update)
        self.send_fig.connect(self.plotter.replot)
        self.plotter.return_fig.connect(self.plot)
        #move to thread and start
        self.plotter.moveToThread(self.thread)
        self.thread.start()

    def start_update(self):
        ticker = self.editor.text()
        self.editor.clear()
        self.display.setText(ticker)
        # start the plotting
        self.send_fig.emit(ticker)


    # Slot receives data and plots it
    def plot(self, data):
        # plot data
        self.myplot.line.set_data([np.arange(len(data)), data])
        # adjust axes
        self.myplot.axes.set_xlim([0,len(data) ])
        self.myplot.axes.set_ylim([ data.min(),data.max() ])
        self.myplot.draw()


class Plotter(QObject):
    return_fig = pyqtSignal(object)

    @pyqtSlot(str)
    def replot(self, ticker):
        print(ticker)
        # do some random task
        data = np.random.rand(10000,10000)
        data = data.mean(axis=1)
        self.return_fig.emit(data)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    win = MainWindow()
    sys.exit(app.exec_())
ImportanceOfBeingErnest
  • 321,279
  • 53
  • 665
  • 712
  • Thank you! Your code does work and does want I want, but it seems like the threads never end. I added a print(True) in the if statement and the program goes into that loop each time you enter a ticker (exept the first time). Also, if you quickly enter 2 tickers, the plot will stop updating forever. – BillyBoom Dec 15 '16 at 16:46
  • Also, the use of terminate is not recommended in the documentation. Maybe the good solution is to do the second alternative you proposed, but I'm not sure how to implement it. – BillyBoom Dec 15 '16 at 16:51
  • Updated with a solution for the second option. This new solution is also thread-safe. – ImportanceOfBeingErnest Dec 15 '16 at 22:14
  • @ImportanceOfBeingErnest is it possible to move .draw() to a qthread too? depending on the complexity of the chart, this function takes a lot of time. Or maybe qthread draw_artist() for all children, and only call FigureCanvas.update() in the main thread? – harbun Apr 07 '17 at 09:48
  • 1
    I don't think it's possible to run a GUI distributed over two different threads. Since the figure canvas is part of the GUI, it must be drawn inside the main thread. But you may just try it out and see at which point it fails. What is surely not possible is to draw parts of the figure in different threads. – ImportanceOfBeingErnest Apr 07 '17 at 10:02
  • `[...] if you delete one of them, the other is sad.` funny but helpful. – deponovo Oct 27 '21 at 11:40