From 5ac86d408cd8c5201876fde44b5492d3994d0683 Mon Sep 17 00:00:00 2001 From: Martin Kudlacek Date: Mon, 10 Mar 2025 17:10:04 +0100 Subject: [PATCH] Verze po zapracovani prvni vlny pozadavku --- .gitignore | 30 +++ assets/icon.icns | Bin 0 -> 51344 bytes assets/icon.iconset/icon_128x128.png | Bin 0 -> 5369 bytes assets/icon.iconset/icon_16x16.png | Bin 0 -> 658 bytes assets/icon.iconset/icon_256x256.png | Bin 0 -> 10536 bytes assets/icon.iconset/icon_32x32.png | Bin 0 -> 1395 bytes assets/icon.iconset/icon_512x512.png | Bin 0 -> 22192 bytes assets/line-chart_24x24.png | Bin 0 -> 1007 bytes assets/line-chart_64x64.png | Bin 0 -> 2770 bytes build-config/macos_build.spec | 76 ++++++++ build-config/windows_build.spec | 58 ++++++ requirements.txt | 10 + src/__init__.py | 0 src/callbacks.py | 51 +++++ src/change_start_dialog.py | 86 +++++++++ src/channel.py | 64 +++++++ src/channel_calibration_dialog.py | 198 ++++++++++++++++++++ src/channels_menu.py | 63 +++++++ src/chart_menu.py | 29 +++ src/config.py | 37 ++++ src/data_menu.py | 112 +++++++++++ src/detektor_data.py | 266 +++++++++++++++++++++++++++ src/detektor_plot.py | 215 ++++++++++++++++++++++ src/detektor_region.py | 139 ++++++++++++++ src/generic_dialog.py | 27 +++ src/main.py | 26 +++ src/menubar.py | 190 +++++++++++++++++++ src/moving_average_dialog.py | 78 ++++++++ src/open_file.py | 74 ++++++++ src/parsers.py | 148 +++++++++++++++ src/quit_dialog.py | 62 +++++++ src/save_as_dialog.py | 33 ++++ src/widgets.py | 20 ++ src/window.py | 128 +++++++++++++ 34 files changed, 2220 insertions(+) create mode 100644 .gitignore create mode 100644 assets/icon.icns create mode 100644 assets/icon.iconset/icon_128x128.png create mode 100644 assets/icon.iconset/icon_16x16.png create mode 100644 assets/icon.iconset/icon_256x256.png create mode 100644 assets/icon.iconset/icon_32x32.png create mode 100644 assets/icon.iconset/icon_512x512.png create mode 100644 assets/line-chart_24x24.png create mode 100644 assets/line-chart_64x64.png create mode 100644 build-config/macos_build.spec create mode 100644 build-config/windows_build.spec create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/callbacks.py create mode 100644 src/change_start_dialog.py create mode 100644 src/channel.py create mode 100644 src/channel_calibration_dialog.py create mode 100644 src/channels_menu.py create mode 100644 src/chart_menu.py create mode 100644 src/config.py create mode 100644 src/data_menu.py create mode 100644 src/detektor_data.py create mode 100644 src/detektor_plot.py create mode 100644 src/detektor_region.py create mode 100644 src/generic_dialog.py create mode 100644 src/main.py create mode 100644 src/menubar.py create mode 100644 src/moving_average_dialog.py create mode 100644 src/open_file.py create mode 100644 src/parsers.py create mode 100644 src/quit_dialog.py create mode 100644 src/save_as_dialog.py create mode 100644 src/widgets.py create mode 100644 src/window.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc44f71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +.DS_Store +venvs/ +*.pyc +__pycache__/ +*.pyo +*.pyd +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +*.xls +*.xlsx +*.exe +*.dbf + +*.idea diff --git a/assets/icon.icns b/assets/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..d731efd5ae185d2e10a395fb16bd1a8496702fb2 GIT binary patch literal 51344 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 Oie*3mV>bU2!22&i^|pWj literal 0 HcmV?d00001 diff --git a/assets/icon.iconset/icon_128x128.png b/assets/icon.iconset/icon_128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..f89b57e7e3dccd3830808af19892f3cd322b5bf9 GIT binary patch literal 5369 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 literal 0 HcmV?d00001 diff --git a/assets/icon.iconset/icon_256x256.png b/assets/icon.iconset/icon_256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..0d8cbbeca00441f6596d9d87c8198e6b58ca6362 GIT binary patch literal 10536 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 qig9q#FC8OO$dsmFaHZTUB5E` literal 0 HcmV?d00001 diff --git a/assets/icon.iconset/icon_32x32.png b/assets/icon.iconset/icon_32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..c0ce73f30cc6f1ce7343695beb1744a59eb4b543 GIT binary patch literal 1395 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 literal 0 HcmV?d00001 diff --git a/assets/line-chart_64x64.png b/assets/line-chart_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..1c25aebe74d9ecd97295b85fed5879f65b2faef4 GIT binary patch literal 2770 zcmV;@3N7`CP)(Tw z@1FOale?UE-+e;beQ$wmW2q$$n0LIuq zl6kZK7l>|}c-)HNZG@OWoE=RJ?B|gF%|)NXP!^GH(sC8`!GYF^_*>k&2C;IbHxfg~ zD_VMh8~6kO;e!*!d1wiM*tW2A!6ltBq?rYSurVm&hCa{dQvSl0_LmC_)oi+g%SGuP zkq!&bzJ0tu?>2FV!?%Z~gmKf1yuNCz4QQ~GB>;?VmpYw&W!^kr+sm6SBVR3^*Ffz8 zLExkWrULol(4!InDoS=}&g3FN&y2LGsJz$Sc>u4EI%8v)Kx~92-9BM%`1G_~B`x=x z2yfD5@M|J;tOXovL}v~-4v|r#*D1uGkO8=-Rxebj0d}5nLm;n4V-3s!Vo{#;_W-p9 zFfwf;0K#gPEVvzrcnLlpu-EW3%7rM)DpM$K?g45C1_6Um&d2HOl074{PDh~R1G~gV z79!e`3fHrA!P&=F-qD3?fu3wB<$$PgZ)K8v3n$Vt7{_$rH>y-rB};Y7g~@H}x_kicUZQ6M#egMB#@tld)3&}#2cWP}jm@G0=z;QNMXJ2rp3|iRP=7g( z09OO69jEg5jyPR50O?CK5#=#pzkzWxE)h>hye^pvk6*0a-4=6!7=?-DDe~Vy+~ckX zxZcuk&T4f8vV!B|4`Tq{G}?HazqYG*`JO5>YKl)4hZ)y1cj!1&ws?&UwBA^j);C~~$)Mhb$)+qlQ;q2ww&+B15&=(k8 znIbiz>^2Vp+cw19fS4OFdQr5U*Q|nb1a1d517CsMYW3`2svzOCPp{SS&CVYbWwa3z z1wIBw`V61DYV;vzwc5NM%79aW7sGKLTDQeH$dcEVACY%qz(OYq7*UyqW&Wu)-@Ag2 zx3cqhFthlPU~Mz%bfPL#@jHKGMXIa-$g9yS_8A_tl!rukIzE96{Nw&hhnGKxtL>;y zf|~hYf{40+CSvTfq~iH+MNA6tqrhJ^YtjRO+NRg(gpQmCC`py3ie!1z2$uot@jKsO zDR<7eQNn&d7?l7xtJUGG){akxNK-n{Q{iQq{@iu|DJ%L+EEx^#SCMBlYf^gqX0BSz z6{Q4dRJf_F^KDU)ENjmCkfEwv>F{#S>{R(U9L+lZ{!`Rh=fxtVR3cx+fwU6=7x@Q3Ufq39Qmc?DON6Cg}2Wx?M%*^7e zI~12Q=?+zy3%EtBidJJ3`0m%=t@+oSg6<5Urrv%GpFtSVTu|-k>mQwd!;r0C zjQP{57qxn5+N7MEOWz2k!OF^iNz}>_q-8<%BC<|Z*BE3q_*Ro(UnfOH2Ls;wNQOYx z8+xYK@F~#ESX@~#NWQRMN1E4>!Z|elVW%Cuw zUkf^uX4L3)D!i%6qgAQ$@lfTpa+tv8p7avn zG3rYzn}5GkAOrYJ+}^8Y_OzWpv7bHpRz+V04v5HA?I+hr;9myS>Wff~P>s=5Tyss( zp;3`4D}0829Xao}>dq20!T5X?`@6-DQP`<4iAFxktkc#reCf5C)#5yGOL?lj)--I% z0pEPM3Lpt+QsEH;4K2>AFt#dLTF1N=6vXLXs}2|rtdKY>RZ2>l^xny5 zsaHP$`iYV!vx;BnjPr1Wb3ia?PX1_B79+f6%8IW$RH2eyM~*-dw4VBKD>$oCWYJIT zjLYkxab_hj6k(y*-I?lfyaNnTm|~k*To4VtQBQ?cnFt{0;Y**0R;9=c6@HJh3|M1? zQ5`uCAahIhh}V+}Y(yEO9>)e?2+AVUuq9yn94Vr@Bc$Uep05@oQR->NTBY?)Q`W7O;( zWke+aT3BdQH5b4b6ZQSXqlxJBfX%x`1HT1bE;EWgiHgmi2~jyLt*Ut-hy;G>`=z2e z74{9lSQ|0cMjQ^uZky z#zz_9Yc?n|{cZFW#@v?}o|vu!!RfT&J#{|lP0iPK0Edo}srE#zK8V?gNJ9vF4lLgQ zU_Ztd_=Ct++rG2>Ds1&3xt6aV5Rb8ahd*%(VmEBf@B3dT_=7M3G#lhR2z~^m>p)ig YA5F+R*4+w^KL7v#07*qoM6N<$g6AYK<^TWy literal 0 HcmV?d00001 diff --git a/build-config/macos_build.spec b/build-config/macos_build.spec new file mode 100644 index 0000000..219a289 --- /dev/null +++ b/build-config/macos_build.spec @@ -0,0 +1,76 @@ +# -*- mode: python ; coding: utf-8 -*- +import sys +import os + + +# Get the current working directory +current_directory = os.getcwd() + +# Build the path to the '../src' directory +src_path = os.path.abspath(os.path.join(current_directory, 'src')) + +# Add the 'src' directory to the sys.path +sys.path.append(src_path) + +import config + + +block_cipher = None + +a = Analysis( + ['../src/main.py'], + pathex=['../src'], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name=config.APP_NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) + +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name=config.APP_NAME, +) + +app = BUNDLE( + coll, + name=f'{config.APP_NAME}_{config.APP_VERSION}.app', + icon='../assets/icon.icns', + bundle_identifier='com.detekta.detektor', + info_plist={ + 'CFBundleShortVersionString': config.APP_VERSION, + 'CFBundleVersion': config.APP_VERSION, + 'NSHighResolutionCapable': 'True' + } +) diff --git a/build-config/windows_build.spec b/build-config/windows_build.spec new file mode 100644 index 0000000..02dfb01 --- /dev/null +++ b/build-config/windows_build.spec @@ -0,0 +1,58 @@ +# -*- mode: python ; coding: utf-8 -*- + +import sys +import os + +# Get the current working directory +current_directory = os.getcwd() + +# Build the path to the '../src' directory +src_path = os.path.abspath(os.path.join(current_directory, 'src')) + +# Add the 'src' directory to the sys.path +sys.path.append(src_path) + +# Now you can import config +import config + +block_cipher = None + +a = Analysis( + ['../src/main.py'], + pathex=['../src'], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name=f'{config.APP_NAME}_{config.APP_VERSION}', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None + # icon='../assets/app_icon.ico' +) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f62079f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +PyQt6==6.8.0 +pyinstaller==6.11.1 +wheel==0.45.1 +pyqtgraph==0.13.7 +pandas==2.2.3 +dbfread==2.0.7 +openpyxl==3.1.5 +xlsxwriter==3.2.2 +PyOpenGL==3.1.9 +PyOpenGL_accelerate==3.1.9 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/callbacks.py b/src/callbacks.py new file mode 100644 index 0000000..f6e53c3 --- /dev/null +++ b/src/callbacks.py @@ -0,0 +1,51 @@ +from typing import Callable, Dict, List +from enum import Enum +import logging +from collections import defaultdict + +class CallbackType(Enum): + # when new channel is added + ADD_CHANNEL = 1 + # when existing channel is removed from data structure + REMOVE_CHANNEL = 2 + # when channel data is changed after calibration + UPDATE_CHANNEL = 3 + LOCKED_Y = 4 + # when the X axis should be updated after cut/paste/delete + UPDATE_X = 5 + # upon change of the region state + REGION_STATE = 6 + # when the channel is enabled or disabled (removed from chart) + DISABLE_CHANNEL = 7 + ENABLE_CHANNEL = 8 + # for when the data has been changed + DATA_TAINTED = 9 + # when the file name changed - opened, or saved + FILE_NAME_CHANGED = 10 + + # + DATA_PARSED = 11 + DATA_NOT_PARSED = 12 + +class CallbackDispatcher: + """ + Singleton class for managing callbacks + """ + + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._callbacks: Dict[CallbackType, List[Callable]] = defaultdict(list) + return cls._instance + + def register(self, t: CallbackType, c: Callable): + logging.debug(f'Registering callback {t} -> {c}') + self._callbacks[t].append(c) + + def call(self, t: CallbackType, *args, **kwargs): + logging.debug(f'Calling callbacks for {t}') + for callback in self._callbacks[t]: + logging.debug(f' -> {callback}') + callback(*args, **kwargs) \ No newline at end of file diff --git a/src/change_start_dialog.py b/src/change_start_dialog.py new file mode 100644 index 0000000..ec0e896 --- /dev/null +++ b/src/change_start_dialog.py @@ -0,0 +1,86 @@ +import logging +from datetime import datetime + +from PyQt6.QtCore import QDateTime, Qt, QDate, QTime +from PyQt6.QtWidgets import QDialog, QCalendarWidget, QPushButton, QVBoxLayout, QLabel, QHBoxLayout, QSpinBox + +from detektor_data import DetektorContainer +from callbacks import CallbackDispatcher, CallbackType + + +class ChangeStartDialog(QDialog): + def __init__(self): + super().__init__() + + self.setWindowTitle("Změna počátku dat") + self.setModal(True) # Set the dialog as modal (blocks main window) + self.resize(400,400) + + # Convert datetime.datetime to QDateTime manually + start_dt = DetektorContainer().get().start_datetime # Assuming this is a datetime.datetime object + datetime_value = QDateTime( + QDate(start_dt.year, start_dt.month, start_dt.day), + QTime(start_dt.hour, start_dt.minute, start_dt.second) + ) + + # Calendar widget + self.calendar = QCalendarWidget() + self.calendar.setGridVisible(True) + self.calendar.setSelectedDate(datetime_value.date()) + + # Time selection + self.time_label = QLabel("Čas:") + + self.hour_spin = QSpinBox() + self.hour_spin.setRange(0, 23) + self.hour_spin.setValue(datetime_value.time().hour()) + + self.minute_spin = QSpinBox() + self.minute_spin.setRange(0, 59) + self.minute_spin.setValue(datetime_value.time().minute()) + + self.second_spin = QSpinBox() + self.second_spin.setRange(0, 59) + self.second_spin.setValue(datetime_value.time().second()) + + time_layout = QHBoxLayout() + time_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + time_layout.addWidget(self.time_label) + time_layout.addWidget(self.hour_spin) + time_layout.addWidget(QLabel(":")) + time_layout.addWidget(self.minute_spin) + time_layout.addWidget(QLabel(":")) + time_layout.addWidget(self.second_spin) + + # OK button + self.ok_button = QPushButton("OK") + self.ok_button.clicked.connect(self.accept) + + # Layout + layout = QVBoxLayout() + layout.addWidget(self.calendar) + layout.addLayout(time_layout) + layout.addWidget(self.ok_button) + + self.setLayout(layout) + + self.exec() + + def accept(self): + selected_date = self.calendar.selectedDate() + selected_time = QTime(self.hour_spin.value(), self.minute_spin.value(), self.second_spin.value()) + + new_start_datetime = datetime( + selected_date.year(), selected_date.month(), selected_date.day(), + selected_time.hour(), selected_time.minute(), selected_time.second() + ) + + if new_start_datetime != DetektorContainer().get().start_datetime: + logging.debug(f'Updating start time from {DetektorContainer().get().start_datetime} to {new_start_datetime}') + + DetektorContainer().duplicate(force_update=False) + DetektorContainer().get().start_datetime = new_start_datetime + else: + logging.debug('Nothing has changed, skipping save') + + super().accept() diff --git a/src/channel.py b/src/channel.py new file mode 100644 index 0000000..9e0d096 --- /dev/null +++ b/src/channel.py @@ -0,0 +1,64 @@ +import logging +import uuid +from enum import Enum + +from callbacks import CallbackDispatcher, CallbackType + +class ChannelUnit(Enum): + VOLTS=1 + PPM=2 + AMPERS=3 + CELSIUS=4 + +class Channel: + name: str = "" + _active: bool = True + number: int = 0 + unit: ChannelUnit + data = [] + color: str = "" + + _slice = [] + + def __init__(self): + self.id = uuid.uuid1() + + @property + def active(self): + return self._active + + def toggle_active(self): + self._active = not self._active + if self._active: + logging.debug(f'Enabling channel {self.name} ({self.id})') + CallbackDispatcher().call(CallbackType.ENABLE_CHANNEL, self.id) + else: + logging.debug(f'Disabling channel {self.name} ({self.id})') + CallbackDispatcher().call(CallbackType.DISABLE_CHANNEL, self.id) + + + def calibrate_data(self, offset: float=0, multiple: float=1, start=None, end=None): + """ + 'Calibrates' internal data by adding offset and multiplying all numbers + Updates the chart series + Marks that the data has been tainted and should be saved or discarded upon exit + """ + + if start and end: + self.data[start:end] = [(x + offset) * multiple for x in self.data[start:end]] + else: + self.data = [(x + offset) * multiple for x in self.data] + logging.debug(f'Calibrating channel {self.name} ({self.id})') + CallbackDispatcher().call(CallbackType.UPDATE_CHANNEL, self.id, True) + CallbackDispatcher().call(CallbackType.DATA_TAINTED) + + def copy(self, start: int, end: int): + self._slice = self.data[start:end + 1] + + def cut(self, start: int, end: int): + # save the cut and remove it from active data + self.copy(start, end) + self.data[start:end + 1] = [] + + def paste(self, at: int): + self.data[at:at] = self._slice \ No newline at end of file diff --git a/src/channel_calibration_dialog.py b/src/channel_calibration_dialog.py new file mode 100644 index 0000000..50da021 --- /dev/null +++ b/src/channel_calibration_dialog.py @@ -0,0 +1,198 @@ +import logging +from typing import List, Tuple, Dict +from uuid import uuid1 + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QDoubleValidator +from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QHBoxLayout, QWidget, QCheckBox, QButtonGroup, \ + QRadioButton, QSplitter, QLineEdit, QGroupBox + +from detektor_region import DetektorRegion, DetektorRegionState +from detektor_data import DetektorContainer +from widgets import RoundedColorRectangleWidget + + +class ChannelCalibrationDialog(QDialog): + + _only_region = None + + _radio_map: Dict[uuid1, QRadioButton] = {} + + def __init__(self, region: DetektorRegion): + super().__init__() + self.region = region + + self.setWindowTitle("Kalibrace kanálu") + self.setModal(True) # Set the dialog as modal (blocks main window) + self.resize(300, 150) + + # Main layout + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + # Splitter for two columns + splitter = QSplitter() + splitter.setOrientation(Qt.Orientation.Horizontal) # Horizontal splitter for left/right columns + + # Left GroupBox (Channels) + left_group = QGroupBox("Vyberte kanál") + left_layout = QVBoxLayout() + left_group.setLayout(left_layout) + + # Right GroupBox (Settings) + right_group = QGroupBox("Nastavení kanálu") + right_layout = QVBoxLayout() + right_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + right_group.setLayout(right_layout) + + splitter.addWidget(left_group) + splitter.addWidget(right_group) + + splitter.setStretchFactor(0, 1) # 50% + splitter.setStretchFactor(1, 1) # 50% + + main_layout.addWidget(splitter) + + # Create a button group to ensure only one channel is active at a time + self.radio_group = QButtonGroup() + + for ch in DetektorContainer().get().channels: + channel_layout = QHBoxLayout() + channel_layout.setContentsMargins(0, 0, 0, 0) + channel_widget = QWidget() + channel_widget.setLayout(channel_layout) + left_layout.addWidget(channel_widget) + + channel_radio = QRadioButton(f"{ch.name}") + self._radio_map[ch.id] = channel_radio + + self.radio_group.addButton(channel_radio) # Add to button group + channel_layout.addWidget(channel_radio) + + channel_layout.addWidget(RoundedColorRectangleWidget(ch.color)) + + float_validator = CustomDoubleValidator() + # float_validator.setLocale("C") + + # Přičíst input + add_layout = QVBoxLayout() + add_label = QLabel("Přičíst hodnotu ke kanálu") + self.add_input = QLineEdit() + self.add_input.setValidator(float_validator) + self.add_input.setText('0') + add_layout.addWidget(add_label) + add_layout.addWidget(self.add_input) + + # Vynásobit input + multiply_layout = QVBoxLayout() + multiply_label = QLabel("Vynásobit data kanálu hodnotou") + self.multiply_input = QLineEdit() + self.multiply_input.setValidator(float_validator) + self.multiply_input.setText('1') + multiply_layout.addWidget(multiply_label) + multiply_layout.addWidget(self.multiply_input) + + only_region_checkbox = QCheckBox(f"Kalibrovat pouze výběr") + # initial state is taken from region state + self._only_region = (self.region.state != DetektorRegionState.UNSET) + only_region_checkbox.setEnabled(self._only_region) + only_region_checkbox.setChecked(self._only_region) + # on click, intert the property state + only_region_checkbox.stateChanged.connect(self.toggle_only_region) + + + # Add inputs to the right group box + right_layout.addLayout(add_layout) + right_layout.addLayout(multiply_layout) + right_layout.addWidget(only_region_checkbox) + + # Buttons layout (aligned at the bottom, next to each other) + button_layout = QHBoxLayout() + + calibrate_button = QPushButton("Kalibrovat") + calibrate_button.clicked.connect(self.accept) # Close the dialog when the button is clicked + button_layout.addWidget(calibrate_button) + + cancel_button = QPushButton("Zrušit") + cancel_button.clicked.connect(self.close) # Close the dialog when the button is clicked + button_layout.addWidget(cancel_button) + + # Add buttons to the main layout below both columns + main_layout.addLayout(button_layout) + + # Show the dialog + self.exec() + + def toggle_only_region(self): + self._only_region = not self._only_region + + def accept(self): + for channel_id, radio in self._radio_map.items(): + if radio.isChecked(): + DetektorContainer().duplicate() + + has_channel = False + for channel_id, radio in self._radio_map.items(): + + if radio.isChecked(): + # get the current channel object of the current (duplicated dataset) + channel = DetektorContainer().get().get_channel_by_uuid(channel_id) + + offset = float(self.add_input.text()) + multiple = float(self.multiply_input.text()) + logging.debug(f'Calibrating channel {channel.name} +{offset} x{multiple}') + + if self._only_region: + start, end = self.region.get_safe_region() + logging.debug(f'Calibrating only region {start} - {end}') + + channel.calibrate_data( + offset=offset, + multiple=multiple, + start=start, + end=end + ) + + self.region.unset() + else: + channel.calibrate_data( + offset=offset, + multiple=multiple + ) + + has_channel = True + break + + # if we got here, no channel has been selected + if not has_channel: + # TODO: revert the duplication, since nothing has changed + + MissingChannelDialog() + else: + super().accept() + + +class MissingChannelDialog(QDialog): + def __init__(self): + super().__init__() + + self.setWindowTitle("Vyberte kanál") + self.setModal(True) # Set the dialog as modal (blocks main window) + self.resize(300, 150) + + # Main layout + main_layout = QVBoxLayout() + self.setLayout(main_layout) + main_layout.addWidget(QLabel("Vyberte kanál, na kterém chcete provést kalibraci")) + + cancel_button = QPushButton("OK") + cancel_button.clicked.connect(self.close) + main_layout.addWidget(cancel_button) + + # Show the dialog + self.exec() + +class CustomDoubleValidator(QDoubleValidator): + def validate(self, input_str, pos): + input_str = input_str.replace(',', '.') # Replace ',' with '.' + return super().validate(input_str, pos) \ No newline at end of file diff --git a/src/channels_menu.py b/src/channels_menu.py new file mode 100644 index 0000000..154f853 --- /dev/null +++ b/src/channels_menu.py @@ -0,0 +1,63 @@ +import logging +from typing import Dict +from uuid import uuid1 + +from PyQt6.QtWidgets import QBoxLayout, QGroupBox, QHBoxLayout, QWidget, QCheckBox, QVBoxLayout + +from callbacks import CallbackDispatcher, CallbackType +from channel import Channel +from detektor_data import DetektorContainer +from widgets import RoundedColorRectangleWidget + + +class ChannelsMenu: + layout: QBoxLayout = None + + _channels: Dict[uuid1, QWidget] = {} + + def __init__(self, layout: QBoxLayout): + self.layout = layout + + self.channels_layout = QVBoxLayout() + self.channels_group = QGroupBox("Kanály") + self.channels_group.setLayout(self.channels_layout) + layout.addWidget(self.channels_group) + + # the data might be already filled by passing filename as an argument + if DetektorContainer().get(): + for ch in DetektorContainer().get().channels: + self.add_channel(ch.id) + + # register for future updates + CallbackDispatcher().register(CallbackType.ADD_CHANNEL, self.add_channel) + CallbackDispatcher().register(CallbackType.REMOVE_CHANNEL, self.remove_channel) + + def add_channel(self, channel_id: uuid1): + logging.debug(f'Adding channel {channel_id} to the menu') + ch = DetektorContainer().get().get_channel_by_uuid(channel_id) + + channel_layout = QHBoxLayout() + channel_layout.setContentsMargins(0, 0, 0, 0) + channel_widget = QWidget() + channel_widget.setLayout(channel_layout) + self.channels_layout.addWidget(channel_widget) + + channel_checkbox = QCheckBox(ch.name) + channel_checkbox.setChecked(ch.active) + channel_checkbox.stateChanged.connect(lambda _, channel_id=ch.id: self.toggle_channel(channel_id)) + channel_layout.addWidget(channel_checkbox) + + channel_layout.addWidget(RoundedColorRectangleWidget(ch.color)) + + self._channels[ch.id] = channel_widget + + def remove_channel(self, channel_id: uuid1): + if channel_id in self._channels: + widget = self._channels[channel_id] + self.channels_layout.removeWidget(widget) + widget.deleteLater() # Properly delete the widget + del self._channels[channel_id] # Remove from the dictionary + + def toggle_channel(self, channel_id: uuid1): + DetektorContainer().get().get_channel_by_uuid(channel_id).toggle_active() + diff --git a/src/chart_menu.py b/src/chart_menu.py new file mode 100644 index 0000000..0bbb064 --- /dev/null +++ b/src/chart_menu.py @@ -0,0 +1,29 @@ +from PyQt6.QtWidgets import QBoxLayout, QGroupBox, QPushButton, QCheckBox, QVBoxLayout + +from detektor_plot import DetektorPlot + + +class ChartMenu(): + layout: QBoxLayout = None + + def __init__(self, layout: QBoxLayout, plot: DetektorPlot): + self.layout = layout + self.plot = plot + + chart_options_layout = QVBoxLayout() + chart_options_group = QGroupBox("Graf") + chart_options_group.setLayout(chart_options_layout) + self.layout.addWidget(chart_options_group) + + show_all_button = QPushButton(f"Zobrazit vše") + # button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + # show_all_button.setEnabled(False) + show_all_button.clicked.connect(self.plot.show_all) + chart_options_layout.addWidget(show_all_button) + + locked_y_checkbox = QCheckBox(f"Zamknout osu Y") + # initial state is taken from property + #locked_y_checkbox.setChecked(self.locked_y) + # on click, intert the property state + locked_y_checkbox.stateChanged.connect(self.plot.toggle_locked_y) + chart_options_layout.addWidget(locked_y_checkbox) \ No newline at end of file diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..e75e810 --- /dev/null +++ b/src/config.py @@ -0,0 +1,37 @@ +APP_NAME='Detektor' +APP_VERSION='1.1' + +# Names used by generate_data() +# no importance when opening file +DUMMY_CHANNEL_NAMES = [ + 'Bernath', + 'Kyslík 1', + 'Kyslík 2', + 'Ratfisch starý', + 'Ratfisch nový A', + 'Ratfisch nový 2', + 'Multor CO', + 'Multor SO2', + 'Multor NOx', + 'Xentra CO', + 'Xentra SO2', + 'Xentra NOx', +] +ChannelColors = [ + '#5bb3d4', # Bold Cyan + '#76c78d', # Strong Mint Green + '#f09a4e', # Vivid Peach + '#b467bf', # Intense Lavender + '#e45c68', # Deep Pastel Pink + '#f2ab32', # Bold Light Yellow + '#6caf4d', # Sharp Pastel Green + '#bf4d6c', # Bold Rose + '#bf8750', # Warm Sand + '#50a7a9', # Crisp Aqua + '#bf4d9f', # Rich Mauve + '#508dbf', # Vibrant Sky Blue + '#d4bf50', # Deep Light Gold + '#4dbfa9', # Sharp Seafoam + '#bf4d7c', # Intense Pastel Rose + '#bf9f50', # Strong Beige +] diff --git a/src/data_menu.py b/src/data_menu.py new file mode 100644 index 0000000..e59c9f7 --- /dev/null +++ b/src/data_menu.py @@ -0,0 +1,112 @@ +from PyQt6.QtWidgets import QBoxLayout, QVBoxLayout, QGroupBox, QPushButton + +from callbacks import CallbackDispatcher, CallbackType +from detektor_region import DetektorRegionState, DetektorRegion +from channel_calibration_dialog import ChannelCalibrationDialog +from change_start_dialog import ChangeStartDialog +from detektor_data import DetektorContainer +from moving_average_dialog import MovingAverageDialog + + +class DataMenu: + layout: QBoxLayout = None + region: DetektorRegion = None + + def __init__(self, layout: QBoxLayout, region: DetektorRegion): + self.layout = layout + self.region = region + + CallbackDispatcher().register(CallbackType.REGION_STATE, self.update_enabled) + CallbackDispatcher().register(CallbackType.DATA_PARSED, self.update_enabled) + CallbackDispatcher().register(CallbackType.DATA_NOT_PARSED, self.update_enabled) + + data_menu_layout = QVBoxLayout() + chart_options_group = QGroupBox("Data") + chart_options_group.setLayout(data_menu_layout) + self.layout.addWidget(chart_options_group) + + self.add_selection_button = QPushButton(f"Přidat výběr") + self.add_selection_button.clicked.connect(self.region.set) + data_menu_layout.addWidget(self.add_selection_button) + + self.cancel_selection_button = QPushButton(f"Zrušit výběr") + self.cancel_selection_button.clicked.connect(self.region.unset) + data_menu_layout.addWidget(self.cancel_selection_button) + + self.delete_selection_button = QPushButton(f"Smazat výběr") + self.delete_selection_button.clicked.connect(self.region.delete) + data_menu_layout.addWidget(self.delete_selection_button) + + self.copy_selection_button = QPushButton(f"Kopírovat") + self.copy_selection_button.clicked.connect(self.region.copy) + data_menu_layout.addWidget(self.copy_selection_button) + + self.cut_selection_button = QPushButton(f"Vyjmout") + self.cut_selection_button.clicked.connect(self.region.cut) + data_menu_layout.addWidget(self.cut_selection_button) + + self.paste_selection_after_button = QPushButton(f"Vložit data za výběr") + self.paste_selection_after_button.clicked.connect(self.region.paste_after) + data_menu_layout.addWidget(self.paste_selection_after_button) + + self.paste_selection_end_button = QPushButton(f"Vložit data na konec") + self.paste_selection_end_button.clicked.connect(self.region.paste_end) + data_menu_layout.addWidget(self.paste_selection_end_button) + + self.change_start_button = QPushButton(f"Změnit datum a čas") + self.change_start_button.clicked.connect(self.open_change_start_dialog) + data_menu_layout.addWidget(self.change_start_button) + + self.calibration_button = QPushButton(f"Kalibrovat data") + self.calibration_button.clicked.connect(self.open_calibration_dialog) + data_menu_layout.addWidget(self.calibration_button) + + self.moving_average_button = QPushButton(f"Klouzavý průměr") + self.moving_average_button.clicked.connect(self.open_moving_average_dialog) + data_menu_layout.addWidget(self.moving_average_button) + + self.update_enabled() + + def open_change_start_dialog(self): + ChangeStartDialog() + + def open_calibration_dialog(self): + ChannelCalibrationDialog(self.region) + + def open_moving_average_dialog(self): + MovingAverageDialog() + + def update_enabled(self): + if self.region.state == DetektorRegionState.UNSET: + self.add_selection_button.setEnabled(True) + self.cancel_selection_button.setEnabled(False) + self.delete_selection_button.setEnabled(False) + self.copy_selection_button.setEnabled(False) + self.cut_selection_button.setEnabled(False) + self.paste_selection_after_button.setEnabled(False) + self.paste_selection_end_button.setEnabled(False) + elif self.region.state == DetektorRegionState.SET: + self.add_selection_button.setEnabled(False) + self.cancel_selection_button.setEnabled(True) + self.delete_selection_button.setEnabled(True) + self.copy_selection_button.setEnabled(True) + self.cut_selection_button.setEnabled(True) + self.paste_selection_after_button.setEnabled(False) + self.paste_selection_end_button.setEnabled(False) + elif self.region.state == DetektorRegionState.COPIED: + self.add_selection_button.setEnabled(False) + self.cancel_selection_button.setEnabled(True) + self.delete_selection_button.setEnabled(True) + self.copy_selection_button.setEnabled(True) + self.cut_selection_button.setEnabled(True) + self.paste_selection_after_button.setEnabled(True) + self.paste_selection_end_button.setEnabled(True) + + if DetektorContainer().get().file_path is None: + self.calibration_button.setEnabled(False) + self.moving_average_button.setEnabled(False) + self.change_start_button.setEnabled(False) + else: + self.calibration_button.setEnabled(True) + self.moving_average_button.setEnabled(True) + self.change_start_button.setEnabled(True) \ No newline at end of file diff --git a/src/detektor_data.py b/src/detektor_data.py new file mode 100644 index 0000000..2c5147c --- /dev/null +++ b/src/detektor_data.py @@ -0,0 +1,266 @@ +import copy +import logging +from datetime import datetime, timedelta +import random +from typing import List, Union +from uuid import uuid1 + +import config +from channel import Channel, ChannelUnit +from config import ChannelColors +from callbacks import CallbackDispatcher, CallbackType + +class DetektorData: + def __init__(self): + self._file_path: Union[str,None] = None + + self._start_datetime: Union[datetime, None] = None + self.interval_ms: Union[int, None] = 1000 + + # this is just auxiliary thing for debugging the reverting feature + self.last_changed = self._start_datetime + + self.channels: List[Channel] = [] + + self._x_labels = [] + + self.data_tainted = False + + # any internal change of channel data will switch the tainted flag on + CallbackDispatcher().register( + CallbackType.DATA_TAINTED, + self.set_tainted + ) + CallbackDispatcher().register( + CallbackType.UPDATE_X, + self._generate_x_labels + ) + CallbackDispatcher().register( + CallbackType.UPDATE_X, + self.set_tainted + ) + + @property + def start_datetime(self) -> datetime: + return self._start_datetime + + @start_datetime.setter + def start_datetime(self, value: datetime): + if self._start_datetime != value: + # the new value is different + previous_start_datetime = self._start_datetime + self._start_datetime = value + + #self._generate_x_labels() + CallbackDispatcher().call(CallbackType.UPDATE_X) + + if previous_start_datetime != None: + # we previously had some value, so no need to taint the data - this is probably called during import + CallbackDispatcher().call(CallbackType.DATA_TAINTED) + + @property + def file_path(self) -> str: + return self._file_path + + @file_path.setter + def file_path(self, value: str): + self._file_path = value + CallbackDispatcher().call(CallbackType.FILE_NAME_CHANGED) + + def set_tainted(self): + self.data_tainted = True + + def add_channel(self, c: Channel): + self.channels.append(c) + if len(self.channels) == 1: + self._generate_x_labels() + CallbackDispatcher().call(CallbackType.ADD_CHANNEL, c.id) + + def remove_channel(self, c: Channel): + CallbackDispatcher().call(CallbackType.REMOVE_CHANNEL, c.id) + self.channels.remove(c) + + def x_labels(self) -> List[str]: + if len(self._x_labels) == 0: + self._generate_x_labels() + + return self._x_labels + + def _generate_x_labels(self): + """ Pregenerates the list of labels. The DetektorAxis will pick labels from this list. """ + + # Initialize the list + self._x_labels = [] + + # Start from the initial time + current_time = self._start_datetime + + if self.data_count() > 0: + for i in range(self.data_count()+1): + # Format and add label + self._x_labels.append(current_time.strftime("%H:%M:%S")) + + # Increment time + current_time += timedelta(milliseconds=self.interval_ms) + + + def data_count(self): + """ + Number of data. All channels should have the same amount of data timed from the same start + """ + first_channel = next(iter(self.channels), None) + if first_channel is None: + return 0 + else: + return len(first_channel.data) + + def min_y(self, active: bool = True): + return min( + (min(ch.data, default=float('inf')) for ch in self.channels if not active or (active and ch.active)), + default=None + ) + + def max_y(self, active: bool = True): + return max( + (max(ch.data, default=float('-inf')) for ch in self.channels if not active or (active and ch.active)), + default=None + ) + + def flush(self): + """ Removes all channels """ + for ch in list(self.channels): # Use a copy to avoid modification issues + self.remove_channel(ch) + + def cut(self, start: int, end: int): + """ For cutting and deleting """ + logging.debug(f'Cutting range {start} - {end}') + for c in self.channels: + c.cut(start, end) + # update the channel, but don't update the X labels or zoom/pan limits yet + CallbackDispatcher().call(CallbackType.UPDATE_CHANNEL, c.id, False) + + # if the start is 0, we have to change the start_datetime accordingly + if start == 0: + new_start_datetime = self.start_datetime + timedelta(milliseconds=self.interval_ms * (end-start)) + logging.debug(f'Since we\'re cutting from the start, changing the start from {self.start_datetime} to {new_start_datetime}') + self.start_datetime = new_start_datetime + + # update the X labels and zoom/pan limits at once + self._generate_x_labels() + CallbackDispatcher().call(CallbackType.UPDATE_X) + CallbackDispatcher().call(CallbackType.DATA_TAINTED) + + def copy(self, start, end): + """ For copying a chunk of data """ + logging.debug(f'Copying range {start} - {end}') + for c in self.channels: + c.copy(start, end) + + def paste(self, at: int): + """ For pasting at end, or any other position """ + for c in self.channels: + c.paste(at) + # update the channel, but don't update the X labels or zoom/pan limits yet + CallbackDispatcher().call(CallbackType.UPDATE_CHANNEL, c.id, False) + + # update the X labels and zoom/pan limits at once + self._generate_x_labels() + CallbackDispatcher().call(CallbackType.UPDATE_X) + CallbackDispatcher().call(CallbackType.DATA_TAINTED) + + def get_channel_by_uuid(self, uuid: uuid1) -> Union[Channel, None]: + ret = None + for c in self.channels: + if c.id == uuid: + ret = c + break + return ret + +class DetektorContainer: + _data: List[DetektorData] = [] + + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def get(self) -> Union[DetektorData, None]: + if len(self._data) > 0: + return self._data[-1] + else: + logging.debug('No dataset to return') + return None + + def set(self, data: DetektorData): + logging.debug('New dataset') + data.last_changed = 'patient zero' + self._data.append(data) + + def revert(self): + # revert the data, but only if we have changed them + if self.has_history(): + self._data.pop() + + CallbackDispatcher().call(CallbackType.DATA_PARSED) + + logging.debug(f'Dataset reverted to {self.get().last_changed}') + else: + logging.debug('No dataset to revert to') + + def duplicate(self, force_update: bool = True): + self.get().last_changed = datetime.now() + + self._data.append( + copy.deepcopy( + self.get() + ) + ) + + self.get().last_changed = 'latest' + + if force_update: + CallbackDispatcher().call(CallbackType.DATA_PARSED) + + logging.debug('Dataset duplicated') + + def has_history(self) -> bool: + """ Used for enabling / disabling CTRL-Z """ + return len(self._data) > 1 + + def flush(self): + logging.debug('Flushing all datasets') + self._data = [] + + +def generate_data(channel_count: int = 5, data_count: int = 100, random_values: bool = True): + """ Simple data generator for development purposes """ + if channel_count > len(config.DUMMY_CHANNEL_NAMES): + raise Exception('Too many channels to generate') + + d = DetektorData() + d.start_datetime = datetime.now() + d.file_path = 'TESTOVACÍ DATA' + + cn = 1 + for c in config.DUMMY_CHANNEL_NAMES: + nc = Channel() + nc.name = c + nc.number = cn + nc.color = ChannelColors[cn-1] + nc.unit = random.choice(list(ChannelUnit)) + + offset = 100 * random.random() + if random_values: + nc.data = [round(random.gauss(25, 5), 2) + offset for _ in range(data_count)] + else: + nc.data = [i+offset for i in range(data_count)] + d.add_channel(nc) + + if cn == channel_count: + break + cn += 1 + + + return d \ No newline at end of file diff --git a/src/detektor_plot.py b/src/detektor_plot.py new file mode 100644 index 0000000..736ac49 --- /dev/null +++ b/src/detektor_plot.py @@ -0,0 +1,215 @@ +import logging +from math import trunc +from random import random +from typing import Dict +from uuid import uuid1 + +import pyqtgraph as pg +from PyQt6.QtGui import QFont, QColor +from PyQt6.QtWidgets import QBoxLayout, QLabel +from PyQt6.QtCore import Qt + +from callbacks import CallbackDispatcher, CallbackType +from detektor_data import DetektorContainer + + +class DetektorViewBox(pg.ViewBox): + """Custom ViewBox to disable right-click zooming while allowing selection.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.setMenuEnabled(False) + + def mouseDragEvent(self, mouse_event, axis=None): + """Override right-click to prevent zooming.""" + if mouse_event.button() == Qt.MouseButton.RightButton: + mouse_event.accept() # Consume the event, preventing default zoom + else: + super().mouseDragEvent(mouse_event) # Allow normal behavior for other buttons + + +# Custom x-axis that limits the number of ticks +class DetektorAxis(pg.AxisItem): + def __init__(self, *args, **kwargs): + super().__init__( *args, **kwargs) + + def tickStrings(self, values, scale, spacing): + """ + Override tickStrings to return labels for specific positions. + """ + + ticks = [] + for v in values: + trunc_v = trunc(v) + if v == trunc_v: + labels = DetektorContainer().get().x_labels() + ticks.append(labels[trunc_v]) + else: + ticks.append('') + + return ticks + + +class DetektorPlot: + view_box: DetektorViewBox = None + graphWidget: pg.PlotWidget = None + + _locked_y: bool = False + + _series: Dict[uuid1, pg.PlotDataItem] = {} + _x_label = [] + + _cbd = CallbackDispatcher() + + _layout: QBoxLayout = None + _no_data_label = None + + def __init__(self, layout: QBoxLayout): + self._layout = layout + self.view_box = DetektorViewBox() + self.x_axis = DetektorAxis(orientation="bottom") + #self.view_box.sigRangeChanged.connect(lambda: self.x_axis.update_ticks(self.view_box.viewRange())) + + self.graphWidget = pg.PlotWidget(viewBox=self.view_box, axisItems={"bottom": self.x_axis}) + if True or DetektorContainer().get().data_count() > 0: + self._layout.addWidget(self.graphWidget) + else: + self._no_data_label = QLabel("Žádná data") + self._no_data_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._layout.addWidget(self._no_data_label) + + #self.graphWidget.useOpenGL(True) + self.graphWidget.setAntialiasing(True) + self.graphWidget.setBackground("w") + + self.graphWidget.getPlotItem().hideButtons() + self.graphWidget.getPlotItem().setContentsMargins(0, 20, 20, 0) + self.graphWidget.getPlotItem().showGrid(x=True, y=True) + + grid_pen = pg.mkPen(color=(100, 100, 100), width=2, style=pg.QtCore.Qt.PenStyle.DashLine) + self.graphWidget.getPlotItem().getAxis("bottom").setPen(grid_pen) + self.graphWidget.getPlotItem().getAxis("left").setPen(grid_pen) + self.graphWidget.getPlotItem().getAxis("bottom").setTextPen(QColor(* (100, 100, 100))) + self.graphWidget.getPlotItem().getAxis("left").setTextPen(QColor(* (100, 100, 100))) + self.graphWidget.getPlotItem().getAxis("left").setWidth(40) + + self.cursorLine = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen(color=(25, 25, 25), width=2)) + self.graphWidget.addItem(self.cursorLine) + self.graphWidget.scene().sigMouseMoved.connect(self.on_mouse_move) + + self._cbd.register(CallbackType.ADD_CHANNEL, self.add_series) + self._cbd.register(CallbackType.REMOVE_CHANNEL, self.remove_series) + self._cbd.register(CallbackType.ENABLE_CHANNEL, self.add_series) + self._cbd.register(CallbackType.DISABLE_CHANNEL, self.remove_series) + + self._cbd.register(CallbackType.UPDATE_CHANNEL, self.update_series) + self._cbd.register(CallbackType.UPDATE_X, self.set_limits) + + self._cbd.register(CallbackType.DATA_PARSED, self.flush_series) + self._cbd.register(CallbackType.DATA_PARSED, self.load_data) + + self.load_data() + + def on_mouse_move(self, event): + # get the current mouse position within the plot region + mouse_point = self.graphWidget.getPlotItem().vb.mapSceneToView(event) + # Snap to a discrete X value + x = int(round(mouse_point.x())) + self.cursorLine.setPos(x) + + def toggle_locked_y(self): + self._locked_y = not self._locked_y + + if self._locked_y: + self.view_box.setMouseEnabled(x=True, y=False) + else: + self.view_box.setMouseEnabled(x=True, y=True) + + self._cbd.call(CallbackType.LOCKED_Y) + + def show_all(self): + """ Zooms out to show all the data """ + self.graphWidget.enableAutoRange(axis='xy', enable=True) + + def add_series(self, channel_id: uuid1, update_limits: bool = True): + """ Adds new series to the plot """ + c = DetektorContainer().get().get_channel_by_uuid(channel_id) + logging.debug(f'Adding series {c.name} ({c.id})') + + if c.id in self._series: + logging.warning(f'Channel {c.name} ({c.id}) already in series') + return + + pen = pg.mkPen(color=c.color, width=4, cosmetic=True) + curve = self.graphWidget.plot(range(len(c.data)), c.data, pen=pen) + self._series[c.id] = curve + + if update_limits: + self.set_limits() + + def remove_series(self, channel_id: uuid1, update_limits: bool = True) -> bool: + """ Removes series from the plot """ + c = DetektorContainer().get().get_channel_by_uuid(channel_id) + logging.debug(f'Removing series {c.name} ({c.id})') + + # check if the affected channel is in the shown series (might be hidden) + if c.id in self._series: + logging.debug(f'Series {c.name} is shown, removing') + self.graphWidget.removeItem(self._series[c.id]) + del self._series[c.id] + + if update_limits: + self.set_limits() + + return True + else: + return False + + def update_series(self, channel_id: uuid1, update_limits: bool = True): + # only update series, that is displayed + if self.remove_series(channel_id, False): + self.add_series(channel_id, update_limits) + self.set_limits() + + def flush_series(self): + self.graphWidget.clear() + + # Collect keys first to avoid modifying dict while iterating + keys_to_delete = list(self._series.keys()) + + for channel_id in keys_to_delete: + curve = self._series[channel_id] + self.graphWidget.removeItem(curve) + del self._series[channel_id] + + + def set_limits(self): + """ Sets limits for panning and zooming """ + if DetektorContainer().get().data_count() > 0: + x_min, x_max = 0, DetektorContainer().get().data_count() - 1 + y_min_limit, y_max_limit = DetektorContainer().get().min_y(), DetektorContainer().get().max_y() + + if y_min_limit is not None and y_max_limit is not None: + self.view_box.setLimits( + xMin=x_min, xMax=x_max, + yMin=y_min_limit*(0.98+random()*0.0001), yMax=y_max_limit*1.02, + minXRange=5, maxXRange=(x_max - x_min), + minYRange=2, maxYRange=(y_max_limit - y_min_limit) + ) + + # this sorcery is for updating the X axis labels when changing the start datetime + self.graphWidget.setAxisItems({"bottom": self.x_axis}) + self.graphWidget.getAxis("bottom").update() + self.graphWidget.repaint() + + self.show_all() + + def load_data(self): + """ Loads data into series and labels """ + + # y series + for c in DetektorContainer().get().channels: + if c.active: + self.add_series(c.id, update_limits=False) + + self.set_limits() \ No newline at end of file diff --git a/src/detektor_region.py b/src/detektor_region.py new file mode 100644 index 0000000..37a2bbd --- /dev/null +++ b/src/detektor_region.py @@ -0,0 +1,139 @@ +from enum import Enum +from math import trunc + +import pyqtgraph as pg + +from callbacks import CallbackDispatcher, CallbackType +from detektor_plot import DetektorPlot +from detektor_data import DetektorContainer + + +class DetektorRegionState(Enum): + UNSET = 1 + SET = 4 + COPIED = 5 + + +class DetektorRegion(pg.LinearRegionItem): + """Encapsulates a linear region that snaps to discrete X-axis values and shows a context menu.""" + + # in which mode the region is + _state: DetektorRegionState = DetektorRegionState.UNSET + _plot: DetektorPlot = None + + # the start and end is integer, not a label (time) + _start_position: int = 0 + _end_position: int = 0 + + def __init__(self, plot: DetektorPlot): + super().__init__() + + # reference to the chart so we can add and remove the widget + self._plot = plot + + # move the rectangle behind the plot lines + self.setZValue(-10) + + # color is the same for selecting and hovering + grid_color = pg.mkBrush((100, 100, 250, 50)) + self.setBrush(grid_color) + self.setHoverBrush(grid_color) + + self.setAcceptHoverEvents(True) + + # callback for changing the width + self.sigRegionChanged.connect(self.snap_to_x_labels) + + @property + def state(self): + return self._state + + @state.setter + def state(self, v: DetektorRegionState): + """ Sets state and calls hooks if it changes from the previous one """ + if v != self._state: + self._state = v + + CallbackDispatcher().call(CallbackType.REGION_STATE) + + def set(self): + """ Displays region occupying roughly a third of the actual view range """ + self.state = DetektorRegionState.SET + x_range, y_range = self._plot.view_box.viewRange() + x_min, x_max = x_range + third = (x_max - x_min) / 3 + self.setRegion([x_min + third, x_max - third]) + self.display() + + def unset(self): + self.state = DetektorRegionState.UNSET + self.hide() + + def get_safe_region(self): + start, end = self.getRegion() + if start < 0: + start = 0 + if end > DetektorContainer().get().data_count()-1: + end = DetektorContainer().get().data_count() + + return trunc(start), trunc(end) + + def delete(self): + """ Deletes data by cutting the region without keeping it """ + start, end = self.get_safe_region() + DetektorContainer().duplicate() + DetektorContainer().get().cut(start, end) + + self.state = DetektorRegionState.UNSET + self.hide() + + def copy(self): + """ Copies the data """ + start, end = self.get_safe_region() + DetektorContainer().get().copy(start, end) + + self.state = DetektorRegionState.COPIED + + def cut(self): + """ Cuts the data and hiding the region """ + start, end = self.get_safe_region() + DetektorContainer().duplicate() + DetektorContainer().get().cut(start, end) + + self.state = DetektorRegionState.COPIED + self.hide() + + def paste_end(self): + DetektorContainer().duplicate() + DetektorContainer().get().paste( + DetektorContainer().get().data_count() + ) + + self.unset() + + def paste_after(self): + _, end = self.get_safe_region() + DetektorContainer().duplicate() + DetektorContainer().get().paste(end) + + self.unset() + + def paste_at(self): + cursor_x = self._plot.cursorLine.getXPos() + DetektorContainer().duplicate() + DetektorContainer().get().paste(cursor_x) + + self.unset() + + def snap_to_x_labels(self): + """Snaps the region boundaries to the nearest discrete X-axis values.""" + min_x, max_x = self.getRegion() + self.setRegion([int(round(min_x)), int(round(max_x))]) + + def display(self): + """ Adds the region to the plot """ + self._plot.graphWidget.addItem(self) + + def hide(self): + """ Removes the region from the plot """ + self._plot.graphWidget.removeItem(self) \ No newline at end of file diff --git a/src/generic_dialog.py b/src/generic_dialog.py new file mode 100644 index 0000000..072df00 --- /dev/null +++ b/src/generic_dialog.py @@ -0,0 +1,27 @@ +from PyQt6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QHBoxLayout, QLabel + + +class GenericDialog(QDialog): + + def __init__(self, title="Dialog", text="Toto je dialog", ok_button="OK"): + super().__init__() + self.setWindowTitle(title) + + layout = QVBoxLayout() + self.setLayout(layout) + + message_label = QLabel(text) + layout.addWidget(message_label) + + # Button layout (horizontal) + button_layout = QHBoxLayout() + ok_button = QPushButton(ok_button) + ok_button.clicked.connect(self.ok) + button_layout.addWidget(ok_button) + + layout.addLayout(button_layout) + + self.exec() + + def ok(self): + super().accept() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..e071157 --- /dev/null +++ b/src/main.py @@ -0,0 +1,26 @@ +from window import create_app +import sys +import logging + + +def main(filename=None): + app, window = create_app(filename) + window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + # Configure logging + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[ + # logging.FileHandler("app.log"), # Log to file + logging.StreamHandler() # Log to console + ] + ) + + # Get filename from command-line arguments if provided + filename = sys.argv[1] if len(sys.argv) > 1 else None + + main(filename) diff --git a/src/menubar.py b/src/menubar.py new file mode 100644 index 0000000..d5c7601 --- /dev/null +++ b/src/menubar.py @@ -0,0 +1,190 @@ +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import QMainWindow, QMenuBar + +from callbacks import CallbackDispatcher +from callbacks import CallbackType +from channel_calibration_dialog import ChannelCalibrationDialog +from detektor_data import DetektorContainer +from detektor_region import DetektorRegionState, DetektorRegion +from open_file import OpenFileDialog +from save_as_dialog import SaveAsFileDialog +from change_start_dialog import ChangeStartDialog +from parsers import export_to_xlsx + + +class Menubar(QMenuBar): + region: DetektorRegion = None + + def __init__(self, main_window: QMainWindow, region: DetektorRegion): + super().__init__(main_window) + + self.main_window = main_window + self.region = region + + CallbackDispatcher().register(CallbackType.REGION_STATE, self.update_enabled) + CallbackDispatcher().register(CallbackType.DATA_PARSED, self.update_enabled) + CallbackDispatcher().register(CallbackType.DATA_NOT_PARSED, self.update_enabled) + CallbackDispatcher().register(CallbackType.DATA_TAINTED, self.update_enabled) + + ################################################## + ### File + ################################################## + file_menu = self.addMenu('Soubor') + + self.open_action = QAction('Otevřít', self) + self.open_action.setShortcut('Ctrl+O') + self.open_action.triggered.connect(self.open_file_dialog) + file_menu.addAction(self.open_action) + + self.save_action = QAction('Uložit', self) + self.save_action.setShortcut('Ctrl+S') + self.save_action.triggered.connect(self.save) + file_menu.addAction(self.save_action) + + self.save_as_action = QAction('Uložit jako...', self) + self.save_as_action.triggered.connect(self.save_as_dialog) + file_menu.addAction(self.save_as_action) + + self.exit_action = QAction('Ukončit', self) + self.exit_action.triggered.connect(self.quit) # Close the application + self.exit_action.setShortcut('Ctrl+W') + file_menu.addAction(self.exit_action) + + ################################################## + ### Data + ################################################## + + data_menu = self.addMenu('Data') + # Create actions for the Edit menu + self.revert_change_action = QAction('Zpět', self) + self.revert_change_action.setShortcut('Ctrl+Z') + self.revert_change_action.triggered.connect(self.revert_change) + data_menu.addAction(self.revert_change_action) + + data_menu.addSeparator() + + self.add_selection_action = QAction('Přidat výběr', self) + self.add_selection_action.setShortcut('Ctrl+A') + self.add_selection_action.triggered.connect(self.region.set) + data_menu.addAction(self.add_selection_action) + + self.cancel_selection_action = QAction('Zrušit výběr', self) + self.cancel_selection_action.setShortcut('Esc') + self.cancel_selection_action.triggered.connect(self.region.unset) + data_menu.addAction(self.cancel_selection_action) + + self.delete_selection_action = QAction('Smazat výběr', self) + self.delete_selection_action.setShortcut('Delete') + self.delete_selection_action.triggered.connect(self.region.delete) + data_menu.addAction(self.delete_selection_action) + + self.copy_action = QAction('Kopírovat', self) + self.copy_action.setShortcut('Ctrl+C') + self.copy_action.triggered.connect(self.region.copy) + data_menu.addAction(self.copy_action) + + self.cut_action = QAction('Vyjmout', self) + self.cut_action.setShortcut('Ctrl+X') + self.cut_action.triggered.connect(self.region.cut) + data_menu.addAction(self.cut_action) + + self.paste_action = QAction('Vložit na kurzor', self) + self.paste_action.setShortcut('Ctrl+V') + self.paste_action.triggered.connect(self.region.paste_at) + data_menu.addAction(self.paste_action) + + self.paste_after_action = QAction('Vložit za výběr', self) + self.paste_after_action.setShortcut('Ctrl+B') + self.paste_after_action.triggered.connect(self.region.paste_after) + data_menu.addAction(self.paste_after_action) + + self.paste_at_end_action = QAction('Vložit na konec', self) + self.paste_at_end_action.setShortcut('Ctrl+K') + self.paste_at_end_action.triggered.connect(self.region.paste_end) + data_menu.addAction(self.paste_at_end_action) + + data_menu.addSeparator() + + self.change_start_action = QAction('Změnit datum a čas', self) + self.change_start_action.setShortcut('Ctrl+M') + self.change_start_action.triggered.connect(self.open_change_start_dialog) + data_menu.addAction(self.change_start_action) + + self.calibrate_channel_action = QAction('Kalibrovat data kanálu', self) + self.calibrate_channel_action.setShortcut('Ctrl+K') + self.calibrate_channel_action.triggered.connect(self.open_calibrate_channel_dialog) + data_menu.addAction(self.calibrate_channel_action) + + self.update_enabled() + + def update_enabled(self): + if self.region.state == DetektorRegionState.UNSET: + self.add_selection_action.setEnabled(True) + self.cancel_selection_action.setEnabled(False) + self.delete_selection_action.setEnabled(False) + self.cut_action.setEnabled(False) + self.copy_action.setEnabled(False) + self.paste_action.setEnabled(False) + self.paste_after_action.setEnabled(False) + self.paste_at_end_action.setEnabled(False) + elif self.region.state == DetektorRegionState.SET: + self.add_selection_action.setEnabled(False) + self.cancel_selection_action.setEnabled(True) + self.delete_selection_action.setEnabled(True) + self.cut_action.setEnabled(True) + self.copy_action.setEnabled(True) + self.paste_action.setEnabled(False) + self.paste_after_action.setEnabled(False) + self.paste_at_end_action.setEnabled(False) + elif self.region.state == DetektorRegionState.COPIED: + self.add_selection_action.setEnabled(False) + self.cancel_selection_action.setEnabled(True) + self.delete_selection_action.setEnabled(True) + self.cut_action.setEnabled(True) + self.copy_action.setEnabled(True) + self.paste_action.setEnabled(True) + self.paste_after_action.setEnabled(True) + self.paste_at_end_action.setEnabled(True) + + if DetektorContainer().get().file_path is None: + # no file path - no save + self.save_action.setEnabled(False) + self.calibrate_channel_action.setEnabled(False) + self.change_start_action.setEnabled(False) + else: + if DetektorContainer().get().file_path.endswith('.xlsx'): + # loaded XLSX -> we can save it + self.save_action.setEnabled(True) + else: + # Loaded DBF -> no save + self.save_action.setEnabled(False) + self.calibrate_channel_action.setEnabled(True) + self.change_start_action.setEnabled(True) + + if DetektorContainer().has_history(): + self.revert_change_action.setEnabled(True) + else: + self.revert_change_action.setEnabled(False) + + def open_change_start_dialog(self): + diag = ChangeStartDialog() + + def open_calibrate_channel_dialog(self): + diag = ChannelCalibrationDialog(self.region) + + def open_file_dialog(self): + diag = OpenFileDialog(DetektorContainer().get()) + + def save_as_dialog(self): + diag = SaveAsFileDialog() + + def save(self): + export_to_xlsx(DetektorContainer().get().file_path) + + def revert_change(self): + DetektorContainer().revert() + + def quit(self): + """ Decides what to do with the saving or quitting """ + self.main_window.close() + diff --git a/src/moving_average_dialog.py b/src/moving_average_dialog.py new file mode 100644 index 0000000..72d210c --- /dev/null +++ b/src/moving_average_dialog.py @@ -0,0 +1,78 @@ +import logging + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QIntValidator +from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton, QLineEdit, QHBoxLayout + +from detektor_data import DetektorContainer +from callbacks import CallbackDispatcher, CallbackType + + +class MovingAverageDialog(QDialog): + + def __init__(self): + super().__init__() + + self.setWindowTitle("Klouzavý průměr") + self.setModal(True) # Set the dialog as modal (blocks main window) + self.resize(300, 150) + + # Main layout + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + input_layout = QHBoxLayout() + input_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) + + add_label = QLabel("Nastavit klouzavý průměr na ") + input_layout.addWidget(add_label) + + self.add_input = QLineEdit() + self.add_input.setValidator(QIntValidator(1,9999)) + self.add_input.setText('30') + self.add_input.setFixedWidth(50) + input_layout.addWidget(self.add_input) + + add_label = QLabel("sekund.") + input_layout.addWidget(add_label) + + main_layout.addLayout(input_layout) + + # Buttons layout (aligned at the bottom, next to each other) + button_layout = QHBoxLayout() + + calibrate_button = QPushButton("Nastavit průměr") + calibrate_button.clicked.connect(self.accept) # Close the dialog when the button is clicked + button_layout.addWidget(calibrate_button) + + cancel_button = QPushButton("Zrušit") + cancel_button.clicked.connect(self.close) # Close the dialog when the button is clicked + button_layout.addWidget(cancel_button) + + # Add buttons to the main layout below both columns + main_layout.addLayout(button_layout) + + # Show the dialog + self.exec() + + def accept(self): + DetektorContainer().duplicate() + logging.debug(f'Setting moving average to {self.add_input.text()}s') + # TODO: this thinks, that the interval of data is 1000ms + number_of_samples = DetektorContainer().get().data_count() + for c in DetektorContainer().get().channels: + new_data = [] + + for k, _ in enumerate(c.data): + start_index = k + end_index = k + int(self.add_input.text()) + list_for_averaging = c.data[start_index:end_index] + new_data.append( + sum(list_for_averaging) / len(list_for_averaging) + ) + + c.data = new_data + CallbackDispatcher().call(CallbackType.UPDATE_CHANNEL, c.id, True) + + CallbackDispatcher().call(CallbackType.DATA_TAINTED) + super().accept() diff --git a/src/open_file.py b/src/open_file.py new file mode 100644 index 0000000..2a63f1b --- /dev/null +++ b/src/open_file.py @@ -0,0 +1,74 @@ +import logging +import os + +from PyQt6.QtWidgets import QDialog, QFileDialog + +from parsers import parse_dbf_to_detektor, parse_xls_to_detektor +from callbacks import CallbackDispatcher, CallbackType +from generic_dialog import GenericDialog +from detektor_data import DetektorContainer, DetektorData + + +class OpenFileDialog(QDialog): + def __init__(self, data): + super().__init__() + self.data = data + + file_path, _ = QFileDialog.getOpenFileName( + self, + "Otevřít soubor", + "", + "Comet MS+ / Detektor XLSX (*.dbf *.xlsx);;Všechny soubory (*.*)" + ) + + if file_path: + file_opener(file_path) + + +def file_opener(file_path: str = ""): + """ Gets file path and tries to open it """ + + if not os.path.isfile(file_path): # Check if it's a valid file + logging.error(f"{file_path} is not a valid file.") + GenericDialog('', f'Soubor {file_path} neexistuje') + return False + + try: + with open(file_path, "r") as f: # Check if the file is readable + pass + except IOError: + logging.error(f"{file_path} cannot be opened.") + GenericDialog('', f'Soubor {file_path} nelze otevřít') + return False + + name, extension = file_path.rsplit('.', 1) if '.' in file_path else (file_path, "") + extension = extension.lower() + if extension not in ['xlsx','dbf']: + GenericDialog('', f'Soubor typu \'{extension}\' není podporován') + return False + else: + logging.debug(f'Opening file {file_path}') + d = DetektorContainer().get() + if not d: + d = DetektorData() + DetektorContainer().set(d) + else: + DetektorContainer().get().flush() + + if extension == "xlsx": + status = parse_xls_to_detektor(file_path) + elif extension == "dbf": + status = parse_dbf_to_detektor(file_path) + else: + status = False + + if status: + DetektorContainer().get().file_path = file_path + + CallbackDispatcher().call(CallbackType.UPDATE_X) + CallbackDispatcher().call(CallbackType.DATA_PARSED) + + else: + DetektorContainer().get().file_path = None + CallbackDispatcher().call(CallbackType.DATA_NOT_PARSED) + GenericDialog('', 'Soubor se nepodařilo načíst') \ No newline at end of file diff --git a/src/parsers.py b/src/parsers.py new file mode 100644 index 0000000..02ecb00 --- /dev/null +++ b/src/parsers.py @@ -0,0 +1,148 @@ +import logging +from datetime import timedelta + +import pandas as pd + +from channel import Channel +from config import ChannelColors +from detektor_data import DetektorContainer +from dbfread import DBF + +from channel import ChannelUnit +from detektor_data import DetektorData + + +def parse_dbf_to_detektor(dbf_file: str) -> bool: + + try: + interval_ms = 1000 + + # Convert to DataFrame + df = pd.DataFrame(iter(DBF(dbf_file, encoding="utf-8"))) + + # Create a datetime column + df["TIMESTAMP"] = pd.to_datetime(df["DATUM"].astype(str) + " " + df["CAS"], format="%Y-%m-%d %H:%M:%S") + + # Drop unnecessary columns + df = df.drop(columns=["DATUM", "CAS", "VYPADEK"]) + + # Set timestamp as index + df = df.set_index("TIMESTAMP") + + # Generate the complete time range + start_time = df.index.min() + end_time = df.index.max() + full_time_range = pd.date_range(start=start_time, end=end_time, freq=f"{interval_ms}ms") + + # Reindex the DataFrame to include missing timestamps, filling with 0s + df = df.reindex(full_time_range, fill_value=0) + + DetektorContainer().get().file_path = dbf_file + DetektorContainer().get().start_datetime = start_time + DetektorContainer().get().interval_ms = interval_ms + + # Assign colors to channels + for i, column in enumerate(df.columns): + color = ChannelColors[i % len(ChannelColors)] # Cycle through colors + channel = Channel() + channel.name=column + channel.unit=ChannelUnit.PPM + channel.color=color + channel.data = df[column].tolist() + DetektorContainer().get().add_channel(channel) + except Exception as e: + logging.error(e) + return False + + return True + + +def parse_xls_to_detektor(xls_file: str) -> bool: + """ + Parses an XLSX file into a DetektorData structure. + """ + + # Load the XLSX file + try: +# if True: + xls_data = pd.ExcelFile(xls_file) + + df = xls_data.parse(xls_data.sheet_names[0]) # Assume data is in the first sheet + + # (re)Initialize DetektorData + DetektorContainer().flush() + d = DetektorData() + DetektorContainer().set(d) + + DetektorContainer().get().start_datetime = pd.to_datetime(df.iloc[1, 0], errors="coerce") + logging.debug(f'Parsed start_datetime: {DetektorContainer().get().start_datetime}') + + # if we have at least two lines of data, calculate the interval + if len(df) >= 3: + interval = int( + (pd.to_datetime(df.iloc[2, 0]) - DetektorContainer().get().start_datetime).total_seconds() * 1000 + ) + DetektorContainer().get().interval_ms = interval + logging.debug(f'Parsed interval: {interval}') + else: + interval = 1000 + DetektorContainer().get().interval_ms = interval + logging.debug(f'Interval set to {interval}') + + + # Create channels + for idx, col in enumerate(df.columns[1:]): + channel = Channel() + channel.name = str(col) + channel.number = idx + 1 + channel.data = list(df.iloc[1:, idx + 1]) + channel.color = ChannelColors[idx % len(ChannelColors)] + DetektorContainer().get().add_channel(channel) + + logging.debug(f'Parsed channel {col}, data count of {len(channel.data)} records') + + except Exception as e: + logging.error(e) + return False + + return True + +def export_to_xlsx(xlsx_file: str): + # Example data + export = dict() + + export["Datum"] = [] + # Start from the initial time + current_time = DetektorContainer().get().start_datetime + + for i in range(DetektorContainer().get().data_count()): + # Format and add label + export["Datum"].append(current_time) + + # Increment time + current_time += timedelta(milliseconds=DetektorContainer().get().interval_ms) + + for c in DetektorContainer().get().channels: + export[c.name] = c.data + + # Create DataFrame + df = pd.DataFrame(export) + + #df["Datum"] = pd.to_datetime(df["Datum"]) + + # Export to XLSX with formatting + with pd.ExcelWriter(xlsx_file, engine="xlsxwriter") as writer: + df.to_excel(writer, sheet_name="Sheet1", index=False) + logging.debug(f'Saving to file {xlsx_file}') + + # Get workbook and worksheet objects + #workbook = writer.book + #worksheet = writer.sheets["Sheet1"] + + # Define date format + #date_format = workbook.add_format({"num_format": "dd.mm.yyyy hh:mm:ss"}) + + # Apply format to the 'Timestamp' column (1-based index, first column = 0) + #worksheet.set_column("A:A", 20, date_format) + + return True \ No newline at end of file diff --git a/src/quit_dialog.py b/src/quit_dialog.py new file mode 100644 index 0000000..79884ec --- /dev/null +++ b/src/quit_dialog.py @@ -0,0 +1,62 @@ +import logging + +from PyQt6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QHBoxLayout, QLabel + +from detektor_data import DetektorContainer +from parsers import export_to_xlsx +from save_as_dialog import SaveAsFileDialog + + +class QuitDialog(QDialog): + + def __init__(self): + super().__init__() + self.setWindowTitle("Ukončit") + self.setModal(True) + #self.setFixedSize(200, 100) + + layout = QVBoxLayout() + self.setLayout(layout) + + # Message label + #message_label = QLabel("Data byla změněna. Uložit?") + #layout.addWidget(message_label) + + # Button layout (horizontal) + button_layout = QHBoxLayout() + save_button = QPushButton("Uložit a ukončit") + cancel_button = QPushButton("Zpět") + discard_button = QPushButton("Zahodit a ukončit") + + save_button.clicked.connect(self.save) + cancel_button.clicked.connect(self.cancel) + discard_button.clicked.connect(self.discard) + + button_layout.addWidget(save_button) + button_layout.addWidget(cancel_button) + button_layout.addWidget(discard_button) + + layout.addLayout(button_layout) + + def save(self): + if DetektorContainer().get().file_path.endswith('.xlsx'): + saved = export_to_xlsx(DetektorContainer().get().file_path) + else: + saved = SaveAsFileDialog() + + if saved: + logging.debug('Saving succeeded') + super().accept() + else: + logging.debug('Saving didn\'t work out') + super().reject() + + def cancel(self): + logging.debug('Saving discardded, going back') + super().reject() + + def discard(self): + logging.debug('Data discardded') + super().accept() + + diff --git a/src/save_as_dialog.py b/src/save_as_dialog.py new file mode 100644 index 0000000..72675a1 --- /dev/null +++ b/src/save_as_dialog.py @@ -0,0 +1,33 @@ +import logging + +from PyQt6.QtWidgets import QFileDialog + +from detektor_data import DetektorContainer +from parsers import export_to_xlsx + + +class SaveAsFileDialog(QFileDialog): + def __init__(self, parent=None, default_name="", file_types="Detektor XLS (*.xlsx)"): + super().__init__(parent) + + self.setWindowTitle("Uložit soubor") + self.setAcceptMode(QFileDialog.AcceptMode.AcceptSave) # Set to save mode + self.setNameFilters(file_types.split(";;")) # Apply file filters + self.setDefaultSuffix("xlsx") # Default file extension + self.selectFile(default_name) # Pre-fill filename if provided + + if self.exec(): + self.accept() + else: + self.reject() + + def accept(self): + selected_file = self.selectedFiles()[0] if self.selectedFiles() else None + if selected_file: + export_to_xlsx(selected_file) + DetektorContainer().get().file_path = selected_file + super().accept() + + def reject(self): + logging.debug('"Save as" file dialog canceled') + super().reject() \ No newline at end of file diff --git a/src/widgets.py b/src/widgets.py new file mode 100644 index 0000000..0793ee8 --- /dev/null +++ b/src/widgets.py @@ -0,0 +1,20 @@ +from PyQt6.QtCore import QSize +from PyQt6.QtGui import QColor, QPainter +from PyQt6.QtWidgets import QWidget + + +class RoundedColorRectangleWidget(QWidget): + def __init__(self, color, width=12, height=12, radius=4): + super().__init__() + self.color = QColor(color) + self.width = width + self.height = height + self.radius = radius + self.setFixedSize(QSize(width, height)) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) # Enable smooth edges + painter.setBrush(self.color) + painter.setPen(self.color) + painter.drawRoundedRect(0, 0, self.width, self.height, self.radius, self.radius) diff --git a/src/window.py b/src/window.py new file mode 100644 index 0000000..8f07494 --- /dev/null +++ b/src/window.py @@ -0,0 +1,128 @@ +import sys + +from PyQt6.QtWidgets import ( + QApplication, + QMainWindow, + QWidget, + QVBoxLayout, + QHBoxLayout, QDialog, +) + +from PyQt6.QtCore import Qt + +import config +from detektor_data import generate_data +from detektor_plot import DetektorPlot +from detektor_region import DetektorRegion +from channels_menu import ChannelsMenu +from chart_menu import ChartMenu +from data_menu import DataMenu +from menubar import Menubar +from quit_dialog import QuitDialog +from callbacks import CallbackDispatcher, CallbackType +from open_file import file_opener +from detektor_data import DetektorContainer + + +class MainWindow(QMainWindow): + region: DetektorRegion = None + plot: DetektorPlot = None + + channels_menu: ChannelsMenu = None + chart_menu = None + data_menu = None + + def __init__(self, data_file: str = None): + super().__init__() + + if data_file: + file_opener(data_file) + else: + DetektorContainer().set( + generate_data(channel_count=3, data_count=200, random_values=True) + ) + + self.set_base_title() + self.setGeometry(0, 0, 1800, 800) + self.showMaximized() + + CallbackDispatcher().register(CallbackType.FILE_NAME_CHANGED, self.set_base_title) + + self.main_layout() + # menubar has to be loaded after layout to have DetektorRegion available + self.setMenuBar(Menubar(self, self.region)) + + def set_base_title(self): + """Set the base window title (default or with filename).""" + title = f"{config.APP_NAME} {config.APP_VERSION}" + + if DetektorContainer().get() and DetektorContainer().get().file_path: + # Append filename if available + title += f" ({DetektorContainer().get().file_path})" + + self.setWindowTitle(title) + + def main_layout(self): + central_widget = QWidget() + self.setCentralWidget(central_widget) + + main_layout = QHBoxLayout() + central_widget.setLayout(main_layout) + + self.left_layout(main_layout) + self.right_layout(main_layout) + + def left_layout(self, main_layout: QHBoxLayout): + left_layout = QVBoxLayout() + main_layout.addLayout(left_layout) + + self.plot = DetektorPlot(left_layout) + self.region = DetektorRegion(self.plot) + + def right_layout(self, main_layout: QHBoxLayout): + # Create a fixed-size widget to hold the right layout + right_widget = QWidget() + right_widget.setFixedWidth(220) # Ensure the width stays fixed + + # Create a vertical layout inside the fixed-width widget + right_layout = QVBoxLayout() + right_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + right_widget.setLayout(right_layout) # Set the layout inside the fixed widget + + # Add the fixed widget (not just the layout) to the main layout + main_layout.addWidget(right_widget) + + # Add contents to right_layout + self.channels_menu = ChannelsMenu(right_layout) + self.chart_menu = ChartMenu(right_layout, self.plot) + self.data_menu = DataMenu(right_layout, self.region) + + def closeEvent(self, event): + if DetektorContainer().get().data_tainted: + diag = QuitDialog() + + if diag.exec() == QDialog.DialogCode.Accepted: + """Properly clean up before closing.""" + if self.plot: + if self.plot.view_box: + self.plot.view_box.clear() + self.plot.view_box.deleteLater() + self.plot.view_box = None + + if self.plot.graphWidget: + self.plot.graphWidget.clear() # Clear all items + self.plot.graphWidget.deleteLater() + self.plot.graphWidget = None + + event.accept() # Allow window to close + else: + # Prevent closing if "Cancel" was clicked + event.ignore() + else: + # Allow closing if data is not tainted + event.accept() + +def create_app(filename: str = None): + app = QApplication(sys.argv) + window = MainWindow(filename) + return app, window