Skip to content

Instantly share code, notes, and snippets.

@cpascual
Last active March 12, 2024 19:24
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save cpascual/cdcead6c166e63de2981bc23f5840a98 to your computer and use it in GitHub Desktop.
Save cpascual/cdcead6c166e63de2981bc23f5840a98 to your computer and use it in GitHub Desktop.
An example on how to use a timestamp-based axis with pyqtgraph. This file has been adapted from taurus_pyqtgraph: https://github.com/taurus-org/taurus_pyqtgraph/blob/master/taurus_pyqtgraph/dateaxisitem.py
#!/usr/bin/env python
#############################################################################
#
# This file was adapted from Taurus TEP17, but all taurus dependencies were
# removed so that it works with just pyqtgraph
#
# Just run it and play with the zoom to see how the labels and tick positions
# automatically adapt to the shown range
#
#############################################################################
# http://taurus-scada.org
#
# Copyright 2011 CELLS / ALBA Synchrotron, Bellaterra, Spain
#
# Taurus is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Taurus is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with Taurus. If not, see <http://www.gnu.org/licenses/>.
#
#############################################################################
"""
This module provides date-time aware axis
"""
__all__ = ["DateAxisItem"]
import numpy
from pyqtgraph import AxisItem
from datetime import datetime, timedelta
from time import mktime
class DateAxisItem(AxisItem):
"""
A tool that provides a date-time aware axis. It is implemented as an
AxisItem that interpretes positions as unix timestamps (i.e. seconds
since 1970).
The labels and the tick positions are dynamically adjusted depending
on the range.
It provides a :meth:`attachToPlotItem` method to add it to a given
PlotItem
"""
# Max width in pixels reserved for each label in axis
_pxLabelWidth = 80
def __init__(self, *args, **kwargs):
AxisItem.__init__(self, *args, **kwargs)
self._oldAxis = None
def tickValues(self, minVal, maxVal, size):
"""
Reimplemented from PlotItem to adjust to the range and to force
the ticks at "round" positions in the context of time units instead of
rounding in a decimal base
"""
maxMajSteps = int(size/self._pxLabelWidth)
dt1 = datetime.fromtimestamp(minVal)
dt2 = datetime.fromtimestamp(maxVal)
dx = maxVal - minVal
majticks = []
if dx > 63072001: # 3600s*24*(365+366) = 2 years (count leap year)
d = timedelta(days=366)
for y in range(dt1.year + 1, dt2.year):
dt = datetime(year=y, month=1, day=1)
majticks.append(mktime(dt.timetuple()))
elif dx > 5270400: # 3600s*24*61 = 61 days
d = timedelta(days=31)
dt = dt1.replace(day=1, hour=0, minute=0,
second=0, microsecond=0) + d
while dt < dt2:
# make sure that we are on day 1 (even if always sum 31 days)
dt = dt.replace(day=1)
majticks.append(mktime(dt.timetuple()))
dt += d
elif dx > 172800: # 3600s24*2 = 2 days
d = timedelta(days=1)
dt = dt1.replace(hour=0, minute=0, second=0, microsecond=0) + d
while dt < dt2:
majticks.append(mktime(dt.timetuple()))
dt += d
elif dx > 7200: # 3600s*2 = 2hours
d = timedelta(hours=1)
dt = dt1.replace(minute=0, second=0, microsecond=0) + d
while dt < dt2:
majticks.append(mktime(dt.timetuple()))
dt += d
elif dx > 1200: # 60s*20 = 20 minutes
d = timedelta(minutes=10)
dt = dt1.replace(minute=(dt1.minute // 10) * 10,
second=0, microsecond=0) + d
while dt < dt2:
majticks.append(mktime(dt.timetuple()))
dt += d
elif dx > 120: # 60s*2 = 2 minutes
d = timedelta(minutes=1)
dt = dt1.replace(second=0, microsecond=0) + d
while dt < dt2:
majticks.append(mktime(dt.timetuple()))
dt += d
elif dx > 20: # 20s
d = timedelta(seconds=10)
dt = dt1.replace(second=(dt1.second // 10) * 10, microsecond=0) + d
while dt < dt2:
majticks.append(mktime(dt.timetuple()))
dt += d
elif dx > 2: # 2s
d = timedelta(seconds=1)
majticks = range(int(minVal), int(maxVal))
else: # <2s , use standard implementation from parent
return AxisItem.tickValues(self, minVal, maxVal, size)
L = len(majticks)
if L > maxMajSteps:
majticks = majticks[::int(numpy.ceil(float(L) / maxMajSteps))]
return [(d.total_seconds(), majticks)]
def tickStrings(self, values, scale, spacing):
"""Reimplemented from PlotItem to adjust to the range"""
ret = []
if not values:
return []
if spacing >= 31622400: # 366 days
fmt = "%Y"
elif spacing >= 2678400: # 31 days
fmt = "%Y %b"
elif spacing >= 86400: # = 1 day
fmt = "%b/%d"
elif spacing >= 3600: # 1 h
fmt = "%b/%d-%Hh"
elif spacing >= 60: # 1 m
fmt = "%H:%M"
elif spacing >= 1: # 1s
fmt = "%H:%M:%S"
else:
# less than 2s (show microseconds)
# fmt = '%S.%f"'
fmt = '[+%fms]' # explicitly relative to last second
for x in values:
try:
t = datetime.fromtimestamp(x)
ret.append(t.strftime(fmt))
except ValueError: # Windows can't handle dates before 1970
ret.append('')
return ret
def attachToPlotItem(self, plotItem):
"""Add this axis to the given PlotItem
:param plotItem: (PlotItem)
"""
self.setParentItem(plotItem)
viewBox = plotItem.getViewBox()
self.linkToView(viewBox)
self._oldAxis = plotItem.axes[self.orientation]['item']
self._oldAxis.hide()
plotItem.axes[self.orientation]['item'] = self
pos = plotItem.axes[self.orientation]['pos']
plotItem.layout.addItem(self, *pos)
self.setZValue(-1000)
def detachFromPlotItem(self):
"""Remove this axis from its attached PlotItem
(not yet implemented)
"""
raise NotImplementedError() # TODO
if __name__ == '__main__':
import time
import sys
import pyqtgraph as pg
from PyQt4 import QtGui
app = QtGui.QApplication([])
w = pg.PlotWidget()
# Add the Date-time axis
axis = DateAxisItem(orientation='bottom')
axis.attachToPlotItem(w.getPlotItem())
# plot some random data with timestamps in the last hour
now = time.time()
timestamps = numpy.linspace(now - 3600, now, 100)
w.plot(x=timestamps, y=numpy.random.rand(100), symbol='o')
w.show()
sys.exit(app.exec_())
@lcmcninch
Copy link

This is (almost) exactly what I needed. Saved me a ton of time, thanks!

@queezz
Copy link

queezz commented Mar 5, 2020

Very nice! Such axis should be added naively to pyqtgraph. While there is a PR with similar capabilities, it is old and yet not merged: pyqtgraph/pyqtgraph#74

This code is a nice workaround.

@crayxt
Copy link

crayxt commented Mar 12, 2020

Awesome! Although the X axis is gone if you zoom out such as min value of X axis reaches UNIX epoch start. Then you would need to reset the view to get X axis back.

@cpascual
Copy link
Author

@crayxt:

Although the X axis is gone if you zoom out such as min value of X axis reaches UNIX epoch start

Hi, I could not reproduce it (at least using PyQt5)

@crayxt
Copy link

crayxt commented Mar 12, 2020

@crayxt:

Although the X axis is gone if you zoom out such as min value of X axis reaches UNIX epoch start

Hi, I could not reproduce it (at least using PyQt5)

I tried with timestamps = numpy.linspace(now - 3600*24*365*10, now, 100) to have bigger time period and scrolled to zoom out, when the left-hand side reaches below 1970, axis is gone. When zooming in, in appears again.
PyQt5 is from fresh conda environment.

@frodo4fingers
Copy link

i so like this! thanks!!
...for me it throws an error though

QGridLayoutEngine::addItem: Cell (3, 1) already taken

when using. so i added two lines to attachToPlotItem():

# remove the old item to not get message from QGridLayoutEngine
old_item = plotItem.layout.itemAt(*pos)
plotItem.layout.removeItem(old_item)
# add new one

right before addItem is called.
maybe this can be of use.. 😃

@younesyhp
Copy link

thank you you saved ma time

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment