|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2019 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the MicroPython graph widget. |
|
8 """ |
|
9 |
|
10 from collections import deque |
|
11 import bisect |
|
12 import os |
|
13 import time |
|
14 import csv |
|
15 import contextlib |
|
16 |
|
17 from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt |
|
18 from PyQt5.QtGui import QPainter |
|
19 from PyQt5.QtWidgets import ( |
|
20 QWidget, QHBoxLayout, QVBoxLayout, QToolButton, QSizePolicy, QSpacerItem, |
|
21 QLabel, QSpinBox |
|
22 ) |
|
23 from PyQt5.QtChart import QChartView, QChart, QLineSeries, QValueAxis |
|
24 |
|
25 from E5Gui import E5MessageBox |
|
26 from E5Gui.E5Application import e5App |
|
27 |
|
28 import UI.PixmapCache |
|
29 import Preferences |
|
30 |
|
31 |
|
32 class MicroPythonGraphWidget(QWidget): |
|
33 """ |
|
34 Class implementing the MicroPython graph widget. |
|
35 |
|
36 @signal dataFlood emitted to indicate, that too much data is received |
|
37 """ |
|
38 dataFlood = pyqtSignal() |
|
39 |
|
40 def __init__(self, parent=None): |
|
41 """ |
|
42 Constructor |
|
43 |
|
44 @param parent reference to the parent widget |
|
45 @type QWidget |
|
46 """ |
|
47 super().__init__(parent) |
|
48 |
|
49 self.__layout = QHBoxLayout() |
|
50 self.__layout.setContentsMargins(2, 2, 2, 2) |
|
51 self.setLayout(self.__layout) |
|
52 |
|
53 self.__chartView = QChartView(self) |
|
54 self.__chartView.setSizePolicy( |
|
55 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) |
|
56 self.__layout.addWidget(self.__chartView) |
|
57 |
|
58 self.__verticalLayout = QVBoxLayout() |
|
59 self.__verticalLayout.setContentsMargins(0, 0, 0, 0) |
|
60 self.__layout.addLayout(self.__verticalLayout) |
|
61 |
|
62 self.__saveButton = QToolButton(self) |
|
63 self.__saveButton.setIcon(UI.PixmapCache.getIcon("fileSave")) |
|
64 self.__saveButton.setToolTip(self.tr("Press to save the raw data")) |
|
65 self.__saveButton.clicked.connect(self.on_saveButton_clicked) |
|
66 self.__verticalLayout.addWidget(self.__saveButton) |
|
67 self.__verticalLayout.setAlignment(self.__saveButton, |
|
68 Qt.AlignmentFlag.AlignHCenter) |
|
69 |
|
70 spacerItem = QSpacerItem(20, 20, QSizePolicy.Policy.Minimum, |
|
71 QSizePolicy.Policy.Expanding) |
|
72 self.__verticalLayout.addItem(spacerItem) |
|
73 |
|
74 label = QLabel(self.tr("max. X:")) |
|
75 self.__verticalLayout.addWidget(label) |
|
76 self.__verticalLayout.setAlignment(label, |
|
77 Qt.AlignmentFlag.AlignHCenter) |
|
78 |
|
79 self.__maxX = 100 |
|
80 self.__maxXSpinBox = QSpinBox() |
|
81 self.__maxXSpinBox.setMinimum(100) |
|
82 self.__maxXSpinBox.setMaximum(1000) |
|
83 self.__maxXSpinBox.setSingleStep(100) |
|
84 self.__maxXSpinBox.setToolTip(self.tr( |
|
85 "Enter the maximum number of data points to be plotted.")) |
|
86 self.__maxXSpinBox.setValue(self.__maxX) |
|
87 self.__maxXSpinBox.setAlignment(Qt.AlignmentFlag.AlignRight) |
|
88 self.__verticalLayout.addWidget(self.__maxXSpinBox) |
|
89 |
|
90 # holds the data to be checked for plotable data |
|
91 self.__inputBuffer = [] |
|
92 # holds the raw data |
|
93 self.__rawData = [] |
|
94 self.__dirty = False |
|
95 |
|
96 self.__maxY = 1000 |
|
97 self.__flooded = False # flag indicating a data flood |
|
98 |
|
99 self.__data = [deque([0] * self.__maxX)] |
|
100 self.__series = [QLineSeries()] |
|
101 |
|
102 # Y-axis ranges |
|
103 self.__yRanges = [1, 5, 10, 25, 50, 100, 250, 500, 1000] |
|
104 |
|
105 # setup the chart |
|
106 self.__chart = QChart() |
|
107 self.__chart.legend().hide() |
|
108 self.__chart.addSeries(self.__series[0]) |
|
109 self.__axisX = QValueAxis() |
|
110 self.__axisX.setRange(0, self.__maxX) |
|
111 self.__axisX.setLabelFormat("time") |
|
112 self.__axisY = QValueAxis() |
|
113 self.__axisY.setRange(-self.__maxY, self.__maxY) |
|
114 self.__axisY.setLabelFormat("%d") |
|
115 self.__chart.setAxisX(self.__axisX, self.__series[0]) |
|
116 self.__chart.setAxisY(self.__axisY, self.__series[0]) |
|
117 self.__chartView.setChart(self.__chart) |
|
118 self.__chartView.setRenderHint(QPainter.RenderHint.Antialiasing) |
|
119 self.preferencesChanged() |
|
120 |
|
121 self.__maxXSpinBox.valueChanged.connect(self.__handleMaxXChanged) |
|
122 |
|
123 @pyqtSlot() |
|
124 def preferencesChanged(self): |
|
125 """ |
|
126 Public slot to apply changed preferences. |
|
127 """ |
|
128 chartColorTheme = Preferences.getMicroPython("ChartColorTheme") |
|
129 if chartColorTheme == -1: |
|
130 # automatic selection of light or dark depending on desktop |
|
131 # color scheme |
|
132 if e5App().usesDarkPalette(): |
|
133 self.__chart.setTheme(QChart.ChartTheme.ChartThemeDark) |
|
134 else: |
|
135 self.__chart.setTheme(QChart.ChartTheme.ChartThemeLight) |
|
136 else: |
|
137 self.__chart.setTheme(chartColorTheme) |
|
138 |
|
139 @pyqtSlot(bytes) |
|
140 def processData(self, data): |
|
141 """ |
|
142 Public slot to process the raw data. |
|
143 |
|
144 It takes raw bytes, checks the data for a valid tuple of ints or |
|
145 floats and adds the data to the graph. If the the length of the bytes |
|
146 data is greater than 1024 then a dataFlood signal is emitted to ensure |
|
147 eric can take action to remain responsive. |
|
148 |
|
149 @param data raw data received from the connected device via the main |
|
150 device widget |
|
151 @type bytes |
|
152 """ |
|
153 # flooding guard |
|
154 if self.__flooded: |
|
155 return |
|
156 |
|
157 if len(data) > 1024: |
|
158 self.__flooded = True |
|
159 self.dataFlood.emit() |
|
160 return |
|
161 |
|
162 # disable the inputs while processing data |
|
163 self.__saveButton.setEnabled(False) |
|
164 self.__maxXSpinBox.setEnabled(False) |
|
165 |
|
166 data = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n") |
|
167 self.__inputBuffer.append(data) |
|
168 |
|
169 # check if the data contains a Python tuple containing numbers (int |
|
170 # or float) on a single line |
|
171 inputBytes = b"".join(self.__inputBuffer) |
|
172 lines = inputBytes.splitlines(True) |
|
173 for line in lines: |
|
174 if not line.endswith(b"\n"): |
|
175 # incomplete line (last line); skip it |
|
176 break |
|
177 |
|
178 line = line.strip() |
|
179 if line.startswith(b"(") and line.endswith(b")"): |
|
180 # it may be a tuple we are interested in |
|
181 rawValues = [val.strip() for val in line[1:-1].split(b",")] |
|
182 values = [] |
|
183 for raw in rawValues: |
|
184 with contextlib.suppress(ValueError): |
|
185 values.append(int(raw)) |
|
186 # ok, it is an integer |
|
187 continue |
|
188 try: |
|
189 values.append(float(raw)) |
|
190 except ValueError: |
|
191 # it is not an int or float, ignore it |
|
192 continue |
|
193 if values: |
|
194 self.__addData(tuple(values)) |
|
195 |
|
196 self.__inputBuffer = [] |
|
197 if lines[-1] and not lines[-1].endswith(b"\n"): |
|
198 # Append any left over bytes for processing next time data is |
|
199 # received. |
|
200 self.__inputBuffer.append(lines[-1]) |
|
201 |
|
202 # re-enable the inputs |
|
203 self.__saveButton.setEnabled(True) |
|
204 self.__maxXSpinBox.setEnabled(True) |
|
205 |
|
206 def __addData(self, values): |
|
207 """ |
|
208 Private method to add a tuple of values to the graph. |
|
209 |
|
210 It ensures there are the required number of line series, adds the data |
|
211 to the line series and updates the range of the chart so the chart |
|
212 displays nicely. |
|
213 |
|
214 @param values tuple containing the data to be added |
|
215 @type tuple of int or float |
|
216 """ |
|
217 # store incoming data to be able to dump it as CSV upon request |
|
218 self.__rawData.append(values) |
|
219 self.__dirty = True |
|
220 |
|
221 # check number of incoming values and adjust line series accordingly |
|
222 if len(values) != len(self.__series): |
|
223 valuesLen = len(values) |
|
224 seriesLen = len(self.__series) |
|
225 if valuesLen > seriesLen: |
|
226 # add a nwe line series |
|
227 for _index in range(valuesLen - seriesLen): |
|
228 newSeries = QLineSeries() |
|
229 self.__chart.addSeries(newSeries) |
|
230 self.__chart.setAxisX(self.__axisX, newSeries) |
|
231 self.__chart.setAxisY(self.__axisY, newSeries) |
|
232 self.__series.append(newSeries) |
|
233 self.__data.append(deque([0] * self.__maxX)) |
|
234 else: |
|
235 # remove obsolete line series |
|
236 for oldSeries in self.__series[valuesLen:]: |
|
237 self.__chart.removeSeries(oldSeries) |
|
238 self.__series = self.__series[:valuesLen] |
|
239 self.__data = self.__data[:valuesLen] |
|
240 |
|
241 # add the new values to the display and compute the maximum range |
|
242 maxRanges = [] |
|
243 for index, value in enumerate(values): |
|
244 self.__data[index].appendleft(value) |
|
245 maxRanges.append(max([max(self.__data[index]), |
|
246 abs(min(self.__data[index]))])) |
|
247 if len(self.__data[index]) > self.__maxX: |
|
248 self.__data[index].pop() |
|
249 |
|
250 # re-scale the y-axis |
|
251 maxYRange = max(maxRanges) |
|
252 yRange = bisect.bisect_left(self.__yRanges, maxYRange) |
|
253 if yRange < len(self.__yRanges): |
|
254 self.__maxY = self.__yRanges[yRange] |
|
255 elif maxYRange > self.__maxY: |
|
256 self.__maxY += self.__maxY |
|
257 elif maxYRange < self.__maxY / 2: |
|
258 self.__maxY /= 2 |
|
259 self.__axisY.setRange(-self.__maxY, self.__maxY) |
|
260 |
|
261 # ensure that floats are used to label the y-axis if the range is small |
|
262 if self.__maxY <= 5: |
|
263 self.__axisY.setLabelFormat("%2.2f") |
|
264 else: |
|
265 self.__axisY.setLabelFormat("%d") |
|
266 |
|
267 # update the line series |
|
268 for index, series in enumerate(self.__series): |
|
269 series.clear() |
|
270 xyValues = [] |
|
271 for x in range(self.__maxX): |
|
272 value = self.__data[index][self.__maxX - 1 - x] |
|
273 xyValues.append((x, value)) |
|
274 for xy in xyValues: |
|
275 series.append(*xy) |
|
276 |
|
277 @pyqtSlot() |
|
278 def on_saveButton_clicked(self): |
|
279 """ |
|
280 Private slot to save the raw data to a CSV file. |
|
281 """ |
|
282 self.saveData() |
|
283 |
|
284 def hasData(self): |
|
285 """ |
|
286 Public method to check, if the chart contains some valid data. |
|
287 |
|
288 @return flag indicating valid data |
|
289 @rtype bool |
|
290 """ |
|
291 return len(self.__rawData) > 0 |
|
292 |
|
293 def isDirty(self): |
|
294 """ |
|
295 Public method to check, if the chart contains unsaved data. |
|
296 |
|
297 @return flag indicating unsaved data |
|
298 @rtype bool |
|
299 """ |
|
300 return self.hasData() and self.__dirty |
|
301 |
|
302 def saveData(self): |
|
303 """ |
|
304 Public method to save the dialog's raw data. |
|
305 |
|
306 @return flag indicating success |
|
307 @rtype bool |
|
308 """ |
|
309 baseDir = ( |
|
310 Preferences.getMicroPython("MpyWorkspace") or |
|
311 Preferences.getMultiProject("Workspace") or |
|
312 os.path.expanduser("~") |
|
313 ) |
|
314 dataDir = os.path.join(baseDir, "data_capture") |
|
315 |
|
316 if not os.path.exists(dataDir): |
|
317 os.makedirs(dataDir) |
|
318 |
|
319 # save the raw data as a CSV file |
|
320 fileName = "{0}.csv".format(time.strftime("%Y%m%d-%H%M%S")) |
|
321 fullPath = os.path.join(dataDir, fileName) |
|
322 try: |
|
323 with open(fullPath, "w") as csvFile: |
|
324 csvWriter = csv.writer(csvFile) |
|
325 csvWriter.writerows(self.__rawData) |
|
326 |
|
327 self.__dirty = False |
|
328 return True |
|
329 except OSError as err: |
|
330 E5MessageBox.critical( |
|
331 self, |
|
332 self.tr("Save Chart Data"), |
|
333 self.tr( |
|
334 """<p>The chart data could not be saved into file""" |
|
335 """ <b>{0}</b>.</p><p>Reason: {1}</p>""").format( |
|
336 fullPath, str(err))) |
|
337 return False |
|
338 |
|
339 @pyqtSlot(int) |
|
340 def __handleMaxXChanged(self, value): |
|
341 """ |
|
342 Private slot handling a change of the max. X spin box. |
|
343 |
|
344 @param value value of the spin box |
|
345 @type int |
|
346 """ |
|
347 delta = value - self.__maxX |
|
348 if delta == 0: |
|
349 # nothing to change |
|
350 return |
|
351 elif delta > 0: |
|
352 # range must be increased |
|
353 for deq in self.__data: |
|
354 deq.extend([0] * delta) |
|
355 else: |
|
356 # range must be decreased |
|
357 data = [] |
|
358 for deq in self.__data: |
|
359 data.append(deque(list(deq)[:value])) |
|
360 self.__data = data |
|
361 |
|
362 self.__maxX = value |
|
363 self.__axisX.setRange(0, self.__maxX) |