eric7/MicroPython/MicroPythonGraphWidget.py

branch
eric7
changeset 8312
800c432b34c8
parent 8243
cc717c2ae956
child 8318
962bce857696
equal deleted inserted replaced
8311:4e8b98454baa 8312:800c432b34c8
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)

eric ide

mercurial