src/eric7/MicroPython/MicroPythonGraphWidget.py

branch
eric7
changeset 9209
b99e7fd55fd3
parent 8881
54e42bc2437a
child 9221
bf71ee032bb4
equal deleted inserted replaced
9208:3fc8dfeb6ebe 9209:b99e7fd55fd3
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2019 - 2022 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 PyQt6.QtCore import pyqtSignal, pyqtSlot, Qt
18 from PyQt6.QtGui import QPainter
19 from PyQt6.QtWidgets import (
20 QWidget, QHBoxLayout, QVBoxLayout, QToolButton, QSizePolicy, QSpacerItem,
21 QLabel, QSpinBox
22 )
23 from PyQt6.QtCharts import QChartView, QChart, QLineSeries, QValueAxis
24
25 from EricWidgets import EricMessageBox
26 from EricWidgets.EricApplication import ericApp
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
110 self.__axisX = QValueAxis()
111 self.__axisX.setLabelFormat("time")
112 self.__chart.addAxis(self.__axisX, Qt.AlignmentFlag.AlignBottom)
113 self.__series[0].attachAxis(self.__axisX)
114 self.__axisX.setRange(0, self.__maxX)
115
116 self.__axisY = QValueAxis()
117 self.__axisY.setLabelFormat("%d")
118 self.__chart.addAxis(self.__axisY, Qt.AlignmentFlag.AlignLeft)
119 self.__series[0].attachAxis(self.__axisY)
120 self.__axisY.setRange(-self.__maxY, self.__maxY)
121
122 self.__chartView.setChart(self.__chart)
123 self.__chartView.setRenderHint(QPainter.RenderHint.Antialiasing)
124 self.preferencesChanged()
125
126 self.__maxXSpinBox.valueChanged.connect(self.__handleMaxXChanged)
127
128 @pyqtSlot()
129 def preferencesChanged(self):
130 """
131 Public slot to apply changed preferences.
132 """
133 chartColorTheme = Preferences.getMicroPython("ChartColorTheme")
134 if chartColorTheme == -1:
135 # automatic selection of light or dark depending on desktop
136 # color scheme
137 if ericApp().usesDarkPalette():
138 self.__chart.setTheme(QChart.ChartTheme.ChartThemeDark)
139 else:
140 self.__chart.setTheme(QChart.ChartTheme.ChartThemeLight)
141 else:
142 self.__chart.setTheme(chartColorTheme)
143
144 @pyqtSlot(bytes)
145 def processData(self, data):
146 """
147 Public slot to process the raw data.
148
149 It takes raw bytes, checks the data for a valid tuple of ints or
150 floats and adds the data to the graph. If the the length of the bytes
151 data is greater than 1024 then a dataFlood signal is emitted to ensure
152 eric can take action to remain responsive.
153
154 @param data raw data received from the connected device via the main
155 device widget
156 @type bytes
157 """
158 # flooding guard
159 if self.__flooded:
160 return
161
162 if len(data) > 1024:
163 self.__flooded = True
164 self.dataFlood.emit()
165 return
166
167 # disable the inputs while processing data
168 self.__saveButton.setEnabled(False)
169 self.__maxXSpinBox.setEnabled(False)
170
171 data = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
172 self.__inputBuffer.append(data)
173
174 # check if the data contains a Python tuple containing numbers (int
175 # or float) on a single line
176 inputBytes = b"".join(self.__inputBuffer)
177 lines = inputBytes.splitlines(True)
178 for line in lines:
179 if not line.endswith(b"\n"):
180 # incomplete line (last line); skip it
181 break
182
183 line = line.strip()
184 if line.startswith(b"(") and line.endswith(b")"):
185 # it may be a tuple we are interested in
186 rawValues = [val.strip() for val in line[1:-1].split(b",")]
187 values = []
188 for raw in rawValues:
189 with contextlib.suppress(ValueError):
190 values.append(int(raw))
191 # ok, it is an integer
192 continue
193 try:
194 values.append(float(raw))
195 except ValueError:
196 # it is not an int or float, ignore it
197 continue
198 if values:
199 self.__addData(tuple(values))
200
201 self.__inputBuffer = []
202 if lines[-1] and not lines[-1].endswith(b"\n"):
203 # Append any left over bytes for processing next time data is
204 # received.
205 self.__inputBuffer.append(lines[-1])
206
207 # re-enable the inputs
208 self.__saveButton.setEnabled(True)
209 self.__maxXSpinBox.setEnabled(True)
210
211 def __addData(self, values):
212 """
213 Private method to add a tuple of values to the graph.
214
215 It ensures there are the required number of line series, adds the data
216 to the line series and updates the range of the chart so the chart
217 displays nicely.
218
219 @param values tuple containing the data to be added
220 @type tuple of int or float
221 """
222 # store incoming data to be able to dump it as CSV upon request
223 self.__rawData.append(values)
224 self.__dirty = True
225
226 # check number of incoming values and adjust line series accordingly
227 if len(values) != len(self.__series):
228 valuesLen = len(values)
229 seriesLen = len(self.__series)
230 if valuesLen > seriesLen:
231 # add a new line series
232 for _index in range(valuesLen - seriesLen):
233 newSeries = QLineSeries()
234 self.__chart.addSeries(newSeries)
235 newSeries.attachAxis(self.__axisX)
236 newSeries.attachAxis(self.__axisY)
237 self.__series.append(newSeries)
238 self.__data.append(deque([0] * self.__maxX))
239 else:
240 # remove obsolete line series
241 for oldSeries in self.__series[valuesLen:]:
242 self.__chart.removeSeries(oldSeries)
243 self.__series = self.__series[:valuesLen]
244 self.__data = self.__data[:valuesLen]
245
246 # add the new values to the display and compute the maximum range
247 maxRanges = []
248 for index, value in enumerate(values):
249 self.__data[index].appendleft(value)
250 maxRanges.append(max([max(self.__data[index]),
251 abs(min(self.__data[index]))]))
252 if len(self.__data[index]) > self.__maxX:
253 self.__data[index].pop()
254
255 # re-scale the y-axis
256 maxYRange = max(maxRanges)
257 yRange = bisect.bisect_left(self.__yRanges, maxYRange)
258 if yRange < len(self.__yRanges):
259 self.__maxY = self.__yRanges[yRange]
260 elif maxYRange > self.__maxY:
261 self.__maxY += self.__maxY
262 elif maxYRange < self.__maxY / 2:
263 self.__maxY /= 2
264 self.__axisY.setRange(-self.__maxY, self.__maxY)
265
266 # ensure that floats are used to label the y-axis if the range is small
267 if self.__maxY <= 5:
268 self.__axisY.setLabelFormat("%2.2f")
269 else:
270 self.__axisY.setLabelFormat("%d")
271
272 # update the line series
273 for index, series in enumerate(self.__series):
274 series.clear()
275 xyValues = []
276 for x in range(self.__maxX):
277 value = self.__data[index][self.__maxX - 1 - x]
278 xyValues.append((x, value))
279 for xy in xyValues:
280 series.append(*xy)
281
282 @pyqtSlot()
283 def on_saveButton_clicked(self):
284 """
285 Private slot to save the raw data to a CSV file.
286 """
287 self.saveData()
288
289 def hasData(self):
290 """
291 Public method to check, if the chart contains some valid data.
292
293 @return flag indicating valid data
294 @rtype bool
295 """
296 return len(self.__rawData) > 0
297
298 def isDirty(self):
299 """
300 Public method to check, if the chart contains unsaved data.
301
302 @return flag indicating unsaved data
303 @rtype bool
304 """
305 return self.hasData() and self.__dirty
306
307 def saveData(self):
308 """
309 Public method to save the dialog's raw data.
310
311 @return flag indicating success
312 @rtype bool
313 """
314 baseDir = (
315 Preferences.getMicroPython("MpyWorkspace") or
316 Preferences.getMultiProject("Workspace") or
317 os.path.expanduser("~")
318 )
319 dataDir = os.path.join(baseDir, "data_capture")
320
321 if not os.path.exists(dataDir):
322 os.makedirs(dataDir)
323
324 # save the raw data as a CSV file
325 fileName = "{0}.csv".format(time.strftime("%Y%m%d-%H%M%S"))
326 fullPath = os.path.join(dataDir, fileName)
327 try:
328 with open(fullPath, "w") as csvFile:
329 csvWriter = csv.writer(csvFile)
330 csvWriter.writerows(self.__rawData)
331
332 self.__dirty = False
333 return True
334 except OSError as err:
335 EricMessageBox.critical(
336 self,
337 self.tr("Save Chart Data"),
338 self.tr(
339 """<p>The chart data could not be saved into file"""
340 """ <b>{0}</b>.</p><p>Reason: {1}</p>""").format(
341 fullPath, str(err)))
342 return False
343
344 @pyqtSlot(int)
345 def __handleMaxXChanged(self, value):
346 """
347 Private slot handling a change of the max. X spin box.
348
349 @param value value of the spin box
350 @type int
351 """
352 delta = value - self.__maxX
353 if delta == 0:
354 # nothing to change
355 return
356 elif delta > 0:
357 # range must be increased
358 for deq in self.__data:
359 deq.extend([0] * delta)
360 else:
361 # range must be decreased
362 data = []
363 for deq in self.__data:
364 data.append(deque(list(deq)[:value]))
365 self.__data = data
366
367 self.__maxX = value
368 self.__axisX.setRange(0, self.__maxX)

eric ide

mercurial