]> sigrok.org Git - sigrok-meter.git/commitdiff
Add graphs of measured values.
authorJens Steinhauser <redacted>
Sun, 4 Oct 2015 03:23:36 +0000 (05:23 +0200)
committerJens Steinhauser <redacted>
Sun, 4 Oct 2015 03:23:36 +0000 (05:23 +0200)
datamodel.py
mainwindow.py
multiplotwidget.py [new file with mode: 0755]
qtcompat.py
util.py [new file with mode: 0644]

index ea00278c37b38cf28dfa3540499bcc37903f62b8..d6b1443f5e9962f2cb0811eebca3045d3a780d20 100644 (file)
 ## 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.'''
 
index 7dd6c3a9ced4b81bbad598edc6d57d5eb67fe045..3e07ff7b381fcd21b62a61d204ff8b8f21fd8324 100644 (file)
 ##
 
 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 (executable)
index 0000000..69e871a
--- /dev/null
@@ -0,0 +1,103 @@
+##
+## This file is part of the sigrok-meter project.
+##
+## Copyright (C) 2015 Jens Steinhauser <jens.steinhauser@gmail.com>
+##
+## 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))
index f5c1f19a5de7b3bc65c98d82ee259d5221278055..5cac8ea4eebf106efe6c37cc7afdedd161b3e2c6 100644 (file)
@@ -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 (file)
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 <jens.steinhauser@gmail.com>
+##
+## 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, '')