--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/eric6/MicroPython/MicroPythonGraphWidget.py Tue Jul 16 20:12:53 2019 +0200 @@ -0,0 +1,250 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the MicroPython graph widget. +""" + +from __future__ import unicode_literals + +from collections import deque +import bisect + +from PyQt5.QtCore import pyqtSignal, pyqtSlot +from PyQt5.QtGui import QPainter +from PyQt5.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, QToolButton, QSizePolicy, QSpacerItem +) +from PyQt5.QtChart import QChartView, QChart, QLineSeries, QValueAxis + +import UI.PixmapCache + + +class MicroPythonGraphWidget(QWidget): + """ + Class implementing the MicroPython graph widget. + + @signal dataFlood emitted to indicate, that too much data is received + """ + dataFlood = pyqtSignal() + + def __init__(self, parent=None): + """ + Constructor + + @param parent reference to the parent widget + @type QWidget + """ + super(MicroPythonGraphWidget, self).__init__(parent) + + self.__layout = QHBoxLayout() + self.__layout.setContentsMargins(0, 0, 0, 0) + self.setLayout(self.__layout) + + self.__chartView = QChartView(self) + self.__chartView.setSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Expanding) + self.__layout.addWidget(self.__chartView) + + self.__buttonsLayout = QVBoxLayout() + self.__buttonsLayout.setContentsMargins(0, 0, 0, 0) + self.__layout.addLayout(self.__buttonsLayout) + + self.__saveButton = QToolButton(self) + self.__saveButton.setIcon(UI.PixmapCache.getIcon("fileSave")) + self.__saveButton.setToolTip(self.tr("Press to save the raw data")) + self.__saveButton.clicked.connect(self.on_saveButton_clicked) + self.__buttonsLayout.addWidget(self.__saveButton) + + spacerItem = QSpacerItem(20, 20, QSizePolicy.Minimum, + QSizePolicy.Expanding) + self.__buttonsLayout.addItem(spacerItem) + + # holds the data to be checked for plotable data + self.__inputBuffer = [] + # holds the raw data + self.__rawData = [] + + self.__maxX = 100 + self.__maxY = 1000 + self.__flooded = False # flag indicating a data flood + + self.__data = [deque([0] * self.__maxX)] + self.__series = [QLineSeries()] + + # Y-axis ranges + self.__yRanges = [1, 5, 10, 25, 50, 100, 250, 500, 1000] + + # setup the chart + self.__chart = QChart() + self.__chart.legend().hide() + self.__chart.addSeries(self.__series[0]) + self.__axisX = QValueAxis() + self.__axisX.setRange(0, self.__maxX) + self.__axisX.setLabelFormat("time") + self.__axisY = QValueAxis() + self.__axisY.setRange(-self.__maxY, self.__maxY) + self.__axisY.setLabelFormat("%d") + self.__chart.setAxisX(self.__axisX, self.__series[0]) + self.__chart.setAxisY(self.__axisY, self.__series[0]) + self.__chartView.setChart(self.__chart) + self.__chartView.setRenderHint(QPainter.Antialiasing) + + @pyqtSlot(bytes) + def processData(self, data): + """ + Public slot to process the raw data. + + It takes raw bytes, checks the data for a valid tuple of ints or + floats and adds the data to the graph. If the the length of the bytes + data is greater than 1024 then a dataFlood signal is emitted to ensure + eric can take action to remain responsive. + + @param data raw data received from the connected device via the main + device widget + @type bytes + """ + # flooding guard + if self.__flooded: + return + + if len(data) > 1024: + self.__flooded = True + self.dataFlood.emit() + return + + data = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n") + self.__inputBuffer.append(data) + + # check if the data contains a Python tuple containing numbers (int + # or float) on a single line + inputBytes = b"".join(self.__inputBuffer) + lines = inputBytes.splitlines(True) + for line in lines: + if not line.endswith(b"\n"): + # incomplete line (last line); skip it + continue + + line = line.strip() + if line.startswith(b"(") and line.endswith(b")"): + # it may be a tuple we are interested in + rawValues = [val.strip() for val in line[1:-1].split(b",")] + values = [] + for raw in rawValues: + try: + values.append(int(raw)) + # ok, it is an integer + continue + except ValueError: + # test for a float + pass + try: + values.append(float(raw)) + except ValueError: + # it is not an int or float, ignore it + continue + if values: + self.__addData(tuple(values)) + + self.__inputBuffer = [] + if lines[-1] and not lines[-1].endswith(b"\n"): + # Append any left over bytes for processing next time data is + # received. + self.__inputBuffer.append(lines[-1]) + + def __addData(self, values): + """ + Private method to add a tuple of values to the graph. + + It ensures there are the required number of line series, adds the data + to the line series and updates the range of the chart so the chart + displays nicely. + + @param values tuple containing the data to be added + @type tuple of int or float + """ + # store incoming data to be able to dump it as CSV upon request + self.__rawData.append(values) + + # check number of incoming values and adjust line series accordingly + if len(values) != len(self.__series): + valuesLen = len(values) + seriesLen = len(self.__series) + if valuesLen > seriesLen: + # add a nwe line series + for _index in range(valuesLen - seriesLen): + newSeries = QLineSeries() + self.__chart.addSeries(newSeries) + self.__chart.setAxisX(self.__axisX, newSeries) + self.__chart.setAxisY(self.__axisY, newSeries) + self.__series.append(newSeries) + self.__data.append(deque([0] * self.__maxX)) + else: + # remove obsolete line series + for oldSeries in self.__series[valuesLen:]: + self.__chart.removeSeries(oldSeries) + self.__series = self.__series[:valuesLen] + self.__data = self.__data[:valuesLen] + + # add the new values to the display and compute the maximum range + maxRanges = [] + for index, value in enumerate(values): + self.__data[index].appendleft(value) + maxRanges.append(max([max(self.__data[index]), + abs(min(self.__data[index]))])) + if len(self.__data[index]) > self.__maxX: + self.__data[index].pop() + + # re-scale the y-axis + maxYRange = max(maxRanges) + yRange = bisect.bisect_left(self.__yRanges, maxYRange) + if yRange < len(self.__yRanges): + self.__maxY = self.__yRanges[yRange] + elif maxYRange > self.__maxY: + self.__maxY += self.__maxY + elif maxYRange < self.__maxY / 2: + self.__maxY /= 2 + self.__axisY.setRange(-self.__maxY, self.__maxY) + + # ensure that floats are used to label the y-axis if the range is small + if self.__maxY <= 5: + self.__axisY.setLabelFormat("%2.2f") + else: + self.__axisY.setLabelFormat("%d") + + # update the line series + for index, series in enumerate(self.__series): + series.clear() + xyValues = [] + for x in range(self.__maxX): + value = self.__data[index][self.__maxX - 1 - x] + xyValues.append((x, value)) + for xy in xyValues: + series.append(*xy) + + @pyqtSlot() + def on_saveButton_clicked(self): + """ + Private slot to save the raw data to a CSV file. + """ + self.saveData() + + def hasData(self): + """ + Public method to check, if the chart contains some valid data. + + @return flag indicating valid data + @rtype bool + """ + # TODO: not implemented yet + + def saveData(self): + """ + Public method to save the dialog's raw data. + + @return flag indicating success + @rtype bool + """ + # TODO: not implemented yet