pyqtgraph: fixing the number of strings displayed on the x-axis when using TimeAxisItem and tickStrings

Viewed 347

I am making a scrolling graph which will plot real-time sensor data, with time on the x axis. I am a bit confused by the behavior of tickStrings.

My code is based on the example below (from here). As the number of points plotted increases, the number of x axis strings varies - sometimes it increases and sometimes it decreases . It stabilizes once the deque is full length and the 'scrolling' begins.

Is it possible to keep the spacing between tick strings the same as the number of plotted points increases? I guess that it might be possible use an approach where blank tick strings are replaced as new data is added, but don't know how to do that.

Edit: An example of what I wish to achieve is here.

import sys
import numpy as np
import datetime
from PyQt5.QtCore import QTime, QTimer
from pyqtgraph.Qt import QtGui
import pyqtgraph as pg
from collections import deque
import time

class TimeAxisItem(pg.AxisItem):
    def __init__(self, *args, **kwargs):
        super(TimeAxisItem, self).__init__(*args, **kwargs)

    def tickStrings(self, values, scale, spacing):
        return [int2dt(value).strftime("%H:%M:%S") for value in values]

def int2dt(ts):
    return(datetime.datetime.fromtimestamp(ts))

class MyApplication(QtGui.QApplication):
    def __init__(self, *args, **kwargs):
        super(MyApplication, self).__init__(*args, **kwargs)
        self.t = QTime()
        self.t.start()

        maxlen = 100
        self.data_x = deque(maxlen=maxlen)
        self.data_y = deque(maxlen=maxlen)

        self.win = pg.GraphicsLayoutWidget()
        self.win.resize(1000,600)
        self.plot = self.win.addPlot(title='Scrolling real-time plot', axisItems={'bottom': TimeAxisItem(
            orientation='bottom')})

        self.curve = self.plot.plot()

        self.tmr = QTimer()
        self.tmr.timeout.connect(self.update)
        self.tmr.start(1000)

        self.y = 100
        self.win.show()

    def update(self):
        x = int(time.time())
        self.y = self.y + np.random.uniform(-1, 1)

        self.data_x.append(x)
        self.data_y.append(self.y)
        time.sleep(2)
        print(self.data_x)
        print(self.data_y)
        self.curve.setData(x=list(self.data_x), y=list(self.data_y))

def main():
    app = MyApplication(sys.argv)
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()

2 Answers

To set constant number of ticks, You have to override tickValues method as well. This method generates tick values and tickStrings method gives these values string representation - conversion to human readable time in Your case.

Here is example of TimeAxisItem You can use in Your code:

class TimeAxisItem(pg.AxisItem):
    nticks = 6

    def __init__(self, *args, **kwargs):
        super(TimeAxisItem, self).__init__(*args, **kwargs)

    def tickStrings(self, values, scale, spacing):
        return [int2dt(value).strftime("%H:%M:%S") for value in values]

    def tickValues(self, minVal, maxVal, size):
        if self.nticks is not None:
            ticks = np.linspace(ceil(minVal), ceil(maxVal), num=self.nticks, endpoint=False)
            return [((1.0, 0), ticks)]
        else:
            return super().tickValues(minVal, maxVal, size)

You can see, that in case nticks is not None, we generate n ticks from values between minVal and maxVal. Pyqtgraph generates ticks from ranges called levels. For our case it's enough to use range (1.0, 0), which is basically saying, that distance between ticks is 1 second with offset 0.

You can define multiple ranges based on what You want with setTickSpacing. For example to have ticks for every hour, every minute, every 5 seconds, You can set it to setTickSpacing(levels=[(3600, 0), (60, 0), (5, 0)]).
This will produce tick for every 5 seconds, if Your Δx is less than 60 seconds. Tick for every minute if Δx is less than one hour. And then tick for every hour otherwise.

This is crucial knowledge to understand how ticks are working.

Since question got updated with more details, here is more specific answer to it. I kept previous answer to help someone in case of having fixed number of ticks displayed.

To address exactly what You want, You need to first set tick frequency to 1 per second. This can be done by setting self.setTickSpacing(levels=[(1, 0)]). In Your example You have update time one minute, in that case You want to use self.setTickSpacing(levels=[(60, 0)]).

Second part of the problem is rotation of ticks by -90 degrees. Rotation is not part of the AxisItem implementation. So we have to reimplement drawPicture method. Do the rotation and translation of the tick. Also to have 'enough' height, we must set self.fixedHeight = 150 to fit long tick string .

Constant spacing between ticks is achieved by setting fixed range of axis X. To achieve scrolling effect, we have to hide first ticks without any data attached to it. This is done with ticks = [(1.0, ticks[0][1][self.hide_ticks:])] line.

It's important to note, that my solution fits You specific situation. Otherwise one must play with fixedHeight and rotation part of drawPicture method. Also maxlen for Your queue must be lowered to like 10, so that ticks won't overlap.

Here is full code:

import datetime
import sys
import time
from collections import deque

import numpy as np
import pyqtgraph as pg
from PyQt5.QtCore import QTime, QTimer
from pyqtgraph import debug as debug
from pyqtgraph.Qt import QtGui


class TimeAxisItem(pg.AxisItem):
    hide_ticks = 0

    def __init__(self, *args, **kwargs):
        super(TimeAxisItem, self).__init__(*args, **kwargs)
        # Paint tick every 1 second
        self.setTickSpacing(levels=[(1, 0)])
        # Paint tick every 1 minute
        # self.setTickSpacing(levels=[(60, 0)])
        # Set fixed tick height
        self.fixedHeight = 150

    def tickStrings(self, values, scale, spacing):
        return [int2dt(value).strftime("%Y-%m-%d %H:%M:%S") for value in values]

    def drawPicture(self, p, axisSpec, tickSpecs, textSpecs):

        profiler = debug.Profiler()

        p.setRenderHint(p.RenderHint.Antialiasing, False)
        p.setRenderHint(p.RenderHint.TextAntialiasing, True)

        ## draw long line along axis
        pen, p1, p2 = axisSpec
        p.setPen(pen)
        p.drawLine(p1, p2)
        # p.translate(0.5,0)  ## resolves some damn pixel ambiguity

        ## draw ticks
        for pen, p1, p2 in tickSpecs:
            p.setPen(pen)
            p.drawLine(p1, p2)
        profiler('draw ticks')

        # Draw all text
        if self.style['tickFont'] is not None:
            p.setFont(self.style['tickFont'])
        p.setPen(self.textPen())
        bounding = self.boundingRect().toAlignedRect()
        p.setClipRect(bounding)
        for rect, flags, text in textSpecs:
            p.save()  # save the painter state
            p.translate(rect.center())  # move coordinate system to center of text rect
            p.rotate(-90)  # rotate text
            p.translate(-rect.center())  # revert coordinate system
            p.translate(-65, 0)  # Move rotated tick down by 65 pixels
            p.drawText(rect, int(flags), text)
            p.restore()  # restore the painter state

        profiler('draw text')

    def tickValues(self, minVal, maxVal, size):
        if minVal == 0:
            return []
        else:
            ticks = super().tickValues(minVal, maxVal, size)
            ticks = [(1.0, ticks[0][1][self.hide_ticks:])]
            return ticks


def int2dt(ts):
    return (datetime.datetime.fromtimestamp(ts))


class MyApplication(QtGui.QApplication):
    def __init__(self, *args, **kwargs):
        super(MyApplication, self).__init__(*args, **kwargs)
        self.t = QTime()
        self.t.start()

        maxlen = 10
        self.data_x = deque(maxlen=maxlen)
        self.data_y = deque(maxlen=maxlen)
        self.win = pg.GraphicsLayoutWidget()
        self.win.resize(1000, 600)
        self.axis_x = TimeAxisItem(orientation='bottom')
        self.plot = self.win.addPlot(title='Scrolling real-time plot', axisItems={'bottom': self.axis_x})
        self.curve = self.plot.plot()

        self.tmr = QTimer()
        self.tmr.timeout.connect(self.update)
        self.tmr.start(1000)

        self.y = 100
        self.win.show()

    def update(self):
        x = int(time.time())
        self.y = self.y + np.random.uniform(-1, 1)

        self.data_x.append(x)
        self.data_y.append(self.y)
        time.sleep(1)

        # Set fixed range
        self.plot.setXRange(self.data_x[0] - self.data_x.maxlen + len(self.data_x),
                            self.data_x[0] + len(self.data_x) - 1, padding=0)
        # Hide ticks without data attached to it, to achieve scrolling effect
        self.axis_x.hide_ticks = self.data_x.maxlen - len(self.data_x)
        self.curve.setData(x=list(self.data_x), y=list(self.data_y))


def main():
    app = MyApplication(sys.argv)
    sys.exit(app.exec_())


if __name__ == '__main__':
    main()
Related