SpecLog 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
SpecLog/monitor.py ADDED
@@ -0,0 +1,1023 @@
1
+ """
2
+ Monitor.py: plotting data in real-time or static
3
+
4
+ 1. Read log files
5
+ 2. Plot live or static data
6
+ 3. Show warning information
7
+
8
+ It use PySide 6, there are some terms you need to know before modifying this script
9
+
10
+ 1. 'name': the identification of a curve, and the key word to call the curve line and item. It can be the alias if the alias presents
11
+ 2. 'item': the curve items, e.g Legend class from PySide 6
12
+ 3. 'line': the data line for plotting, it is a class from PySide 6
13
+
14
+ Author: Yen-Chun Huang
15
+
16
+ Company: Bridge 12 Technologies, Inc
17
+ """
18
+
19
+ import os
20
+ import csv
21
+ from datetime import datetime
22
+ import numpy as _np
23
+ from PySide6.QtWidgets import QApplication, QFileDialog, QMainWindow, QVBoxLayout, QCheckBox
24
+ from PySide6 import QtCore
25
+ import pyqtgraph as pg
26
+ from .ui.plotting import Ui_MainWindow
27
+ from .loggerConfig import *
28
+ from .debugLog import *
29
+
30
+ red = "QCheckBox::indicator {\nwidth:10px;\nheight:10px;\nborder-radius:7px;\n}\n\nQCheckBox::indicator:unchecked {\nbackground-color:red;\nborder:2px solid white;\n}\n"
31
+ green = "QCheckBox::indiator {\nwidth:10px;\nheight:10px;\nborder-radius:7px;\n}\n\nQCheckBox::indicator:unchecked {\nbackground-color:green;\nborder:2pcx solid white;\n}\n"
32
+
33
+
34
+ class MainWindow(QMainWindow, Ui_MainWindow):
35
+ def __init__(self, config_file: str = None):
36
+ super().__init__()
37
+ self.setupUi(self)
38
+
39
+ # configuration file
40
+ config = loggerConfig(config_file)
41
+ self.settings = config.settings
42
+ self.limit_hours = int(self.settings['limit_hours'])
43
+ self.device_config = config.devices
44
+ self.file_dir = self.settings["log_folder_location"] + "/LOG/"
45
+ self.commands = config.commands
46
+ self.getAlias()
47
+ self.getCommandInfoByName()
48
+ self.status_string = ""
49
+
50
+ # debug log
51
+ self.debugLogger = debugLog(config_file).logger
52
+
53
+ # get files from directionary
54
+ self.getFiles()
55
+ self.getData()
56
+ self.setWarningLevelByName()
57
+ self.getPenByName()
58
+ self.getLine() # initialize plot
59
+
60
+ # Shown items
61
+ if not self.loadDisplaySettings():
62
+ self.saveDisplaySettings() # initial saving
63
+
64
+ self.hiddenListWidget.addItems(self.hidden_list)
65
+ self.shownListWidget.addItems(
66
+ self.shown_list
67
+ ) # self.shown_list is used for displaying data
68
+
69
+ # Status Indicator
70
+ self.setLEDIndicator()
71
+ self.setStatus()
72
+
73
+ # Buttons and Menu settings
74
+ self.hiddenToShown.clicked.connect(
75
+ self.showItems
76
+ ) # button to move item from hidden widget to shown widget
77
+ self.shownToHidden.clicked.connect(
78
+ self.hideItems
79
+ ) # button to move item from shown widget to hidden widget
80
+ self.clearWarningText.clicked.connect(
81
+ self.clearWarning
82
+ ) # button to clear warning message
83
+ self.setPlotType()
84
+
85
+ self.timer = QtCore.QTimer()
86
+ self.timer.setInterval(300)
87
+ self.timer.timeout.connect(self.setStatus)
88
+ self.timer.timeout.connect(self.printWarning)
89
+ self.timer.timeout.connect(self.updateFiles)
90
+ self.timer.timeout.connect(self.updateData)
91
+ self.timer.timeout.connect(self.plot)
92
+ self.timer.start()
93
+
94
+ ### ======================================================= Log Data Files =======================================================
95
+ def getFiles(self):
96
+ """
97
+ Get files from directory
98
+ """
99
+ self.all_file_list = [
100
+ file for file in os.listdir(self.file_dir) if "log_" in file and '.csv' in file
101
+ ]
102
+ self.file_list = self.all_file_list[-3:]
103
+ return True
104
+
105
+ def updateFiles(self):
106
+ """
107
+ update files from directory
108
+ """
109
+ file = list(os.listdir(self.file_dir))[-1]
110
+ if file != self.all_file_list[-1]:
111
+ # check if file format is correct
112
+ if "log_" in file and '.csv' in file:
113
+ self.all_file_list.append(file)
114
+ self.file_list = self.file_list[1:]
115
+ self.file_list.append(file)
116
+ return True
117
+
118
+ else:
119
+ return False
120
+
121
+ ### ======================================================= Data Dictionary =======================================================
122
+ def getData(self):
123
+ """
124
+ Get data from logger files when monitor starts. It is not used for updating data reading
125
+ """
126
+ self.all_data_by_name = {"Date": [], "Time": [], "Seconds": []}
127
+ window_length = self.windowLength.value()
128
+
129
+ if not self.file_list:
130
+ self.debugLogger.error("Logged Data Not Found")
131
+ return False
132
+
133
+ # When the monitor starts or a new file presents
134
+ for file in self.file_list:
135
+ f = open(self.file_dir + file, "r")
136
+ names = f.readline().strip("\n").split(",")
137
+ print(names)
138
+ if names[0]:
139
+ names = self.convertNames(
140
+ names
141
+ ) # at this point, the name is used locally
142
+ for data in csv.reader(f, delimiter=","):
143
+ self.all_data_by_name = self.updateDataToDict(
144
+ names, data, self.all_data_by_name, self.limit_hours
145
+ )
146
+
147
+ if file != self.file_list[-1]: # close the file
148
+ f.close()
149
+ else: # leave the current file open for further reading
150
+ # after reading all data, put the local names list to global
151
+ self.current_file = file
152
+ self.f = f
153
+ self.names = names
154
+ self.window_length = window_length
155
+
156
+ # for initial data dictionary
157
+ self.all_names = [
158
+ name
159
+ for name in self.all_data_by_name.keys()
160
+ if name not in ["Date", "Time", "Seconds"]
161
+ ] # get list of data name except Date, Time and Seconds
162
+ self.all_x = self.all_data_by_name["Seconds"]
163
+ self.data_by_name = {
164
+ name: val[-1 * window_length :]
165
+ for name, val in self.all_data_by_name.items()
166
+ if name not in ["Date", "Time", "Seconds"]
167
+ }
168
+ self.x = self.all_x[-1 * window_length :]
169
+ self.x_ticks = self.getXTicks(
170
+ self.x, self.all_data_by_name["Time"][-1 * window_length :]
171
+ )
172
+
173
+ def updateData(self):
174
+ """
175
+ Update data when new line appears or new file presents
176
+
177
+ This is the data checking processing and used for live data
178
+ """
179
+
180
+ if self.current_file != self.file_list[-1]:
181
+ self.f.close() # when new file exist, close the previous file
182
+ self.current_file = self.file_list[-1]
183
+ self.f = open(self.file_dir + self.current_file, "r")
184
+ self.names = self.f.readline().strip("\n").split(",")
185
+ self.names = self.convertNames(self.names) # convert names list from alias
186
+
187
+ self.line = self.f.readline().strip("\n")
188
+ self.latest_data = None
189
+ if self.line:
190
+ self.all_data_by_name = self.updateDataToDict(
191
+ self.names, self.line.strip("\n").split(",") , self.all_data_by_name, self.limit_hours
192
+ )
193
+ # self.all_x doesn't need to be updated because it is the reference to self.all_data_by_name['Seconds']
194
+
195
+ if self.plot_type:
196
+ self.livePlot()
197
+
198
+ else:
199
+ self.staticPlot()
200
+
201
+ def livePlot(self):
202
+ """
203
+ Choose to plot data in live
204
+ it is always updating
205
+
206
+ Modification Suggestions: might add a condition: '# if self.window_length != window_length or self.line or [change_from_static_to_live]:'
207
+
208
+ """
209
+ window_length = self.windowLength.value()
210
+ if self.window_length != window_length:
211
+ self.saveDisplaySettings() # update the window length
212
+ self.window_length = window_length
213
+ self.data_by_name = {
214
+ name: val[-1 * window_length :]
215
+ for name, val in self.all_data_by_name.items()
216
+ if name not in ["Date", "Time"]
217
+ }
218
+ self.x = self.all_x[-1 * window_length :]
219
+ self.x_ticks = self.getXTicks(
220
+ self.x, self.all_data_by_name["Time"][-1 * window_length :]
221
+ )
222
+
223
+ return self.plot_type
224
+
225
+ def staticPlot(self):
226
+ if self.static_update_request:
227
+ if self.selected_data_by_date:
228
+ if (
229
+ self.time_is_valid
230
+ ): # only update plot when time is valid, 'OK' button is pressed, and file is not selected
231
+ index = self.static_index
232
+ self.data_by_name = {
233
+ name: val[index[0] : index[1]]
234
+ for name, val in self.all_data_by_name.items()
235
+ if name not in ["Date", "Time"]
236
+ }
237
+ self.x = self.all_x[index[0] : index[1]]
238
+ self.x_ticks = self.getXTicks(
239
+ self.x, self.all_data_by_name["Time"][index[0] : index[1]]
240
+ )
241
+ self.static_update_request = (
242
+ False # just update the static figure once
243
+ )
244
+
245
+ elif (
246
+ self.selected_data_by_file
247
+ ): # only update when time when 'OK' button is pressed and file is selected
248
+ self.data_by_name = {
249
+ name: val
250
+ for name, val in self.temp_data_by_name.items()
251
+ if name not in ["Date", "Time"]
252
+ }
253
+ self.x = self.temp_data_by_name["Seconds"]
254
+ self.x_ticks = self.getXTicks(self.x, self.temp_data_by_name["Time"])
255
+ self.static_update_request = False # just update the static figure once
256
+
257
+ return self.plot_type
258
+
259
+ def updateDataToDict(self, names: list, data, d: dict, hours: int = 0):
260
+ """
261
+ Add data to target dictionary based on name
262
+
263
+ Args:
264
+ names (list): list of key (name) to add to target dictionary
265
+ data: csv read data
266
+ d (dict): target dictionary to add data
267
+ hours (int): the limited hours saved in this dictionary, greater than 0 means no hour limit
268
+
269
+ Return:
270
+ d (dict): target dictionary with added data
271
+ """
272
+
273
+ # convert name from alias:
274
+ # temporary dictionary
275
+ td = {name.strip(): val.strip() for name, val in zip(names, data)}
276
+
277
+ # remove the overtime data if necessary
278
+ # if hours > 0:
279
+ # if d['Seconds'] != []:
280
+ # # print(td)
281
+ # while self.getXAxisFromTime(td["Date"].strip(), td["Time"].strip()) - d['Seconds'][0] > hours * 60 * 60:
282
+ # for name, value in d.items():
283
+ # value.pop(0)
284
+
285
+ # create empty list if key not exists in dictionary
286
+ for name in td.keys():
287
+ if name not in d.keys():
288
+ d[name] = [_np.nan] * len(d["Date"])
289
+
290
+ for name in d.keys():
291
+ if name in ["Date", "Time"]:
292
+ td[name] = td[name].strip()
293
+ elif name == "Seconds":
294
+ td[name] = self.getXAxisFromTime(td["Date"].strip(), td["Time"].strip())
295
+ elif name in td and td[name] != "nan":
296
+ if self.command_info_by_name[name][3]:
297
+ td[name] = str(td[name])
298
+ else:
299
+ td[name] = float(td[name])
300
+ elif name:
301
+ td[name] = _np.nan
302
+
303
+ d[name].append(td[name])
304
+ self.latest_data = td
305
+
306
+ return d
307
+
308
+ ### ======================================================= X-axis Processing =======================================================
309
+ def getXAxisFromTime(self, date: str, time: str):
310
+ """
311
+ Convert Date and Time string to the seconds from 1970/1/1
312
+
313
+ Use seconds as X axis
314
+
315
+ Args:
316
+ date (str): in format %Y-%m-%d
317
+ time (str): in format %H:%M:%S
318
+
319
+ Returns:
320
+ seconds (int): seconds from 1970/1/1
321
+ """
322
+
323
+ string = date + " " + time # in format '%Y-%m-%d %H:%M:%S'
324
+ datetime_object = datetime.strptime(string, "%Y-%m-%d %H:%M:%S")
325
+ delta = datetime_object - datetime(1970, 1, 1)
326
+
327
+ return int(delta.total_seconds())
328
+
329
+ def getXTicks(self, seconds: list, time: list, ticks_number: int = 10):
330
+ """
331
+ Get less dense X ticks from x list and time list
332
+
333
+ Args:
334
+ seconds (list): the list of seconds in integer
335
+ time (list): the list of time in string
336
+ ticks_number (int): the number of ticks in plotting
337
+
338
+ Returns:
339
+ x_ticks (list): the list of tuple in format (seconds: time)
340
+
341
+ """
342
+ # if not len(seconds) or len(seconds) != len(time):
343
+ # return False
344
+
345
+ # if len(seconds) <= ticks_number:
346
+ # x_ticks = [(seconds[i], time[i]) for i in range(len(seconds))]
347
+
348
+ # else:
349
+ # x_ticks = [
350
+ # (seconds[i], time[i])
351
+ # for i in _np.linspace(0, len(seconds) - 1, ticks_number, dtype=int)
352
+ # ]
353
+ time = []
354
+
355
+ for i in range(len(seconds)):
356
+ time.append(datetime.utcfromtimestamp(seconds[i]).strftime("%Y-%m-%d\n%H:%M:%S"))
357
+
358
+ if len(seconds) <= ticks_number:
359
+ x_ticks = [(seconds[i], time[i]) for i in range(len(seconds))]
360
+
361
+ else:
362
+ x_ticks = [
363
+ (seconds[i], time[i])
364
+ for i in _np.linspace(0, len(seconds) - 1, ticks_number, dtype=int)
365
+ ]
366
+ return x_ticks
367
+
368
+ def getLine(self):
369
+ """
370
+ Initialize plotting and get line based on name
371
+ """
372
+ self.line_by_name = {} # {name: line}
373
+ for name, data in self.data_by_name.items(): # loop all names
374
+ pen = self.pen_by_name[name] # set pen
375
+ self.line_by_name[name] = self.graphWidget.plot(
376
+ [], [], name=name, pen=pen, label=self.x_ticks
377
+ ) # initialize data plotting and get line for each name
378
+
379
+ self.graphWidget.setBackground("w")
380
+ self.graphWidget.showGrid(x=True, y=True)
381
+ self.legend = self.graphWidget.addLegend()
382
+ self.ax = self.graphWidget.getAxis("bottom")
383
+ self.ax.setStyle(tickFont=pg.QtGui.QFont("Arial", 7))
384
+
385
+ return True
386
+
387
+ ### ======================================================= Drawing Pen =======================================================
388
+ def getPenByName(self):
389
+ """
390
+ Set curve color and line style to pen
391
+
392
+ Idea: loop color list. If color is the same, use different dash line
393
+ 1. loop the color list. e.g. 1%4 = 1 and 5%4 = 1, so the second and fifth element use the same color
394
+ 2. loop the dash list. e.g. 1//4%4 = 0 and 5//4%4 = 1, so the second element doesn't have dash line but fifth element use dash line [16, 16]
395
+
396
+ """
397
+ self.pen_by_name = {} # {name: pen}
398
+
399
+ color_list_loop = ["#F37021", "#46812B", "#67AE3E", "#4D4D4F"] # can be extend
400
+ dash_list_loop = [None, None, None, None] # can be extend
401
+
402
+ for index, name in enumerate(self.all_names):
403
+ color = color_list_loop[index % len(color_list_loop)] # loop color list
404
+ dash = dash_list_loop[
405
+ index // len(color_list_loop) % len(dash_list_loop)
406
+ ] # loop dash line list if same color
407
+ self.pen_by_name[name] = pg.mkPen(
408
+ color=color, dash=dash, width=2.5
409
+ ) # set to pen by name
410
+
411
+ return True
412
+
413
+ ### ================================================ Set Static or Live plot ===============================================
414
+ def setPlotType(self):
415
+ """
416
+ The setToStatic button will set the static plot
417
+ The setToLive button will set the live plot
418
+ By default, the plot is live
419
+ """
420
+ self.plot_type = True # plot is live
421
+ self.setToStatic.clicked.connect(
422
+ self.setStatic
423
+ ) # ok button to set static by date
424
+ self.setToLive.clicked.connect(
425
+ self.setLive
426
+ ) # reset button to set plot back to live
427
+ self.loadFile.triggered.connect(
428
+ self.loadStaticFile
429
+ ) # menu bar for file selection
430
+
431
+ def setStatic(self):
432
+ """
433
+ Set plot type to static if the input is valid, or give the error and set plot type back to live
434
+ """
435
+ self.getSelectedDataRangeByDate()
436
+ self.selected_data_by_file = False
437
+ self.selected_data_by_date = True
438
+
439
+ def setLive(self):
440
+ self.plot_type = True
441
+ self.selected_data_by_file = False
442
+ self.selected_data_by_date = False
443
+ self.static_update_request = False
444
+
445
+ ### =============================================== Static Plot by File Name ===============================================
446
+ def loadStaticFile(self):
447
+ """
448
+ Load file from menu bar
449
+ """
450
+ filename, ext = QFileDialog.getOpenFileName(
451
+ caption="Import File", dir=self.file_dir, filter="*.csv"
452
+ )
453
+ if filename and "log_" in filename and '.csv' in filename: # if a file is selected and file is valid
454
+ try:
455
+ self.temp_data_by_name = {"Date": [], "Time": [], "Seconds": []}
456
+ f = open(filename, "r")
457
+ names = f.readline().strip("\n").split(",")
458
+ names = self.convertNames(names)
459
+ for data in csv.reader(f, delimiter=","):
460
+ self.temp_data_by_name = self.updateDataToDict(
461
+ names, data, self.temp_data_by_name
462
+ )
463
+ f.close()
464
+ if self.temp_data_by_name["Date"]:
465
+ self.plot_type = False
466
+ self.static_update_request = True
467
+ self.selected_data_by_file = True
468
+ self.selected_data_by_date = False
469
+
470
+ return True
471
+ else:
472
+ self.warningText.appendPlainText("The selected file is empty.")
473
+
474
+ except:
475
+ self.warningText.appendPlainText(
476
+ "The selected file is invalid or compromised."
477
+ )
478
+
479
+ self.static_update_request = False
480
+ self.selected_data_by_file = False
481
+ return False
482
+
483
+ ### ================================================= Static Plot by Date ==================================================
484
+ def getSelectedDataRangeByDate(self):
485
+ """
486
+ Use formatted input dates to calculated the index range, and give the index for staticPlot function
487
+ """
488
+ self.time_is_valid = True # initial True and force to False if input time is not valid. This value is used for making a new static plot
489
+
490
+ # Get time in seconds in a list [start_time, end_time]
491
+
492
+ # Value explains
493
+ # False: the incorrect format that will give the warning message, and the plot will not be static until the input is correct
494
+ # None: the not given value, eg. [1, None] means 1 s to the moment when user click ok and plot is static
495
+ # seconds (int): the seconds converted from formatted input. eg. [500, 1500] means 500 s to 1500 s
496
+
497
+ # The format is yyyymmddHHMM, where yyyy is complete year, mm is complete month, dd is complete day, HH is hour in 24 hour format, and MM is minutes
498
+ # Hours and minutes are optional, the rest are required
499
+ # If the Input and string 'yyyymmddHHMM' or empty, it will assign None to list
500
+
501
+ self.static_time_range = [
502
+ self.returnSeconds(self.startTime.text().strip()),
503
+ self.returnSeconds(self.endTime.text().strip()),
504
+ ]
505
+
506
+ if (
507
+ self.static_time_range[0] == False or self.static_time_range[1] == False
508
+ ): # input time format is incorrect
509
+ self.warningText.appendPlainText("Static input time is not valid")
510
+ self.time_is_valid = False
511
+
512
+ else:
513
+ # convert seconds to the index of nearest value in self.all_x (all data x-axis)
514
+ self.static_index = [
515
+ self.binary_search(self.all_x, self.static_time_range[0]),
516
+ self.binary_search(self.all_x, self.static_time_range[1]),
517
+ ]
518
+
519
+ if not self.static_index[
520
+ 0
521
+ ]: # force None to 0 for start time if no input is given
522
+ self.static_index[0] = 0
523
+ if not self.static_index[
524
+ 1
525
+ ]: # force None to the last index in current data x-axis for end time if no input is given
526
+ self.static_index[1] = len(self.all_x) - 1
527
+
528
+ if (
529
+ self.static_index[1] == 0
530
+ ): # the end time error which leads to single points
531
+ self.warningText.appendPlainText(
532
+ "Static input time is not valid: end time is earlier than the logging start time"
533
+ )
534
+ self.time_is_valid = False
535
+
536
+ if (
537
+ self.static_index[0] >= self.static_index[1]
538
+ ): # the end time error which leads to indexing issue
539
+ self.warningText.appendPlainText(
540
+ "Static input time is not valid: end time should be later than start time"
541
+ )
542
+ self.time_is_valid = False
543
+
544
+ self.plot_type = False # make plot type to static
545
+ self.static_update_request = (
546
+ True # only update static data when 'OK' button is pressed.
547
+ )
548
+
549
+ return True
550
+
551
+ def returnSeconds(self, time: str):
552
+ """
553
+ Check if a time string is valid in format yyyymmdd or yyyymmddHH or yyyymmddHHMM or empty and return seconds
554
+ """
555
+
556
+ # check empty
557
+ if time == "yyyymmddHHMM" or not time: # by default or not given
558
+ return None
559
+
560
+ # check length:
561
+ if len(time) not in [
562
+ len("yyyymmdd"),
563
+ len("yyyymmddHH"),
564
+ len("yyyymmddHHMM"),
565
+ ]: # incorrect length
566
+ return False
567
+
568
+ if len(time) == len("yyyymmdd"):
569
+ try:
570
+ datetime_object = datetime.strptime(time, "%Y%m%d")
571
+ except:
572
+ return False
573
+
574
+ elif len(time) == len("yyyymmddHH"):
575
+ try:
576
+ datetime_object = datetime.strptime(time, "%Y%m%d%H")
577
+ except:
578
+ return False
579
+
580
+ elif len(time) == len("yyyymmddHHMM"):
581
+ try:
582
+ datetime_object = datetime.strptime(time, "%Y%m%d%H%M")
583
+ except:
584
+ return False
585
+ else:
586
+ return False
587
+
588
+ delta = datetime_object - datetime(1970, 1, 1) # use 1970/1/1 as reference
589
+
590
+ return int(delta.total_seconds())
591
+
592
+ ### ======================================================= Plotting =======================================================
593
+ def plot(self):
594
+ """
595
+ Plotting data
596
+ """
597
+ for name in self.all_names:
598
+ if name in self.shown_list: # plot line in shown widget
599
+ if not self.legend.getLabel(
600
+ self.line_by_name[name]
601
+ ): # if not legend, add legend
602
+ self.legend.addItem(self.line_by_name[name], name)
603
+
604
+ self.line_by_name[name].setData(self.x, self.data_by_name[name])
605
+ self.ax.setTicks([self.x_ticks])
606
+
607
+ else: # hide line in hidden widget
608
+ self.legend.removeItem(name) # remove legend
609
+ self.line_by_name[name].setData([], []) # display empty line
610
+
611
+ ### ======================================================= List Widget Interaction =======================================================
612
+ def saveDisplaySettings(self):
613
+ """
614
+ This will save hidden and shown items and window length in the file called 'display_settings.txt'
615
+ format:
616
+ 1st line: 'hidden[, item1][, item2]....'
617
+ 2nd line: 'shown[, item3][, item4]....'
618
+ 3rd line: 'window length,[window_length]'
619
+ """
620
+
621
+ hidden_list = ["hidden"]
622
+ hidden_list.extend(self.hidden_list)
623
+ hidden_string = ",".join(hidden_list) + "\n"
624
+ shown_list = ["shown"]
625
+ shown_list.extend(self.shown_list)
626
+ shown_string = ",".join(shown_list) + "\n"
627
+ ingnore_list = ["ignore"]
628
+ ingnore_list.extend(self.ignore_list)
629
+ ignore_string = ",".join(ingnore_list) + "\n"
630
+ window_length_string = "window length," + self.windowLength.text()
631
+ f = open(self.file_dir + "display_settings.txt", "w")
632
+ f.write(hidden_string + shown_string + ignore_string + window_length_string)
633
+ f.close()
634
+
635
+ return True
636
+
637
+ def loadDisplaySettings(self):
638
+ """
639
+ This will load hidden item and shown item settings
640
+ return True if loading successfully, else False
641
+ """
642
+
643
+ self.hidden_list = []
644
+ self.shown_list = []
645
+ self.ignore_list =[]
646
+ if "display_settings.txt" in os.listdir(self.file_dir):
647
+ f = open(self.file_dir + "display_settings.txt", "r")
648
+ hidden_list = f.readline().strip("\n").split(",")[1:] # remove 'hidden'
649
+ shown_list = f.readline().strip("\n").split(",")[1:] # remove 'shown'
650
+ ignore_list = f.readline().strip("\n").split(",")[1:] # remove 'ignore'
651
+ window_length = (
652
+ f.readline().strip("\n").split(",")[1]
653
+ ) # get window length in str
654
+ self.windowLength.setValue(int(window_length))
655
+ f.close()
656
+ for name in self.all_names:
657
+ if name in shown_list:
658
+ self.shown_list.append(name)
659
+ elif name in hidden_list: # in the case that the item is hidden or hasn't been set up
660
+ self.hidden_list.append(name)
661
+ elif name in ignore_list:
662
+ self.ignore_list.append(name)
663
+ return True
664
+ else: # if the file not exist, copy all names to hidden list
665
+ self.hidden_list = self.all_names.copy()
666
+ for name in self.all_names:
667
+ if self.command_info_by_name[name][3]:
668
+ self.ignore_list.append(self.hidden_list.pop(self.hidden_list.index(name)))
669
+ return False
670
+
671
+ def hideItems(self):
672
+ """
673
+ Move items from shown list to hidden list, and from shown widget to hidden widget
674
+ """
675
+ selected = self.shownListWidget.selectedItems() # multi selection
676
+ for item in selected:
677
+ row = self.shownListWidget.row(item) # get row
678
+ self.hiddenListWidget.addItem(
679
+ self.shownListWidget.takeItem(row)
680
+ ) # remove item from shown widget to hidden widget
681
+ self.shown_list.remove(item.text()) # add the item from hidden list
682
+ self.hidden_list.append(item.text()) # remove the item to the shown list
683
+
684
+ self.saveDisplaySettings() # save current setting
685
+ return True
686
+
687
+ def showItems(self):
688
+ """
689
+ Move items from hidden list to shown list, and from hidden widget to shown widget
690
+ """
691
+
692
+ selected = self.hiddenListWidget.selectedItems() # multi selection
693
+ for item in selected:
694
+ row = self.hiddenListWidget.row(item) # get row
695
+ self.shownListWidget.addItem(
696
+ self.hiddenListWidget.takeItem(row)
697
+ ) # remove item from hidden widget to shown widget
698
+ self.hidden_list.remove(item.text()) # remove the item from hidden list
699
+ self.shown_list.append(item.text()) # add the item to the shown list
700
+
701
+ self.saveDisplaySettings() # save current setting
702
+ return True
703
+
704
+ ### =======================================================Alias Related=======================================================
705
+ def getAlias(self, name: str = None):
706
+ """
707
+ Get alias and assign it to the name
708
+
709
+ Args:
710
+ name (str, option): if name is provided, return the alias, otherwise get alias by name dict
711
+
712
+ Returns:
713
+ alias (str): an alias
714
+ """
715
+ if not name: # generate alias dictionary
716
+ self.alias_by_name = {} # {name: alias}
717
+ for device in self.device_config:
718
+ for name, info in self.commands[device].items():
719
+ if info["alias"]:
720
+ self.alias_by_name[name] = info["alias"]
721
+ else:
722
+ self.alias_by_name[name] = name
723
+
724
+ return True
725
+ else:
726
+ return self.alias_by_name[
727
+ name
728
+ ] # this is the special case that the name is actually variable name
729
+
730
+ def convertNames(self, names: list):
731
+ """
732
+ Convert a list of name to a list of alias
733
+ Args:
734
+ Names (list): a list of name
735
+
736
+ Returns:
737
+ Names (list): a list of name from alias
738
+ """
739
+
740
+ for i in range(len(names)):
741
+ if names[i].strip() in [
742
+ "Date",
743
+ "Time",
744
+ "Seconds",
745
+ ]: # not consider x-axis labels
746
+ pass
747
+ else:
748
+ names[i] = self.getAlias(names[i].strip()) # strip white space in case
749
+
750
+ return names
751
+
752
+ ### =======================================================Status Related=======================================================
753
+ def setLEDIndicator(self):
754
+ """
755
+ Set indicators based on the number of devices and status of devices
756
+ """
757
+ self.status_names = {} # the list to store what need to be shown as status
758
+ layout = QVBoxLayout()
759
+ self.indicator_dictionary = {}
760
+ self.status = {False: red, True: green}
761
+ for device in ["Logger"] + list(self.device_config):
762
+ led = QCheckBox(device)
763
+ led.setCheckable(False)
764
+ led.setStyleSheet(self.status[False])
765
+ layout.addWidget(led)
766
+ self.indicator_dictionary[device] = led
767
+ self.status_names[device] = []
768
+ if device != "Logger":
769
+ for name, info in self.commands[device].items():
770
+ if info['indicators']:
771
+ self.status_names[device].append(name)
772
+ for indicator in info['indicators']:
773
+ led = QCheckBox(indicator)
774
+ led.setCheckable(False)
775
+ led.setStyleSheet(self.status[False])
776
+ layout.addWidget(led)
777
+ self.indicator_dictionary[indicator] = led
778
+ self.groupBox_6.setLayout(layout)
779
+
780
+ def setStatus(self):
781
+ """
782
+ Set indicator values of devices
783
+ """
784
+ current_exe = (
785
+ os.popen("wmic process get description")
786
+ .read()
787
+ .strip()
788
+ .replace(" ", "")
789
+ .split("\n\n")
790
+ )
791
+ logger_status_change = ("SpecLogger_running.exe" in current_exe) != (self.indicator_dictionary["Logger"].styleSheet() == self.status[True])
792
+ if logger_status_change:
793
+ self.indicator_dictionary["Logger"].setStyleSheet(self.status["SpecLogger_running.exe" in current_exe])
794
+
795
+ for device in self.device_config:
796
+ if "SpecLogger_running.exe" in current_exe:
797
+ change_detected = self.device_config[device]["device_status"] != (self.indicator_dictionary[device].styleSheet() == self.status[True])
798
+ if change_detected:
799
+ self.indicator_dictionary[device].setStyleSheet(self.status[self.device_config[device]["device_status"]])
800
+
801
+ if self.latest_data:
802
+ for name in self.status_names[device]:
803
+ indicators = self.commands[device][name]['indicators']
804
+ indicators_reverse = self.commands[device][name]['indicators_reverse']
805
+ status = self.convertStringtoBit(self.latest_data[self.getAlias('status')], len(indicators), True)
806
+ if status == -1:
807
+ for i, indicator in enumerate(indicators):
808
+ self.indicator_dictionary[indicator].setStyleSheet(self.status[False])
809
+ else:
810
+ for i, indicator in enumerate(indicators):
811
+ res = bool(eval(status[i])) if not indicators_reverse[i] else bool(1 - eval(status[i]))
812
+ self.indicator_dictionary[indicator].setStyleSheet(self.status[res])
813
+ else:
814
+ self.indicator_dictionary[device].setStyleSheet(self.status[False])
815
+ for name in self.status_names[device]:
816
+ indicators = self.commands[device][name]['indicators']
817
+ for indicator in indicators:
818
+ self.indicator_dictionary[indicator].setStyleSheet(self.status[False])
819
+
820
+ self.setSystemStatus()
821
+
822
+ def setSystemStatus(self):
823
+ """
824
+ Set System Status based on indicator values
825
+ """
826
+ for indicator in self.indicator_dictionary.values():
827
+ if 'red' in indicator.styleSheet():
828
+ temp_status = self.systemStatus.styleSheet().replace('green', 'red')
829
+ self.systemStatus.setStyleSheet(temp_status)
830
+
831
+ return False
832
+
833
+ for level in self.warning_level_by_name.values():
834
+ if level == 2:
835
+ temp_status = self.systemStatus.styleSheet().replace('green', 'red')
836
+ self.systemStatus.setStyleSheet(temp_status)
837
+ return False
838
+ elif level == 1:
839
+ if 'green' in indicator.styleSheet():
840
+ temp_status = self.systemStatus.styleSheet().replace('green', 'orange')
841
+ self.systemStatus.setStyleSheet(temp_status)
842
+ return False
843
+
844
+ temp_status = self.systemStatus.styleSheet().replace('red', 'green')
845
+ self.systemStatus.setStyleSheet(temp_status)
846
+
847
+ return True
848
+
849
+ ### ======================================================= Warning Related =======================================================
850
+ def getCommandInfoByName(self):
851
+ """
852
+ Get commands returns information: [min, max, static, bit_static]
853
+ """
854
+
855
+ self.command_info_by_name = {}
856
+ for device in self.device_config:
857
+ for name, info in self.commands[device].items():
858
+ name = self.getAlias(name) # convert to alias
859
+ self.command_info_by_name[name] = [None, None, None, None]
860
+ self.command_info_by_name[name][0] = info["min"]
861
+ self.command_info_by_name[name][1] = info["max"]
862
+ self.command_info_by_name[name][2] = info["static"]
863
+ if info['bit_static']:
864
+ self.command_info_by_name[name][3] = self.convertStringtoBit(info['bit_static'], info['bits'], True)
865
+
866
+ ### ======================================================= Warning Related =======================================================
867
+ def setWarningLevelByName(self):
868
+ """
869
+ Set warning level for system indicator
870
+ """
871
+
872
+ self.warning_level_by_name = {name: 0 for name in self.data_by_name}
873
+
874
+ def printWarning(self):
875
+ """
876
+ Print warning if the value is not in range
877
+ """
878
+ for name in self.all_data_by_name:
879
+ if name not in ["Date", "Time", "Seconds"]:
880
+ warning_level = 0
881
+ minimum = self.command_info_by_name[name][0]
882
+ maximum = self.command_info_by_name[name][1]
883
+ static = self.command_info_by_name[name][2]
884
+ if static:
885
+ if self.all_data_by_name[name][-1] != static:
886
+ warning_level = 2
887
+
888
+ if minimum:
889
+ if self.all_data_by_name[name][-1] < minimum:
890
+ warning_level = 2
891
+
892
+ elif self.all_data_by_name[name][-1] < 1.05 * minimum:
893
+ warning_level = 1
894
+
895
+ if maximum:
896
+ if self.all_data_by_name[name][-1] > maximum:
897
+ warning_level = 2
898
+ elif self.all_data_by_name[name][-1] > 0.95 * maximum:
899
+ warning_level = 1
900
+
901
+ if (
902
+ warning_level > 0 and self.warning_level_by_name[name] == 0
903
+ ): # warning begins
904
+ current_time = (
905
+ self.all_data_by_name["Date"][-1]
906
+ + " "
907
+ + self.all_data_by_name["Time"][-1]
908
+ )
909
+ self.warning_level_by_name[name] = warning_level
910
+ if warning_level == 1:
911
+ string = current_time + ": " + name + " is reaching limit"
912
+ elif warning_level == 2:
913
+ string = current_time + ": " + name + " exceeds limit"
914
+ self.warningText.appendPlainText(string)
915
+
916
+ elif (
917
+ warning_level == 0 and self.warning_level_by_name[name] > 0
918
+ ): # warning ends
919
+ current_time = (
920
+ self.all_data_by_name["Date"][-1]
921
+ + " "
922
+ + self.all_data_by_name["Time"][-1]
923
+ )
924
+ self.warning_level_by_name[name] = warning_level
925
+ string = current_time + ": " + name + " warning/error clean."
926
+ self.warningText.appendPlainText(string)
927
+
928
+ def clearWarning(self):
929
+ """
930
+ Clear warning information
931
+ """
932
+ for name in self.data_by_name:
933
+ if name not in ["Date", "Time", "Seconds"]:
934
+ self.warning_level_by_name[name] = 0
935
+ self.warningText.clear()
936
+
937
+ return True
938
+
939
+ ### ======================================================= Internal Used Functions ====================================================
940
+ def binary_search(self, array, value):
941
+ """
942
+ It is used for searching the index of closest value in a given array. The method is binary search, faster in large array
943
+
944
+ Please be notified that the array has been sorted already. The return is modified to get the index
945
+
946
+ Reference: https://www.geeksforgeeks.org/find-closest-number-array/
947
+ """
948
+ if value == None:
949
+ return None
950
+
951
+ n = len(array)
952
+ if value < array[0]:
953
+ return 0
954
+ elif value > array[-1]:
955
+ return n - 1
956
+
957
+ i = 0 # lower limit
958
+ j = n # upper limit
959
+
960
+ while i < j:
961
+ mid = (i + j) // 2
962
+
963
+ if array[mid] == value:
964
+ return mid
965
+
966
+ if value < array[mid]: # in the case value is on the left side of array
967
+ if (
968
+ mid > 0 and value > array[mid - 1]
969
+ ): # array[mid] > value > array[mid - 1]
970
+ return self.get_closest(mid - 1, mid, value, array)
971
+
972
+ j = mid # move upper limit to mid
973
+
974
+ else: # in the case value is on the right side of array
975
+ if (
976
+ mid < n - 1 and value < array[mid + 1]
977
+ ): # array[mid] < value < array[mid + 1]
978
+ return self.get_closest(1, mid + 1, value, array)
979
+
980
+ i = mid + 1 # move lower limit to mid
981
+
982
+ return mid
983
+
984
+ def get_closest(self, index1, index2, target, array):
985
+ if target - array[index1] >= array[index2] - target:
986
+ return index2
987
+ else:
988
+ return index1
989
+
990
+ def convertStringtoBit(self, string, bits = None, reverse = False):
991
+ '''
992
+ convert string to bit string, and optionally to trim it and reverse it
993
+ eg. if string is 301, the function will return 1100000001,
994
+ if bits is 9 and reverse is False, the function will return 100000001,
995
+ if reverse is True and bits is None, the function will return 1000000011,
996
+
997
+ Trimming will be applied before Reverse.
998
+
999
+ '''
1000
+ if isinstance(string, float): # nan
1001
+ return -1
1002
+
1003
+ if '0x' not in string:
1004
+ string = '0x' + string
1005
+
1006
+ out = bin(eval(string))
1007
+ out = out.replace('0b', '')
1008
+ if bits:
1009
+ out = out[-1 * bits:]
1010
+ if len(out) < bits:
1011
+ out = '0' * (bits - len(out)) + out
1012
+ if reverse:
1013
+ out = out[::-1]
1014
+
1015
+ return out
1016
+
1017
+
1018
+
1019
+ if __name__ == "__main__":
1020
+ app = QApplication([])
1021
+ window = MainWindow()
1022
+ window.show()
1023
+ app.exec()