From: Jens Steinhauser Date: Sun, 4 Oct 2015 03:23:36 +0000 (+0200) Subject: Add graphs of measured values. X-Git-Url: https://sigrok.org/gitaction?a=commitdiff_plain;h=f76b9df824276da861bbbdde658deb8130ea8541;p=sigrok-meter.git Add graphs of measured values. --- diff --git a/datamodel.py b/datamodel.py index ea00278..d6b1443 100644 --- a/datamodel.py +++ b/datamodel.py @@ -18,8 +18,11 @@ ## Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA ## +import collections import qtcompat import sigrok.core as sr +import time +import util QtCore = qtcompat.QtCore QtGui = qtcompat.QtGui @@ -28,53 +31,24 @@ class MeasurementDataModel(QtGui.QStandardItemModel): '''Model to hold the measured values.''' '''Role used to identify and find the item.''' - _idRole = QtCore.Qt.UserRole + 1 + idRole = QtCore.Qt.UserRole + 1 '''Role used to store the device vendor and model.''' descRole = QtCore.Qt.UserRole + 2 + '''Role used to store past samples.''' + samplesRole = QtCore.Qt.UserRole + 3 + def __init__(self, parent): super(self.__class__, self).__init__(parent) # Use the description text to sort the items for now, because the - # _idRole holds tuples, and using them to sort doesn't work. + # idRole holds tuples, and using them to sort doesn't work. self.setSortRole(MeasurementDataModel.descRole) # Used in 'format_value()' to check against. self.inf = float('inf') - def format_unit(self, u): - units = { - sr.Unit.VOLT: 'V', - sr.Unit.AMPERE: 'A', - sr.Unit.OHM: u'\u03A9', - sr.Unit.FARAD: 'F', - sr.Unit.KELVIN: 'K', - sr.Unit.CELSIUS: u'\u00B0C', - sr.Unit.FAHRENHEIT: u'\u00B0F', - sr.Unit.HERTZ: 'Hz', - sr.Unit.PERCENTAGE: '%', - # sr.Unit.BOOLEAN - sr.Unit.SECOND: 's', - sr.Unit.SIEMENS: 'S', - sr.Unit.DECIBEL_MW: 'dBu', - sr.Unit.DECIBEL_VOLT: 'dBV', - # sr.Unit.UNITLESS - sr.Unit.DECIBEL_SPL: 'dB', - # sr.Unit.CONCENTRATION - sr.Unit.REVOLUTIONS_PER_MINUTE: 'rpm', - sr.Unit.VOLT_AMPERE: 'VA', - sr.Unit.WATT: 'W', - sr.Unit.WATT_HOUR: 'Wh', - sr.Unit.METER_SECOND: 'm/s', - sr.Unit.HECTOPASCAL: 'hPa', - sr.Unit.HUMIDITY_293K: '%rF', - sr.Unit.DEGREE: u'\u00B0', - sr.Unit.HENRY: 'H' - } - - return units.get(u, '') - def format_mqflags(self, mqflags): if sr.QuantityFlag.AC in mqflags: return 'AC' @@ -105,7 +79,7 @@ class MeasurementDataModel(QtGui.QStandardItemModel): # Find the correct item in the model. for row in range(self.rowCount()): item = self.item(row) - rid = item.data(MeasurementDataModel._idRole) + rid = item.data(MeasurementDataModel.idRole) rid = tuple(rid) # PySide returns a list. if uid == rid: return item @@ -115,8 +89,9 @@ class MeasurementDataModel(QtGui.QStandardItemModel): device.vendor, device.model, channel.name) item = QtGui.QStandardItem() - item.setData(uid, MeasurementDataModel._idRole) + item.setData(uid, MeasurementDataModel.idRole) item.setData(desc, MeasurementDataModel.descRole) + item.setData(collections.defaultdict(list), MeasurementDataModel.samplesRole) self.appendRow(item) self.sort(0) return item @@ -130,13 +105,19 @@ class MeasurementDataModel(QtGui.QStandardItemModel): value, unit, mqflags = data value_str = self.format_value(value) - unit_str = self.format_unit(unit) + unit_str = util.format_unit(unit) mqflags_str = self.format_mqflags(mqflags) # The display role is a tuple containing the value and the unit/flags. disp = (value_str, ' '.join([unit_str, mqflags_str])) item.setData(disp, QtCore.Qt.DisplayRole) + # The samples role is a dictionary that contains the old samples for each unit. + # Should be trimmed periodically, otherwise it grows larger and larger. + sample = (time.time(), value) + d = item.data(MeasurementDataModel.samplesRole) + d[unit].append(sample) + class MultimeterDelegate(QtGui.QStyledItemDelegate): '''Delegate to show the data items from a MeasurementDataModel.''' diff --git a/mainwindow.py b/mainwindow.py index 7dd6c3a..3e07ff7 100644 --- a/mainwindow.py +++ b/mainwindow.py @@ -20,13 +20,17 @@ ## import datamodel +import multiplotwidget import os.path import qtcompat import samplingthread import textwrap +import time +import util QtCore = qtcompat.QtCore QtGui = qtcompat.QtGui +pyqtgraph = qtcompat.pyqtgraph class EmptyMessageListView(QtGui.QListView): '''List view that shows a message if the model im empty.''' @@ -48,6 +52,12 @@ class EmptyMessageListView(QtGui.QListView): class MainWindow(QtGui.QMainWindow): '''The main window of the application.''' + # Number of seconds that the plots display. + BACKLOG = 10 + + # Update interval of the plots in milliseconds. + UPDATEINTERVAL = 100 + def __init__(self, context, drivers): super(self.__class__, self).__init__() @@ -103,8 +113,86 @@ class MainWindow(QtGui.QMainWindow): self.listView.setUniformItemSizes(True) self.listView.setMinimumSize(self.delegate.sizeHint()) - self.setCentralWidget(self.listView) + self.plotwidget = multiplotwidget.MultiPlotWidget(self) + + # Maps from 'unit' to the corresponding plot. + self._plots = {} + # Maps from '(plot, device)' to the corresponding curve. + self._curves = {} + + self.splitter = QtGui.QSplitter(QtCore.Qt.Horizontal); + self.splitter.addWidget(self.listView) + self.splitter.addWidget(self.plotwidget) + self.splitter.setStretchFactor(0, 0) + self.splitter.setStretchFactor(1, 1) + + self.setCentralWidget(self.splitter) self.centralWidget().setContentsMargins(0, 0, 0, 0) + self.resize(800, 500) + + self.startTimer(MainWindow.UPDATEINTERVAL) + + def _getPlot(self, unit): + '''Looks up or creates a new plot for 'unit'.''' + + if unit in self._plots: + return self._plots[unit] + + # create a new plot for the unit + plot = self.plotwidget.addPlot() + plot.yaxis.setLabel(util.quantity_from_unit(unit), units=util.format_unit(unit)) + plot.view.setXRange(-MainWindow.BACKLOG, 0, update=False) + plot.view.setYRange(-1, 1) + plot.view.enableAutoRange(axis=pyqtgraph.ViewBox.YAxis) + # lock to the range calculated by the view using additional padding, + # looks nicer this way + r = plot.view.viewRange() + plot.view.setLimits(xMin=r[0][0], xMax=r[0][1]) + + self._plots[unit] = plot + return plot + + def _getCurve(self, plot, deviceID): + '''Looks up or creates a new curve for '(plot, deviceID)'.''' + + key = (id(plot), deviceID) + if key in self._curves: + return self._curves[key] + + # create a new curve + curve = pyqtgraph.PlotDataItem() + plot.view.addItem(curve) + + self._curves[key] = curve + return curve + + def timerEvent(self, event): + '''Periodically updates all graphs.''' + + for row in range(self.model.rowCount()): + idx = self.model.index(row, 0) + deviceID = self.model.data(idx, datamodel.MeasurementDataModel.idRole) + sampledict = self.model.data(idx, datamodel.MeasurementDataModel.samplesRole) + for unit in sampledict: + self._updatePlot(deviceID, unit, sampledict[unit]) + + def _updatePlot(self, deviceID, unit, samples): + '''Updates the curve of device 'deviceID' and 'unit' with 'samples'.''' + + plot = self._getPlot(unit) + curve = self._getCurve(plot, deviceID) + + now = time.time() + + # remove old samples + l = now - MainWindow.BACKLOG + while samples and samples[0][0] < l: + samples.pop(0) + + xdata = [s[0] - now for s in samples] + ydata = [s[1] for s in samples] + + curve.setData(xdata, ydata) def closeEvent(self, event): self.thread.stop() diff --git a/multiplotwidget.py b/multiplotwidget.py new file mode 100755 index 0000000..69e871a --- /dev/null +++ b/multiplotwidget.py @@ -0,0 +1,103 @@ +## +## This file is part of the sigrok-meter project. +## +## Copyright (C) 2015 Jens Steinhauser +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program 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 General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with this program; if not, write to the Free Software +## Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +## + +import qtcompat + +QtCore = qtcompat.QtCore +QtGui = qtcompat.QtGui +pyqtgraph = qtcompat.pyqtgraph + +# black foreground on white background +pyqtgraph.setConfigOption('background', 'w') +pyqtgraph.setConfigOption('foreground', 'k') + +class MultiPlotItem(pyqtgraph.GraphicsWidget): + + class Plot: + def __init__(self, view, xaxis, yaxis): + self.view = view + self.xaxis = xaxis + self.yaxis = yaxis + + def __init__(self, parent=None): + pyqtgraph.GraphicsWidget.__init__(self, parent) + + self.layout = QtGui.QGraphicsGridLayout() + self.layout.setContentsMargins(10, 10, 10, 1) + self.layout.setHorizontalSpacing(0) + self.layout.setVerticalSpacing(0) + self.setLayout(self.layout) + + self._plots = [] + + for i in range(2): + self.layout.setColumnPreferredWidth(i, 0) + self.layout.setColumnMinimumWidth(i, 0) + self.layout.setColumnSpacing(i, 0) + + self.layout.setColumnStretchFactor(0, 0) + self.layout.setColumnStretchFactor(1, 100) + + def addPlot(self): + row = self.layout.rowCount() + + view = pyqtgraph.ViewBox(parent=self) + if self._plots: + view.setXLink(self._plots[-1].view) + self.layout.addItem(view, row, 1) + + yaxis = pyqtgraph.AxisItem(parent=self, orientation='left') + yaxis.linkToView(view) + yaxis.setGrid(255) + self.layout.addItem(yaxis, row, 0, QtCore.Qt.AlignRight) + + xaxis = pyqtgraph.AxisItem(parent=self, orientation='bottom') + xaxis.linkToView(view) + xaxis.setGrid(255) + self.layout.addItem(xaxis, row + 1, 1) + + for i in range(row, row + 2): + self.layout.setRowPreferredHeight(i, 0) + self.layout.setRowMinimumHeight(i, 0) + self.layout.setRowSpacing(i, 0) + + self.layout.setRowStretchFactor(row, 100) + self.layout.setRowStretchFactor(row + 1, 0) + + p = MultiPlotItem.Plot(view, xaxis, yaxis) + self._plots.append(p) + return p + +class MultiPlotWidget(pyqtgraph.GraphicsView): + '''Widget that aligns multiple plots on top of each other. + + (The built in classes fail at doing this correctly when the axis grow, + just try zooming in the "GraphicsLayout" or the "Linked View" examples.)''' + + def __init__(self, parent=None): + pyqtgraph.GraphicsView.__init__(self, parent) + + self.multiPlotItem = MultiPlotItem() + self.setCentralItem(self.multiPlotItem) + + for m in [ + 'addPlot' + ]: + setattr(self, m, getattr(self.multiPlotItem, m)) diff --git a/qtcompat.py b/qtcompat.py index f5c1f19..5cac8ea 100644 --- a/qtcompat.py +++ b/qtcompat.py @@ -50,3 +50,9 @@ def load_modules(force_pyside): global QtGui QtCore = _QtCore QtGui = _QtGui + + import pyqtgraph as _pyqtgraph + import pyqtgraph.dockarea as _pyqtgraph_dockarea + global pyqtgraph + pyqtgraph = _pyqtgraph + pyqtgraph.dockarea = _pyqtgraph_dockarea diff --git a/util.py b/util.py new file mode 100644 index 0000000..458d05d --- /dev/null +++ b/util.py @@ -0,0 +1,85 @@ +## +## This file is part of the sigrok-meter project. +## +## Copyright (C) 2015 Jens Steinhauser +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program 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 General Public License for more details. +## +## You should have received a copy of the GNU General Public License +## along with this program; if not, write to the Free Software +## Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +## + +import sigrok.core as sr + +def format_unit(u): + units = { + sr.Unit.VOLT: 'V', + sr.Unit.AMPERE: 'A', + sr.Unit.OHM: u'\u03A9', + sr.Unit.FARAD: 'F', + sr.Unit.KELVIN: 'K', + sr.Unit.CELSIUS: u'\u00B0C', + sr.Unit.FAHRENHEIT: u'\u00B0F', + sr.Unit.HERTZ: 'Hz', + sr.Unit.PERCENTAGE: '%', + # sr.Unit.BOOLEAN + sr.Unit.SECOND: 's', + sr.Unit.SIEMENS: 'S', + sr.Unit.DECIBEL_MW: 'dBm', + sr.Unit.DECIBEL_VOLT: 'dBV', + # sr.Unit.UNITLESS + sr.Unit.DECIBEL_SPL: 'dB', + # sr.Unit.CONCENTRATION + sr.Unit.REVOLUTIONS_PER_MINUTE: 'rpm', + sr.Unit.VOLT_AMPERE: 'VA', + sr.Unit.WATT: 'W', + sr.Unit.WATT_HOUR: 'Wh', + sr.Unit.METER_SECOND: 'm/s', + sr.Unit.HECTOPASCAL: 'hPa', + sr.Unit.HUMIDITY_293K: '%rF', + sr.Unit.DEGREE: u'\u00B0', + sr.Unit.HENRY: 'H' + } + + return units.get(u, '') + +def quantity_from_unit(u): + quantities = { + sr.Unit.VOLT: 'Voltage', + sr.Unit.AMPERE: 'Current', + sr.Unit.OHM: 'Resistance', + sr.Unit.FARAD: 'Capacity', + sr.Unit.KELVIN: 'Temperature', + sr.Unit.CELSIUS: 'Temperature', + sr.Unit.FAHRENHEIT: 'Temperature', + sr.Unit.HERTZ: 'Frequency', + sr.Unit.PERCENTAGE: 'Duty Cycle', + sr.Unit.BOOLEAN: 'Continuity', + sr.Unit.SECOND: 'Time', + sr.Unit.SIEMENS: 'Conductance', + sr.Unit.DECIBEL_MW: 'Power Ratio', + sr.Unit.DECIBEL_VOLT: 'Voltage Ratio', + sr.Unit.UNITLESS: 'Unitless Quantity', + sr.Unit.DECIBEL_SPL: 'Sound Pressure', + sr.Unit.CONCENTRATION: 'Concentration', + sr.Unit.REVOLUTIONS_PER_MINUTE: 'Revolutions', + sr.Unit.VOLT_AMPERE: 'Apparent Power', + sr.Unit.WATT: 'Power', + sr.Unit.WATT_HOUR: 'Energy', + sr.Unit.METER_SECOND: 'Velocity', + sr.Unit.HECTOPASCAL: 'Pressure', + sr.Unit.HUMIDITY_293K: 'Humidity', + sr.Unit.DEGREE: 'Angle', + sr.Unit.HENRY: 'Inductance' + } + + return quantities.get(u, '')