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: |