-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.py
1881 lines (1570 loc) · 81.3 KB
/
main.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Linecraft - Frequency response display and statistics tool
# Copyright (C) 2024 - Kerem Basaran
# https://github.com/kbasaran
__email__ = "[email protected]"
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from difflib import SequenceMatcher
from dataclasses import dataclass, fields
# from matplotlib.backends.qt_compat import QtWidgets as qtw
from PySide6 import QtWidgets as qtw
from PySide6 import QtCore as qtc
from PySide6 import QtGui as qtg
from generictools.graphing_widget import MatplotlibWidget
# https://matplotlib.org/stable/gallery/user_interfaces/embedding_in_qt_sgskip.html
from generictools import signal_tools
import generictools.personalized_widgets as pwi
import pyperclip # must install xclip on Linux together with this!!
from functools import partial
import matplotlib as mpl
from tabulate import tabulate
from io import StringIO
import pickle
import logging
import time
app_definitions = {"app_name": "Linecraft",
"version": "0.2.4",
# "version": "Test build " + today.strftime("%Y.%m.%d"),
"description": "Linecraft - Frequency response plotting and statistics",
"copyright": "Copyright (C) 2024 Kerem Basaran",
"icon_path": str(Path("./logo/icon.ico")),
"author": "Kerem Basaran",
"author_short": "kbasaran",
"email": "[email protected]",
"website": "https://github.com/kbasaran",
}
@dataclass
class Settings:
global logger
app_name: str = app_definitions["app_name"]
author: str = app_definitions["author"]
author_short: str = app_definitions["author_short"]
version: str = app_definitions["version"]
GAMMA: float = 1.401 # adiabatic index of air
P0: int = 101325
RHO: float = 1.1839 # 25 degrees celcius
Kair: float = 101325. * RHO
c_air: float = (P0 * GAMMA / RHO)**0.5
f_min: int = 10
f_max: int = 3000
ppo: int = 48 * 8
FS: int = 48000
A_beep: float = 0.25
last_used_folder: str = str(Path.home())
show_legend: bool = True
max_legend_size: int = 10
import_ppo: int = 96
export_ppo: int = 96
processing_selected_tab: int = 0
mean_selected: bool = False
median_selected: bool = True
smoothing_type: int = 0
smoothing_resolution_ppo: int = 96
smoothing_bandwidth: int = 6
outlier_fence_iqr: float = 10.
outlier_action: int = 0
matplotlib_style: str = "bmh"
processing_interpolation_ppo: int = 96
interpolate_must_contain_hz: int = 1000
graph_grids: str = "default"
best_fit_calculation_resolution_ppo: int = 24
best_fit_critical_range_start_freq: int = 200
best_fit_critical_range_end_freq: int = 5000
best_fit_critical_range_weight: int = 1
import_table_no_line_headers: int = 1
import_table_no_columns: int = 1
import_table_layout_type: int = 0
import_table_delimiter: int = 0
import_table_decimal_separator: int = 0
def __post_init__(self):
settings_storage_title = self.app_name + " - " + (self.version.split(".")[0] if "." in self.version else "")
self.settings_sys = qtc.QSettings(
self.author_short, settings_storage_title)
self.read_all_from_registry()
def update(self, attr_name, new_val):
if new_val is None:
return
elif type(getattr(self, attr_name)) != type(new_val):
logger.warning(f"Settings.update: Received value type {type(new_val)} does not match the original type {type(getattr(self, attr_name))}"
f"\nValue: {new_val}")
setattr(self, attr_name, new_val)
self.settings_sys.setValue(attr_name, getattr(self, attr_name))
def write_all_to_registry(self):
for field in fields(self):
value = getattr(self, field.name)
# convert tuples to list for Qt compatibility
value = list(value) if isinstance(value, tuple) else value
self.settings_sys.setValue(field.name, value)
def read_all_from_registry(self):
for field in fields(self):
try:
value_raw = self.settings_sys.value(field.name, field.default)
value = field.type(value_raw)
except (TypeError, ValueError):
value = field.default
setattr(self, field.name, value)
def as_dict(self):
settings = {}
for field in fields(self):
settings[field] = getattr(self, field.name)
return settings
def __repr__(self):
return str(self.as_dict())
# Guide for special comments
# https://docs.spyder-ide.org/current/panes/outline.html
def find_longest_match_in_name(names: list) -> str:
"""
https://stackoverflow.com/questions/58585052/find-most-common-substring-in-a-list-of-strings
https://www.adamsmith.haus/python/docs/difflib.SequenceMatcher.get_matching_blocks
Parameters
----------
names : list
A list of names, each one a string.
Returns
-------
max_occurring_substring : str
The piece of string that accurs most commonly in the received list of names.
"""
substring_counts = {}
names_list = list(names)
for i in range(0, len(names)):
for j in range(i+1, len(names)):
string1 = str(names_list[i])
string2 = str(names_list[j])
match = SequenceMatcher(None, string1, string2).find_longest_match(
0, len(string1), 0, len(string2))
matching_substring = string1[match.a:match.a+match.size]
if (matching_substring not in substring_counts):
substring_counts[matching_substring] = 1
else:
substring_counts[matching_substring] += 1
# max() looks at the output of get method
max_occurring_key = max(substring_counts, key=substring_counts.get)
for char in " - - ":
max_occurring_key = max_occurring_key.strip(char)
return max_occurring_key
class CurveAnalyze(qtw.QMainWindow):
global settings, app_definitions, logger
signal_good_beep = qtc.Signal()
signal_bad_beep = qtc.Signal()
signal_user_settings_changed = qtc.Signal()
signal_table_import_successful = qtc.Signal()
signal_table_import_fail = qtc.Signal()
signal_table_import_busy = qtc.Signal()
# ---- Signals to the graph
# signal_update_figure_request = qtc.Signal(object) # failed to pass all the args and kwargs
# in an elegant way
signal_update_labels_request = qtc.Signal(object) # it is in fact a dict but PySide6 has a bug for passing dict
signal_reset_colors_request = qtc.Signal()
signal_remove_curves_request = qtc.Signal(list)
# signal_update_visibility_request = qtc.Signal(object) # it is in fact a dict but PySide6 has a bug for passing dict
signal_reposition_curves_request = qtc.Signal(object) # it is in fact a dict but PySide6 has a bug for passing dict
signal_flash_curve_request = qtc.Signal(int)
signal_toggle_reference_curve_request = qtc.Signal(object) # it is a list or a NoneType
# signal_add_line_request = qtc.Signal(list, object) # failed to pass all the args and kwargs
# in an elegant way
def __init__(self):
super().__init__()
self.setWindowTitle(app_definitions["app_name"])
self._create_core_objects()
self._create_menu_bar()
self._create_widgets()
self._place_widgets()
self._make_connections()
def keyPressEvent(self, keyEvent):
# overwriting method that was inherited from class
# Sequence names: https://doc.qt.io/qtforpython-6/PySide6/QtGui/QKeySequence.html
# Strange that F2 is not available as 'Rename'
if keyEvent.matches(qtg.QKeySequence.Delete):
self.remove_curves()
if keyEvent.matches(qtg.QKeySequence.Cancel):
self.qlistwidget_for_curves.setCurrentRow(-1)
def _create_core_objects(self):
self._interactable_widgets = dict() # a dictionary of QWidgets that users interact with
self.curves = [] # frequency response curves. THIS IS THE SINGLE SOURCE OF TRUTH FOR CURVE DATA.
def _create_menu_bar(self):
menu_bar = self.menuBar()
file_menu = menu_bar.addMenu("File")
load_action = file_menu.addAction("Load state..", self.pick_a_file_and_load_state_from_it)
save_action = file_menu.addAction("Save state..", self.save_state_to_file)
edit_menu = menu_bar.addMenu("Edit")
settings_action = edit_menu.addAction("Settings..", self.open_settings_dialog)
help_menu = menu_bar.addMenu("Help")
about_action = help_menu.addAction("About", self.open_about_menu)
def _create_widgets(self):
# ---- Create graph and buttons widget
self.graph = MatplotlibWidget(settings)
self.graph_buttons = pwi.PushButtonGroup(
{
"import_curve": "Import curve",
"import_table": "Import table",
"auto_import": "Auto import",
"reset_indexes": "Reset indexes",
"reset_colors": "Reset colors",
"remove": "Remove",
"rename": "Rename",
"move_up": "Move up",
"move_to_top": "Move to top",
"hide": "Hide",
"show": "Show",
"set_reference": "Set reference",
"processing": "Processing",
"export_curve": "Export curve",
},
{"import_curve": "Import 2D curve from clipboard",
"auto_import": "Attempt an import whenever new data is found on the clipboard.",
},
)
# Add the widgets that users interact with into the dictionary
self.graph_buttons.add_elements_to_dict(self._interactable_widgets)
# ---- Set types and states for buttons
self._interactable_widgets["auto_import_pushbutton"].setCheckable(True)
self._interactable_widgets["set_reference_pushbutton"].setCheckable(True)
# ---- Create list widget
self.qlistwidget_for_curves = qtw.QListWidget()
self.qlistwidget_for_curves.setSelectionMode(
qtw.QAbstractItemView.ExtendedSelection)
# self.qlistwidget_for_curves.setDragDropMode(qtw.QAbstractItemView.InternalMove) # crashes the application
def _place_widgets(self):
self.setCentralWidget(qtw.QWidget())
self.centralWidget().setLayout(qtw.QVBoxLayout())
# self.layout().setSpacing(0)
self.centralWidget().layout().addWidget(self.graph, 3)
self.centralWidget().layout().addWidget(self.graph_buttons)
self.centralWidget().layout().addWidget(self.qlistwidget_for_curves, 1)
# set size policies
self.graph.setSizePolicy(
qtw.QSizePolicy.Expanding, qtw.QSizePolicy.MinimumExpanding)
def _make_connections(self):
# ---- All the buttons
self._interactable_widgets["remove_pushbutton"].clicked.connect(
self.remove_curves)
self._interactable_widgets["reset_indexes_pushbutton"].clicked.connect(
self.reset_indexes)
self._interactable_widgets["reset_colors_pushbutton"].clicked.connect(
self.reset_colors_of_curves)
self._interactable_widgets["rename_pushbutton"].clicked.connect(
self._rename_curve_clicked)
self._interactable_widgets["move_up_pushbutton"].clicked.connect(
self.move_up_1)
self._interactable_widgets["move_to_top_pushbutton"].clicked.connect(
self.move_to_top)
self._interactable_widgets["hide_pushbutton"].clicked.connect(
self.hide_curves)
self._interactable_widgets["show_pushbutton"].clicked.connect(
self.show_curves)
self._interactable_widgets["export_curve_pushbutton"].clicked.connect(
self._export_curve)
self._interactable_widgets["auto_import_pushbutton"].toggled.connect(
self._auto_importer_status_toggle)
self._interactable_widgets["set_reference_pushbutton"].toggled.connect(
self.reference_curve_status_toggle)
self._interactable_widgets["processing_pushbutton"].clicked.connect(
self.open_processing_dialog)
self._interactable_widgets["import_curve_pushbutton"].clicked.connect(
self.import_single_curve)
self._interactable_widgets["import_table_pushbutton"].clicked.connect(
self._import_table_clicked)
# ---- Double click for highlighting/flashing a curve
self.qlistwidget_for_curves.itemActivated.connect(self._flash_curve)
# ---- Signals to Matplolib graph
# self.signal_update_figure_request.connect(self.graph.update_figure)
self.signal_reposition_curves_request.connect(self.graph.change_lines_order)
self.signal_flash_curve_request.connect(self.graph.flash_curve)
self.signal_update_labels_request.connect(self.graph.update_labels_and_visibilities)
self.signal_user_settings_changed.connect(self.graph.set_grid_type)
self.signal_reset_colors_request.connect(self.graph.reset_colors)
self.signal_remove_curves_request.connect(self.graph.remove_multiple_line2d)
self.signal_toggle_reference_curve_request.connect(self.graph.toggle_reference_curve)
# self.signal_add_line_request.connect(self.graph.add_line2d)
# self.signal_update_visibility_request.connect(self.graph.update_labels_and_visibilities)
# ---- Signals from Matplotlib graph
self.graph.signal_good_beep.connect(self.signal_good_beep)
self.graph.signal_bad_beep.connect(self.signal_bad_beep)
self.graph.signal_is_reference_curve_active.connect(self._interactable_widgets["set_reference_pushbutton"].setChecked)
# Disable some buttons when there is a reference curve active
self.graph.signal_is_reference_curve_active.connect(lambda x: self._interactable_widgets["processing_pushbutton"].setEnabled(not x))
self.graph.signal_is_reference_curve_active.connect(lambda x: self._interactable_widgets["export_curve_pushbutton"].setEnabled(not x))
# Import table dialog good/bad beeps
self.signal_table_import_successful.connect(self.signal_good_beep)
self.signal_table_import_fail.connect(self.signal_bad_beep)
def _export_curve(self):
"""Paste selected curve(s) to clipboard in a table."""
if self.return_false_and_beep_if_no_curve_selected():
return
elif len(self.qlistwidget_for_curves.selectedItems()) > 1:
message_box = qtw.QMessageBox(qtw.QMessageBox.Information,
"Feature not Implemented",
"Can only export one curve at a time.",
)
message_box.setStandardButtons(qtw.QMessageBox.Ok)
message_box.exec()
return
else:
curve = self.get_selected_curves()[0]
if settings.export_ppo == 0:
xy_export = np.transpose(curve.get_xy(ndarray=True))
else:
x_intp, y_intp = signal_tools.interpolate_to_ppo(
*curve.get_xy(),
settings.export_ppo,
settings.interpolate_must_contain_hz,
)
if signal_tools.arrays_are_equal((x_intp, curve.get_xy()[0])):
xy_export = np.transpose(curve.get_xy(ndarray=True))
else:
xy_export = np.column_stack((x_intp, y_intp))
pd.DataFrame(xy_export).to_clipboard(
excel=True, index=False, header=False)
self.signal_good_beep.emit()
def _get_curve_from_clipboard(self):
"""Read a signal_tools.Curve object from clipboard."""
data = pyperclip.paste()
new_curve = signal_tools.Curve(data)
if new_curve.is_curve():
return new_curve
else:
print(f"Unrecognized curve object for data:\n{data}")
return None
def get_selected_curve_indexes(self) -> list:
"""Get a list of indexes for the curves currently selected in the list widget. MAY NOT BE SORTED!"""
selected_list_items = self.qlistwidget_for_curves.selectedItems()
indexes = [self.qlistwidget_for_curves.row(
list_item) for list_item in selected_list_items]
return indexes
def get_selected_curves(self, as_dict: bool = False) -> (list, dict):
"""May NOT be SORTED"""
selected_indexes = self.get_selected_curve_indexes()
if as_dict:
return {i: self.curves[i] for i in selected_indexes}
else:
return [self.curves[i] for i in selected_indexes]
def get_selected_curves_sorted(self) -> list:
curves = self.get_selected_curves(as_dict=True)
return sorted(curves.items()).values()
def count_selected_curves(self) -> int:
selected_indexes = self.get_selected_curve_indexes()
return len(selected_indexes)
def return_false_and_beep_if_no_curve_selected(self) -> bool:
if self.qlistwidget_for_curves.selectedItems():
return False
else:
self.signal_bad_beep.emit()
return True
def _move_curve_up(self, i_insert: int):
"""Move curve up to index 'i_insert'"""
selected_indexes_and_curves = self.get_selected_curves(as_dict=True)
new_order_of_qlist_items = [*range(len(self.curves))]
# each number in the list is the index before location change. index in the list is the new location.
for i_within_selected, (i_before, curve) in enumerate(sorted(selected_indexes_and_curves.items())):
# i_within_selected is the index within the selected curves
# i_before is the index on the complete curves list
i_after = i_insert + i_within_selected
if i_before < i_after:
raise IndexError("This function can only move the item higher up in the list.")
# update the self.curves list (single source of truth)
curve = self.curves.pop(i_before)
self.curves.insert(i_after, curve)
# update the QListWidget
new_list_item = qtw.QListWidgetItem(curve.get_full_name())
if not curve.is_visible():
font = new_list_item.font()
font.setWeight(qtg.QFont.Thin)
new_list_item.setFont(font)
self.qlistwidget_for_curves.insertItem(i_after, new_list_item)
self.qlistwidget_for_curves.takeItem(i_before + 1)
# update the changes dictionary to send to the graph
new_order_of_qlist_items.insert(i_after, new_order_of_qlist_items.pop(i_before))
new_indexes_of_qlist_items = dict(zip(new_order_of_qlist_items, range(len(self.curves))))
# send the changes dictionary to the graph
self.signal_reposition_curves_request.emit(new_indexes_of_qlist_items)
def move_up_1(self):
if self.return_false_and_beep_if_no_curve_selected():
return
selected_indexes = self.get_selected_curve_indexes()
i_insert = max(0, selected_indexes[0] - 1)
self._move_curve_up(i_insert)
if len(selected_indexes) == 1:
self.qlistwidget_for_curves.setCurrentRow(i_insert)
def move_to_top(self):
if self.return_false_and_beep_if_no_curve_selected():
return
self._move_curve_up(0)
self.qlistwidget_for_curves.setCurrentRow(-1)
def reset_indexes(self):
"""Reset the indexes that are stored in the signal_tools.Curve objects and shown as prefix of the name"""
if not len(self.curves):
self.signal_bad_beep.emit()
else:
new_labels = {}
for i, curve in enumerate(self.curves):
# print(i, curve.get_full_name())
curve.set_name_prefix(f"#{i:02d}")
self.qlistwidget_for_curves.item(i).setText(curve.get_full_name())
new_labels[i] = (curve.get_full_name(), curve.is_visible())
self.signal_update_labels_request.emit(new_labels)
def reset_colors_of_curves(self):
"""Reset the colors for the graph curves with ordered standard colors"""
if not len(self.curves):
self.signal_bad_beep.emit()
else:
self.signal_reset_colors_request.emit()
def _rename_curve_clicked(self):
"""Update the base name and suffix. Does not modify the index part (the prefix in Curve object)."""
new_labels = {}
if self.return_false_and_beep_if_no_curve_selected():
return
# ---- Multiple curves. Can only add a common suffix.
elif len(self.qlistwidget_for_curves.selectedItems()) > 1:
indexes_and_curves = self.get_selected_curves(as_dict=True)
text, ok = qtw.QInputDialog.getText(self,
"Add suffix to multiple names",
"Add suffix:", qtw.QLineEdit.Normal,
"",
)
if not ok or text == '':
return
for index, curve in indexes_and_curves.items():
curve.add_name_suffix(text)
list_item = self.qlistwidget_for_curves.item(index)
list_item.setText(curve.get_full_name())
new_labels[index] = (curve.get_full_name(), curve.is_visible())
# ---- Single curve. Edit base name and suffixes into a new base name
else:
index = self.qlistwidget_for_curves.currentRow()
curve = self.curves[index]
text, ok = qtw.QInputDialog.getText(self,
"Change curve name",
"New name:", qtw.QLineEdit.Normal,
curve.get_base_name_and_suffixes(),
)
if not ok or text == '':
return
curve.clear_name_suffixes()
curve.set_name_base(text)
list_item = self.qlistwidget_for_curves.item(index)
list_item.setText(curve.get_full_name())
new_labels[index] = (curve.get_full_name(), curve.is_visible())
self.graph.update_labels_and_visibilities(new_labels)
def import_single_curve(self, curve: signal_tools.Curve = None):
if not curve:
clipboard_curve = self._get_curve_from_clipboard()
if clipboard_curve is None:
self.signal_bad_beep.emit()
return
else:
curve = clipboard_curve
if settings.import_ppo > 0:
x, y = curve.get_xy()
x_intp, y_intp = signal_tools.interpolate_to_ppo(
x, y,
settings.import_ppo,
settings.interpolate_must_contain_hz,
)
curve.set_xy((x_intp, y_intp))
if "clipboard_curve" in locals() or curve.is_curve():
i_insert = self._add_single_curve(None, curve)
self.qlistwidget_for_curves.setCurrentRow(i_insert)
self.signal_good_beep.emit()
else:
self.signal_bad_beep.emit()
def remove_curves(self, indexes: list = None):
if isinstance(indexes, (list, np.ndarray)):
if len(indexes) == 0: # received empty list
self.signal_bad_beep.emit()
return
else:
indexes_to_remove = indexes
elif not indexes:
if self.return_false_and_beep_if_no_curve_selected():
return
else:
indexes_to_remove = self.get_selected_curve_indexes()
for i in sorted(indexes_to_remove, reverse=True):
self.qlistwidget_for_curves.takeItem(i)
self.curves.pop(i)
self.signal_remove_curves_request.emit(indexes_to_remove)
def _import_table_clicked(self):
import_table_dialog = ImportDialog(parent=self)
import_table_dialog.signal_import_table_request.connect(self._import_table_requested)
self.signal_table_import_busy.connect(import_table_dialog.deactivate)
self.signal_table_import_successful.connect(import_table_dialog.reject)
self.signal_table_import_fail.connect(import_table_dialog.reactivate)
import_table_dialog.exec()
def _import_table_requested(self, source, import_settings):
start_time = time.perf_counter()
# ---- get the input
logger.info(f"Import table requested from {source}.")
logger.debug("Settings:" + str(settings))
if source == "file":
file_raw = qtw.QFileDialog.getOpenFileName(self, caption='Open CSV formatted file..',
dir=settings.last_used_folder,
filter='CSV format (*.txt *.csv)',
)[0]
if file_raw and (file := Path(file_raw)).is_file():
settings.update("last_used_folder", file.parent)
else:
return
elif source == "clipboard":
import_file = StringIO(pyperclip.paste())
# ---- setup how to read it
if import_settings["no_header"] == 0:
skiprows = None
header = None
else:
skiprows = [*range(import_settings["no_header"] - 1)
] if import_settings["no_header"] > 1 else None
header = 0
if import_settings["no_index"] == 0:
index_col = None
else:
index_col = import_settings["no_index"] - 1
# ---- read it
self.signal_table_import_busy.emit()
logger.debug(
("Attempting read_csv with settings:"
f"\ndelimiter: {import_settings['delimiter']}"
f"\ndecimal: {import_settings['decimal_separator']}"
f"\nskiprows: {skiprows}"
f"\nheader: {header}"
f"\nindex_col: {index_col}")
)
try:
df = pd.read_csv(import_file,
delimiter=import_settings["delimiter"],
decimal=import_settings["decimal_separator"],
skiprows=skiprows,
header=header,
index_col=index_col,
# skip_blank_lines=True,
# encoding='unicode_escape',
skipinitialspace=True, # since we only have numbers
)
except IndexError as e:
logger.warning("IndexError: " + str(e))
self.signal_table_import_fail.emit() # always emit this first so the import dialog knows it didn't work
raise IndexError(
"Check your import settings and if all your rows and columns have the same length in the imported text.")
except pd.errors.EmptyDataError as e:
logger.warning("EmptyDataError: " + str(e))
self.signal_table_import_fail.emit()
return
logger.debug(
(f"Imported column names: {df.columns}"
f"\nImported index names: {df.index}"
f"\nWhole:\n{df}\n"
)
)
# ---- transpose if frequencies are in indexes
if import_settings["layout_type"] == 1:
df = df.transpose()
# ---- validate curve and header validity
try:
signal_tools.check_if_sorted_and_valid(tuple(df.columns)) # checking headers
df.columns = df.columns.astype(float)
except ValueError as e:
logger.warning("Failed to validate curve: " + str(e) + "\n" + str(df))
self.signal_table_import_fail.emit()
return
# ---- Validate size
if len(df.index) < 1:
logger.warning("Import does not have any curves to put on graph.")
self.signal_table_import_fail.emit()
return
# ---- validate datatype
try:
df = df.astype(float)
except ValueError as e:
logger.warning("Cannot convert table values to float: " + str(e))
self.signal_table_import_fail.emit()
raise ValueError("Your dataset contains values that could not be interpreted as numbers.")
logger.info(df.info)
# ---- put on the graph
for name, values in df.iterrows():
logger.debug(f"Attempting to add xy data of index {name} as curve.")
curve = signal_tools.Curve((df.columns, values))
if settings.import_ppo > 0:
x, y = curve.get_xy()
x_intp, y_intp = signal_tools.interpolate_to_ppo(
x, y,
settings.import_ppo,
settings.interpolate_must_contain_hz,
)
curve.set_xy((x_intp, y_intp))
curve.set_name_base(name)
_ = self._add_single_curve(None, curve, update_figure=False)
logger.info(f"Import of curves finished in {(time.perf_counter()-start_time)*1000:.4g}ms")
self.graph.update_figure()
self.signal_table_import_successful.emit()
def _auto_importer_status_toggle(self, checked: bool):
if checked == 1:
self.auto_importer = AutoImporter(self)
self.auto_importer.signal_new_import.connect(
self.import_single_curve)
self.auto_importer.start()
else:
self.auto_importer.requestInterruption()
def reference_curve_status_toggle(self, checked: bool):
"""
Reference curve is marked in the Curve class instances with "_visible"
Also in the graph object, there is an attribute to store if there is a reference and if so which one it is.
"""
if checked:
# Block precessing options
indexes_and_curves = self.get_selected_curves(as_dict=True)
if len(indexes_and_curves) == 1:
self._interactable_widgets["processing_pushbutton"].setEnabled(
False)
index, curve = list(indexes_and_curves.items())[0]
# mark it as reference
curve.add_name_suffix("reference")
curve.set_reference(True)
# Update the names in qlist widget
reference_item = self.qlistwidget_for_curves.item(index)
reference_item.setText(curve.get_full_name())
# Update graph
self.signal_toggle_reference_curve_request.emit([index, curve])
else:
# multiple selections
self._interactable_widgets["set_reference_pushbutton"].setChecked(False)
self.signal_bad_beep.emit()
elif not checked:
# find back the reference curve
reference_curves = [(index, curve) for index, curve in enumerate(
self.curves) if curve.is_reference()]
if len(reference_curves) == 0:
pass
elif len(reference_curves) > 1:
raise ValueError(
"Multiple reference curves are in the list somehow..")
else:
index, curve = reference_curves[0]
# revert it
curve.remove_name_suffix("reference")
curve.set_reference(False)
# Update the names in list
reference_item = self.qlistwidget_for_curves.item(index)
reference_item.setText(curve.get_full_name())
# Update graph
self.signal_toggle_reference_curve_request.emit(None)
def _add_single_curve(self, i_insert: int, curve: signal_tools.Curve, update_figure: bool = True,
line2d_kwargs={},
):
if curve.is_curve():
i_max = len(self.curves)
if i_insert is None or i_insert >= i_max:
# do an add
if not curve.has_name_prefix():
curve.set_name_prefix(f"#{i_max:02d}")
self.curves.append(curve)
list_item = qtw.QListWidgetItem(curve.get_full_name())
if not curve.is_visible():
font = list_item.font()
font.setWeight(qtg.QFont.Thin)
list_item.setFont(font)
self.qlistwidget_for_curves.addItem(list_item)
self.graph.add_line2d(i_max, curve.get_full_name(), curve.get_xy(),
update_figure=update_figure, line2d_kwargs=line2d_kwargs,
)
return i_max
else:
# do an insert
curve.set_name_prefix(f"#{i_max:02d}")
self.curves.insert(i_insert, curve)
list_item = qtw.QListWidgetItem(curve.get_full_name())
if not curve.is_visible():
font = list_item.font()
font.setWeight(qtg.QFont.Thin)
list_item.setFont(font)
self.qlistwidget_for_curves.insertItem(i_insert, list_item)
self.graph.add_line2d(i_insert, curve.get_full_name(), curve.get_xy(
), update_figure=update_figure, line2d_kwargs=line2d_kwargs)
return i_insert
else:
raise ValueError("Invalid curve")
def hide_curves(self, indexes: list = None):
if isinstance(indexes, (list, np.ndarray)):
indexes_and_curves = {i: self.curves[i] for i in indexes}
elif self.return_false_and_beep_if_no_curve_selected():
return
else:
indexes_and_curves = self.get_selected_curves(as_dict=True)
for index, curve in indexes_and_curves.items():
item = self.qlistwidget_for_curves.item(index)
font = item.font()
font.setWeight(qtg.QFont.Thin)
item.setFont(font)
curve.set_visible(False)
self.update_visibilities_of_graph_curves(indexes_and_curves)
def show_curves(self, indexes: list = None):
if isinstance(indexes, (list, np.ndarray)):
indexes_and_curves = {i: self.curves[i] for i in indexes}
elif self.return_false_and_beep_if_no_curve_selected():
return
else:
indexes_and_curves = self.get_selected_curves(as_dict=True)
for index, curve in indexes_and_curves.items():
item = self.qlistwidget_for_curves.item(index)
font = item.font()
font.setWeight(qtg.QFont.Normal)
item.setFont(font)
curve.set_visible(True)
self.update_visibilities_of_graph_curves(indexes_and_curves)
def _flash_curve(self, item: qtw.QListWidgetItem):
index = self.qlistwidget_for_curves.row(item)
self.signal_flash_curve_request.emit(index)
def update_visibilities_of_graph_curves(self, indexes_and_curves=None, update_figure=True):
if not indexes_and_curves:
visibility_states = {i: (None, curve.is_visible())
for i, curve in enumerate(self.curves)}
else:
visibility_states = {i: (None, curve.is_visible())
for i, curve in indexes_and_curves.items()}
self.graph.update_labels_and_visibilities(visibility_states, update_figure=update_figure)
def open_processing_dialog(self):
if self.return_false_and_beep_if_no_curve_selected():
return
processing_dialog = ProcessingDialog(parent=self)
processing_dialog.signal_processing_request.connect(self._processing_dialog_return)
processing_dialog.exec()
def _processing_dialog_return(self, processing_function_name):
results = getattr(self, processing_function_name)()
to_beep = False
if "to_insert" in results.keys():
# sort the dict by highest key value first
for i_to_insert, curves in sorted(results["to_insert"].items(), reverse=True):
if isinstance(curves, (list, tuple)):
for curve in reversed(curves):
_ = self._add_single_curve(
i_to_insert, curve, update_figure=False, line2d_kwargs=results["line2d_kwargs"])
elif isinstance(curves, signal_tools.Curve):
curve = curves
_ = self._add_single_curve(
i_to_insert, curve, update_figure=False, line2d_kwargs=results["line2d_kwargs"])
else:
raise TypeError(f"Invalid data type to insert: {type(curves)}")
self.graph.update_figure()
to_beep = True
if "result_text" in results.keys():
result_text_box = pwi.ResultTextBox(results["title"], results["result_text"], parent=self)
result_text_box.show()
to_beep = True
if to_beep:
self.signal_good_beep.emit()
def _mean_and_median_analysis(self):
selected_curves = self.get_selected_curves()
length_curves = len(selected_curves)
if length_curves < 2:
raise ValueError(
"A minimum of 2 curves is needed for this analysis.")
curve_mean, curve_median = signal_tools.mean_and_median_of_curves(
[curve.get_xy() for curve in selected_curves]
)
representative_base_name = find_longest_match_in_name(
[curve.get_base_name_and_suffixes() for curve in selected_curves]
)
for curve in (curve_mean, curve_median):
curve.set_name_base(representative_base_name)
curve_mean.add_name_suffix(f"mean, {length_curves} curves")
curve_median.add_name_suffix(f"median, {length_curves} curves")
result_curves = []
if settings.mean_selected:
result_curves.append(curve_mean)
if settings.median_selected:
result_curves.append(curve_median)
line2d_kwargs = {"color": "k", "linestyle": "-"}
return {"to_insert": {0: result_curves}, "line2d_kwargs": line2d_kwargs}
def _outlier_detection(self):
selected_curves = self.get_selected_curves(as_dict=True)
length_curves = len(selected_curves)
if length_curves < 3:
raise ValueError(
"A minimum of 3 curves is needed for this analysis.")
curve_median, lower_fence, upper_fence, outlier_indexes = signal_tools.iqr_analysis(
{i: curve.get_xy() for i, curve in selected_curves.items()},
settings.outlier_fence_iqr,
)
result_curves = curve_median, lower_fence, upper_fence
representative_base_name = find_longest_match_in_name(
[curve.get_base_name_and_suffixes() for curve in selected_curves.values()]
)
for curve in result_curves:
curve.set_name_base(representative_base_name)
curve_median.add_name_suffix(f"median, {length_curves} curves")
lower_fence.add_name_suffix(f"-{settings.outlier_fence_iqr:.1f}xIQR, {length_curves} curves")
upper_fence.add_name_suffix(f"+{settings.outlier_fence_iqr:.1f}xIQR, {length_curves} curves")
if settings.outlier_action == 1 and outlier_indexes: # Hide
self.hide_curves(indexes=outlier_indexes)
for curve in result_curves:
curve.add_name_suffix("calculated before hiding outliers")
elif settings.outlier_action == 2 and outlier_indexes: # Remove
self.remove_curves(indexes=outlier_indexes)
for curve in result_curves:
curve.add_name_suffix("calculated before removing outliers")
line2d_kwargs = {"color": "k", "linestyle": "--"}
return {"to_insert": {0: result_curves}, "line2d_kwargs": line2d_kwargs}
def _show_best_fits(self):
selected_curves = self.get_selected_curves(as_dict=True)
if len(selected_curves) != 1:
warning = qtw.QMessageBox(qtw.QMessageBox.Warning,
"Multiple curves found in selection",
"For this operation you need to choose a single curve from the list.",
qtw.QMessageBox.Ok,
)
warning.exec()
return {}
else:
# ---- Collect curves
i_ref_curve, ref_curve = list(selected_curves.items())[0]
ref_freqs, ref_curve_interpolated = signal_tools.interpolate_to_ppo(
*ref_curve.get_xy(),
settings.best_fit_calculation_resolution_ppo,
settings.interpolate_must_contain_hz,
)
# ---- Calculate residuals squared
residuals_squared = {curve.get_full_name():