|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2019 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the MicroPython graph widget. |
|
8 """ |
|
9 |
|
10 from __future__ import unicode_literals |
|
11 |
|
12 from collections import deque |
|
13 import bisect |
|
14 |
|
15 from PyQt5.QtCore import pyqtSignal, pyqtSlot |
|
16 from PyQt5.QtGui import QPainter |
|
17 from PyQt5.QtWidgets import ( |
|
18 QWidget, QHBoxLayout, QVBoxLayout, QToolButton, QSizePolicy, QSpacerItem |
|
19 ) |
|
20 from PyQt5.QtChart import QChartView, QChart, QLineSeries, QValueAxis |
|
21 |
|
22 import UI.PixmapCache |
|
23 |
|
24 |
|
25 class MicroPythonGraphWidget(QWidget): |
|
26 """ |
|
27 Class implementing the MicroPython graph widget. |
|
28 |
|
29 @signal dataFlood emitted to indicate, that too much data is received |
|
30 """ |
|
31 dataFlood = pyqtSignal() |
|
32 |
|
33 def __init__(self, parent=None): |
|
34 """ |
|
35 Constructor |
|
36 |
|
37 @param parent reference to the parent widget |
|
38 @type QWidget |
|
39 """ |
|
40 super(MicroPythonGraphWidget, self).__init__(parent) |
|
41 |
|
42 self.__layout = QHBoxLayout() |
|
43 self.__layout.setContentsMargins(0, 0, 0, 0) |
|
44 self.setLayout(self.__layout) |
|
45 |
|
46 self.__chartView = QChartView(self) |
|
47 self.__chartView.setSizePolicy( |
|
48 QSizePolicy.Expanding, QSizePolicy.Expanding) |
|
49 self.__layout.addWidget(self.__chartView) |
|
50 |
|
51 self.__buttonsLayout = QVBoxLayout() |
|
52 self.__buttonsLayout.setContentsMargins(0, 0, 0, 0) |
|
53 self.__layout.addLayout(self.__buttonsLayout) |
|
54 |
|
55 self.__saveButton = QToolButton(self) |
|
56 self.__saveButton.setIcon(UI.PixmapCache.getIcon("fileSave")) |
|
57 self.__saveButton.setToolTip(self.tr("Press to save the raw data")) |
|
58 self.__saveButton.clicked.connect(self.on_saveButton_clicked) |
|
59 self.__buttonsLayout.addWidget(self.__saveButton) |
|
60 |
|
61 spacerItem = QSpacerItem(20, 20, QSizePolicy.Minimum, |
|
62 QSizePolicy.Expanding) |
|
63 self.__buttonsLayout.addItem(spacerItem) |
|
64 |
|
65 # holds the data to be checked for plotable data |
|
66 self.__inputBuffer = [] |
|
67 # holds the raw data |
|
68 self.__rawData = [] |
|
69 |
|
70 self.__maxX = 100 |
|
71 self.__maxY = 1000 |
|
72 self.__flooded = False # flag indicating a data flood |
|
73 |
|
74 self.__data = [deque([0] * self.__maxX)] |
|
75 self.__series = [QLineSeries()] |
|
76 |
|
77 # Y-axis ranges |
|
78 self.__yRanges = [1, 5, 10, 25, 50, 100, 250, 500, 1000] |
|
79 |
|
80 # setup the chart |
|
81 self.__chart = QChart() |
|
82 self.__chart.legend().hide() |
|
83 self.__chart.addSeries(self.__series[0]) |
|
84 self.__axisX = QValueAxis() |
|
85 self.__axisX.setRange(0, self.__maxX) |
|
86 self.__axisX.setLabelFormat("time") |
|
87 self.__axisY = QValueAxis() |
|
88 self.__axisY.setRange(-self.__maxY, self.__maxY) |
|
89 self.__axisY.setLabelFormat("%d") |
|
90 self.__chart.setAxisX(self.__axisX, self.__series[0]) |
|
91 self.__chart.setAxisY(self.__axisY, self.__series[0]) |
|
92 self.__chartView.setChart(self.__chart) |
|
93 self.__chartView.setRenderHint(QPainter.Antialiasing) |
|
94 |
|
95 @pyqtSlot(bytes) |
|
96 def processData(self, data): |
|
97 """ |
|
98 Public slot to process the raw data. |
|
99 |
|
100 It takes raw bytes, checks the data for a valid tuple of ints or |
|
101 floats and adds the data to the graph. If the the length of the bytes |
|
102 data is greater than 1024 then a dataFlood signal is emitted to ensure |
|
103 eric can take action to remain responsive. |
|
104 |
|
105 @param data raw data received from the connected device via the main |
|
106 device widget |
|
107 @type bytes |
|
108 """ |
|
109 # flooding guard |
|
110 if self.__flooded: |
|
111 return |
|
112 |
|
113 if len(data) > 1024: |
|
114 self.__flooded = True |
|
115 self.dataFlood.emit() |
|
116 return |
|
117 |
|
118 data = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n") |
|
119 self.__inputBuffer.append(data) |
|
120 |
|
121 # check if the data contains a Python tuple containing numbers (int |
|
122 # or float) on a single line |
|
123 inputBytes = b"".join(self.__inputBuffer) |
|
124 lines = inputBytes.splitlines(True) |
|
125 for line in lines: |
|
126 if not line.endswith(b"\n"): |
|
127 # incomplete line (last line); skip it |
|
128 continue |
|
129 |
|
130 line = line.strip() |
|
131 if line.startswith(b"(") and line.endswith(b")"): |
|
132 # it may be a tuple we are interested in |
|
133 rawValues = [val.strip() for val in line[1:-1].split(b",")] |
|
134 values = [] |
|
135 for raw in rawValues: |
|
136 try: |
|
137 values.append(int(raw)) |
|
138 # ok, it is an integer |
|
139 continue |
|
140 except ValueError: |
|
141 # test for a float |
|
142 pass |
|
143 try: |
|
144 values.append(float(raw)) |
|
145 except ValueError: |
|
146 # it is not an int or float, ignore it |
|
147 continue |
|
148 if values: |
|
149 self.__addData(tuple(values)) |
|
150 |
|
151 self.__inputBuffer = [] |
|
152 if lines[-1] and not lines[-1].endswith(b"\n"): |
|
153 # Append any left over bytes for processing next time data is |
|
154 # received. |
|
155 self.__inputBuffer.append(lines[-1]) |
|
156 |
|
157 def __addData(self, values): |
|
158 """ |
|
159 Private method to add a tuple of values to the graph. |
|
160 |
|
161 It ensures there are the required number of line series, adds the data |
|
162 to the line series and updates the range of the chart so the chart |
|
163 displays nicely. |
|
164 |
|
165 @param values tuple containing the data to be added |
|
166 @type tuple of int or float |
|
167 """ |
|
168 # store incoming data to be able to dump it as CSV upon request |
|
169 self.__rawData.append(values) |
|
170 |
|
171 # check number of incoming values and adjust line series accordingly |
|
172 if len(values) != len(self.__series): |
|
173 valuesLen = len(values) |
|
174 seriesLen = len(self.__series) |
|
175 if valuesLen > seriesLen: |
|
176 # add a nwe line series |
|
177 for _index in range(valuesLen - seriesLen): |
|
178 newSeries = QLineSeries() |
|
179 self.__chart.addSeries(newSeries) |
|
180 self.__chart.setAxisX(self.__axisX, newSeries) |
|
181 self.__chart.setAxisY(self.__axisY, newSeries) |
|
182 self.__series.append(newSeries) |
|
183 self.__data.append(deque([0] * self.__maxX)) |
|
184 else: |
|
185 # remove obsolete line series |
|
186 for oldSeries in self.__series[valuesLen:]: |
|
187 self.__chart.removeSeries(oldSeries) |
|
188 self.__series = self.__series[:valuesLen] |
|
189 self.__data = self.__data[:valuesLen] |
|
190 |
|
191 # add the new values to the display and compute the maximum range |
|
192 maxRanges = [] |
|
193 for index, value in enumerate(values): |
|
194 self.__data[index].appendleft(value) |
|
195 maxRanges.append(max([max(self.__data[index]), |
|
196 abs(min(self.__data[index]))])) |
|
197 if len(self.__data[index]) > self.__maxX: |
|
198 self.__data[index].pop() |
|
199 |
|
200 # re-scale the y-axis |
|
201 maxYRange = max(maxRanges) |
|
202 yRange = bisect.bisect_left(self.__yRanges, maxYRange) |
|
203 if yRange < len(self.__yRanges): |
|
204 self.__maxY = self.__yRanges[yRange] |
|
205 elif maxYRange > self.__maxY: |
|
206 self.__maxY += self.__maxY |
|
207 elif maxYRange < self.__maxY / 2: |
|
208 self.__maxY /= 2 |
|
209 self.__axisY.setRange(-self.__maxY, self.__maxY) |
|
210 |
|
211 # ensure that floats are used to label the y-axis if the range is small |
|
212 if self.__maxY <= 5: |
|
213 self.__axisY.setLabelFormat("%2.2f") |
|
214 else: |
|
215 self.__axisY.setLabelFormat("%d") |
|
216 |
|
217 # update the line series |
|
218 for index, series in enumerate(self.__series): |
|
219 series.clear() |
|
220 xyValues = [] |
|
221 for x in range(self.__maxX): |
|
222 value = self.__data[index][self.__maxX - 1 - x] |
|
223 xyValues.append((x, value)) |
|
224 for xy in xyValues: |
|
225 series.append(*xy) |
|
226 |
|
227 @pyqtSlot() |
|
228 def on_saveButton_clicked(self): |
|
229 """ |
|
230 Private slot to save the raw data to a CSV file. |
|
231 """ |
|
232 self.saveData() |
|
233 |
|
234 def hasData(self): |
|
235 """ |
|
236 Public method to check, if the chart contains some valid data. |
|
237 |
|
238 @return flag indicating valid data |
|
239 @rtype bool |
|
240 """ |
|
241 # TODO: not implemented yet |
|
242 |
|
243 def saveData(self): |
|
244 """ |
|
245 Public method to save the dialog's raw data. |
|
246 |
|
247 @return flag indicating success |
|
248 @rtype bool |
|
249 """ |
|
250 # TODO: not implemented yet |