src/eric7/MicroPython/MicroPythonGraphWidget.py

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

eric ide

mercurial