commit 3e2a0416ccd605e0dd6682289fc07b694e12557a Author: turtlebasket Date: Tue Jul 13 23:25:37 2021 -0700 Initial Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c53e277 --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Local stuff to exclude +.vscode/ diff --git a/app.py b/app.py new file mode 100644 index 0000000..f470645 --- /dev/null +++ b/app.py @@ -0,0 +1,14 @@ +import sys +from PyQt5 import QtGui, QtWidgets, uic +from memdb import mem + +class AppWindow(QtWidgets.QMainWindow): + def __init__(self): + super(AppWindow, self).__init__() + uic.loadUi('app.ui', self) + self.show() + +if __name__ == '__main__': + app = QtWidgets.QApplication(sys.argv) + window = AppWindow() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/app.ui b/app.ui new file mode 100644 index 0000000..3adf916 --- /dev/null +++ b/app.ui @@ -0,0 +1,586 @@ + + + MainWindow + + + + 0 + 0 + 1091 + 580 + + + + CRDS Time Analyzer + + + QGroupBox: {font-style: bold; font-size: 8px } + + + + + + 260 + 0 + 821 + 561 + + + + + 8 + + + + 0 + + + + Raw Data + + + + + -1 + -1 + 811 + 531 + + + + + + + Groups + + + + + 0 + 0 + 651 + 531 + + + + + + + Time Constant + + + + + 0 + 0 + 651 + 531 + + + + + + + + + 10 + 70 + 241 + 141 + + + + + 8 + 75 + true + + + + Grouping Config + + + + + 10 + 20 + 221 + 111 + + + + 0 + + + + + + 0 + 0 + 221 + 71 + + + + + + + Minimum voltage out + + + + + + + 6 + + + 0.001000000000000 + + + + + + + + + + + 0 + 0 + 221 + 101 + + + + + + + Estimated pass time + + + + + + + 6 + + + 0.000600000000000 + + + + + + + Min peak height + + + + + + + 6 + + + 0.000400000000000 + + + + + + + Min peak prominence + + + + + + + 6 + + + 0.001200000000000 + + + + + + + Moving average size + + + + + + + 20 + + + + + + + + + + + + 60 + 220 + 151 + 28 + + + + + -1 + + + + * {font-size: 13px} + + + Correlate + + + + + + 10 + 320 + 241 + 171 + + + + + 8 + 75 + true + + + + Initial Fit Config + + + + + 10 + 80 + 221 + 89 + + + + + + + τ value + + + + + + + 6 + + + + + + + y0 value + + + + + + + 6 + + + + + + + a value + + + + + + + 6 + + + + + + + + + 10 + 20 + 221 + 51 + + + + + + + + 60 + 500 + 151 + 28 + + + + + -1 + + + + * {font-size: 13px} + + + Fit + + + + + + 10 + 10 + 241 + 51 + + + + + 8 + 75 + true + + + + Grouping Algorithm + + + + + 10 + 20 + 221 + 21 + + + + + VThreshold (V column required) + + + + + SpacedGroups + + + + + + + + 10 + 260 + 241 + 51 + + + + + 8 + 75 + true + + + + Peak Isolation Config + + + false + + + + + 9 + 20 + 221 + 25 + + + + + + + Individual Peak Range + + + + + + + 6 + + + + + + + + + + + 0 + 0 + 1091 + 21 + + + + + File + + + + + Help + + + + + + + + + Open CSV File + + + Ctrl+O + + + + + Quit + + + + + Export + + + Ctrl+E + + + + + GitHub Repo + + + + + Open LabView File + + + + + + spin_group_len + QDoubleSpinBox +
widgets
+
+ + spin_min_peakheight + QDoubleSpinBox +
widgets
+
+ + spin_min_peakprominence + QDoubleSpinBox +
widgets
+
+ + spin_moving_average_denom + QSpinBox +
widgets
+
+ + correlate_button + QPushButton +
widgets
+
+ + equation_view + QGraphicsView +
widgets
+
+ + fit_button + QPushButton +
widgets
+
+ + rawdata_graph + QWidget +
widgets
+ 1 +
+ + peaks_graph + QWidget +
widgets
+ 1 +
+ + timeconstant_graph + QWidget +
widgets
+ 1 +
+ + combo_grouping_algo + QComboBox +
widgets
+
+ + file_menu + QMenu +
widgets
+
+ + help_menu + QMenu +
widgets
+
+ + graph_tab + QTabWidget +
widgets
+ 1 +
+ + config_area + QStackedWidget +
widgets
+ 1 +
+ + spin_min_voltage + QDoubleSpinBox +
widgets
+
+
+ + +
diff --git a/assets/eq1.png b/assets/eq1.png new file mode 100644 index 0000000..634bd33 Binary files /dev/null and b/assets/eq1.png differ diff --git a/assets/eq2.png b/assets/eq2.png new file mode 100644 index 0000000..a6746de Binary files /dev/null and b/assets/eq2.png differ diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..24cd15f --- /dev/null +++ b/build.cmd @@ -0,0 +1,13 @@ +echo WARNING: THIS WILL OVERWRITE app.py WITH A CONVERTED VERSION OF THE .UI FILE. + +set /p INPUT=Continue? [y/n] + +If %INPUT%=="y" goto yes +If %INPUT%=="n" goto no + +:yes +pyuic5.exe -x .\app.ui -o app.py +exit + +:no +exit diff --git a/crds_calc.py b/crds_calc.py new file mode 100644 index 0000000..f2b66af --- /dev/null +++ b/crds_calc.py @@ -0,0 +1,112 @@ +import numpy as np +from scipy.signal import find_peaks, correlate + +def spaced_groups( + x_data: np.array, + y_data: np.array, + group_len: float, + peak_minheight: float, + peak_prominence: float, + sma_denom: int +): + """ + Use SpacedGroups algo to separate groups + + Returns 2D array of raw data; every other group + """ + + # Helpers + def t2i(t): # STATIC time to index + delta_t = abs(x_data[0] - t) + timestep = abs(x_data[1] - x_data[0]) + return int(delta_t / timestep) + + def t2i_range(t): # time to index RANGE (just get the delta) + timestep = abs(x_data[1] - x_data[0]) + return int(t / timestep) + + def moving_average(x, w): + return np.convolve(x, np.ones(w), 'valid') / w + + def isolate_group(i): + i_min = i - t2i_range(group_len) + i_max = i + t2i_range(group_len) + return y_data.tolist()[i_min:i_max] + + # Detect peaks w/ averaged data + x_data_av = np.delete(x_data, [range(int(sma_denom / 2))]) + x_data_av = np.delete(x_data_av, [range(len(x_data)-int((sma_denom / 2) - 1), len(x_data_av))]) + y_data_av = moving_average(y_data, sma_denom) + peak_indices = find_peaks(y_data_av, height=peak_minheight, prominence=peak_prominence) # Get indices of all peaks + + peaks = [] + for p in peak_indices[0]: # Get x-values of all peaks + peaks.append(x_data[p]) + + + # Group peaks together + peak_groups = [[]] + group_index = 0 + for i in range(len(peaks)): + item = peaks[i] + next_item = 0 + prev_item = item-10 + try: + next_item = peaks[i+1] + prev_item = peaks[i-1] + except: + pass + + peak_groups[group_index].append(item) + + # Removed overlapping peak checks; don't really need that anymore + if abs(next_item - item) >= group_len: # Check if far enough away for new group + peak_groups.append([]) + group_index += 1 + + for g in peak_groups: # empty group check + if len(g) == 0: + peak_groups.remove(g) + + peaks_init = [] # Get peaks[0] for every group + for g in peak_groups: + peaks_init.append(g[0]) + + # Isolate group data + + groups_raw = [] # NOTE: Only contains every other group + for p in peaks_init: + if peaks_init.index(p) % 2 == 0: + groups_raw.append(isolate_group(t2i(p))) + else: + pass + + return groups_raw + +def correlate_groups(groups_raw): + """ + Overlay groups using `scipy.correlate`. + + Returns 2D array of overlayed groups + """ + + # Compare groups (scipy correlate) + + group_base = np.array(groups_raw[0]) + groups_adjusted = [group_base] + for x in groups_raw[1:len(groups_raw)-1]: + # calculate how much to shift + corr = correlate(group_base, np.array(x)) + shift = corr.tolist().index(max(corr)) + + # adjust alignment to fit on top of base group + diff = shift - len(x) + if diff < 0: + for i in range(abs(diff)): + x.pop(0) + elif diff > 0: + x = [0 for i in range(abs(diff))] + x + + groups_adjusted.append(x) + + return groups_adjusted \ No newline at end of file diff --git a/memdb.py b/memdb.py new file mode 100644 index 0000000..108d253 --- /dev/null +++ b/memdb.py @@ -0,0 +1,10 @@ +from sqlitedict import SqliteDict + +class ModSqliteDict(SqliteDict): + def __init__(self): + + # Initialize in-memory db + self.filename = ':memory:' + super().__init__() + +mem = ModSqliteDict() \ No newline at end of file diff --git a/widgets.py b/widgets.py new file mode 100644 index 0000000..a24e57b --- /dev/null +++ b/widgets.py @@ -0,0 +1,243 @@ +import numpy as np +from pandas import read_csv +import matplotlib +matplotlib.use('Qt5Agg') +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg, NavigationToolbar2QT as NavigationToolbar +from matplotlib.figure import Figure +import crds_calc +import PyQt5 +from PyQt5 import QtWidgets, QtCore +from memdb import mem +import pathlib + +# Helper functions + +def display_warning(message: str): + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.warning) + msg.setText("Warning") + msg.setInformativeText(message) + msg.setWindowTitle("Warning") + msg.exec_() + +def display_error(message: str): + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Critical) + msg.setText("Error") + msg.setInformativeText(message) + msg.setWindowTitle("Error") + msg.exec_() + +# Create global object to send signals + +class Global(QtWidgets.QWidget): + grouping_algo_changed = QtCore.pyqtSignal() + csv_selected = QtCore.pyqtSignal() + correlation_complete = QtCore.pyqtSignal() + fitting_complete = QtCore.pyqtSignal() +globj = Global() + +# Menu definitions + +class file_menu(QtWidgets.QMenu): + def __init__(self, x): + super().__init__(x) + open_csv = QtWidgets.QAction("Open CSV File", self) + open_csv.setShortcut("Ctrl+O") + open_csv.triggered.connect(self.select_csv) + self.addAction(open_csv) + + def select_csv(self): + filename, _ = QtWidgets.QFileDialog.getOpenFileName(self) + data = read_csv(filename, comment="%", delimiter=";").to_numpy() + mem['x_data'] = data.transpose()[0] + mem['y_data'] = data.transpose()[1] + try: + mem['v_data'] = data[2].transpose() + except IndexError: + display_error('No voltage column detected. Functionality will be limited.') + + globj.csv_selected.emit() + +class help_menu(QtWidgets.QMenu): + def __init__(self, x): + super().__init__(x) + visit_repo = QtWidgets.QAction("Go to GitHub Repo", self) + visit_repo.triggered.connect(self.go_to_repo) + self.addAction(visit_repo) + + def go_to_repo(self): + PyQt5.QtGui.QDesktopServices.openUrl(QtCore.QUrl('https://github.com/turtlebasket/crds-time-analyzer')) + +# Inputs / Parameter boxes + +class combo_grouping_algo(QtWidgets.QComboBox): + def change(self): + mem['grouping_algo'] = self.currentIndex() + globj.grouping_algo_changed.emit() + def __init__(self, x): + super().__init__(x) + self.currentIndexChanged.connect(self.change) + +class config_area(QtWidgets.QStackedWidget): + def __init__(self, x): + super().__init__(x) + self.setCurrentIndex(0) + globj.grouping_algo_changed.connect(lambda: self.setCurrentIndex(mem['grouping_algo'])) + +class spin_min_voltage(QtWidgets.QDoubleSpinBox): + def changeVal(self, val): + mem['min_voltage'] = float(val) + def __init__(self, x): + super().__init__(x) + mem['min_voltage'] = float(self.value()) + self.textChanged.connect(self.changeVal) + +class spin_group_len(QtWidgets.QDoubleSpinBox): + def changeVal(self, val): + mem['group_len'] = float(val) + def __init__(self, x): + super().__init__(x) + mem['group_len'] = float(self.value()) + self.textChanged.connect(self.changeVal) + +class spin_peak_len(QtWidgets.QDoubleSpinBox): + def changeVal(self, val): + mem['peak_len'] = float(val) + def __init__(self, x): + super().__init__(x) + mem['peak_len'] = float(self.value()) + self.textChanged.connect(self.changeVal) + +class spin_min_peakheight(QtWidgets.QDoubleSpinBox): + def changeVal(self, val): + mem['peak_minheight'] = float(val) + def __init__(self, x): + super().__init__(x) + mem['peak_minheight'] = float(self.value()) + self.textChanged.connect(self.changeVal) + +class spin_min_peakprominence(QtWidgets.QDoubleSpinBox): + def changeVal(self, val): + mem['peak_prominence'] = float(val) + def __init__(self, x): + super().__init__(x) + mem['peak_prominence'] = float(self.value()) + self.textChanged.connect(self.changeVal) + +class spin_moving_average_denom(QtWidgets.QSpinBox): + def changeVal(self, val): + mem['moving_avg_denom'] = int(val) + print(isinstance(val, str)) + def __init__(self, x): + super().__init__(x) + mem['moving_avg_denom'] = float(self.value()) + self.textChanged.connect(self.changeVal) + +class equation_view(QtWidgets.QGraphicsView): + def __init__(self, x): + super().__init__(x) + pix = PyQt5.QtGui.QPixmap(f"{pathlib.Path(__file__).parent.resolve()}/assets/eq2.png") + item = QtWidgets.QGraphicsPixmapItem(pix) + item.setScale(0.15) + scene = QtWidgets.QGraphicsScene() + scene.addItem(item) + self.setScene(scene) + +class correlate_button(QtWidgets.QPushButton): + def calc(self): + groups_raw = None + try: + if (mem['grouping_algo'] == 0): + display_error('VThreshold not yet implemented.') + elif (mem['grouping_algo'] == 1): + groups_raw = crds_calc.spaced_groups( + mem['x_data'], + mem['y_data'], + mem['group_len'], + mem['peak_minheight'], + mem['peak_prominence'], + mem['moving_avg_denom'] + ) + + mem['groups_correlated'] = crds_calc.correlate_groups(groups_raw) + globj.correlation_complete.emit() + + except KeyError: + display_error('Failed to correlate. Did you import a data file & set parameters?') + + + def __init__(self, x): + super().__init__(x) + self.pressed.connect(self.calc) + +class fit_button(QtWidgets.QPushButton): + def fit(self): + print("hi") + def __init__(self, x): + super().__init__(x) + self.pressed.connect(self.fit) + + +# Graph stuff + +class graph_tab(QtWidgets.QTabWidget): + def __init__(self, x): + super().__init__(x) + globj.csv_selected.connect(lambda: self.setCurrentIndex(0)) + globj.correlation_complete.connect(lambda: self.setCurrentIndex(1)) + globj.fitting_complete.connect(lambda: self.setCurrentIndex(2)) + +class MplCanvas(FigureCanvasQTAgg): + def __init__(self, parent=None, width=5, height=4, dpi=100): + fig = Figure(figsize=(width, height), dpi=dpi) + self.axes = fig.add_subplot(111) + fig.tight_layout() + super(MplCanvas, self).__init__(fig) + +class base_graph(QtWidgets.QWidget): + """ + Widget with embedded matplotlib graph & navigation toolbar + + Reference: https://www.mfitzp.com/tutorials/plotting-matplotlib/ + """ + + canv = None + + def __init__(self, x): + super().__init__(x) + self.canv = MplCanvas(self) + # Example + # canv.axes.plot([0,1,2,3,4], [10,1,20,3,40]) + toolbar = NavigationToolbar(self.canv, self) + layout = QtWidgets.QVBoxLayout() + layout.addWidget(toolbar) + layout.addWidget(self.canv) + self.setLayout(layout) + + def plot(self): + self.canv.axes.plot(mem['x_data'], mem['y_data']) + + def plot_full(self): + self.canv.axes.clear() + self.plot() + self.canv.draw() + print("attempted plot") + +class rawdata_graph(base_graph): + def __init__(self, x): + super().__init__(x) + globj.csv_selected.connect(self.plot_full) + +class peaks_graph(base_graph): + def __init__(self, x): + super().__init__(x) + globj.correlation_complete.connect(self.plot_full) + + def plot(self): + for i in mem['groups_correlated']: + self.canv.axes.plot(i) + + +class timeconstant_graph(base_graph): + pass \ No newline at end of file