commit 5ac86d408cd8c5201876fde44b5492d3994d0683 Author: Martin Kudlacek Date: Mon Mar 10 17:10:04 2025 +0100 Verze po zapracovani prvni vlny pozadavku 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 0000000..d731efd Binary files /dev/null and b/assets/icon.icns differ diff --git a/assets/icon.iconset/icon_128x128.png b/assets/icon.iconset/icon_128x128.png new file mode 100644 index 0000000..f89b57e Binary files /dev/null and b/assets/icon.iconset/icon_128x128.png differ diff --git a/assets/icon.iconset/icon_16x16.png b/assets/icon.iconset/icon_16x16.png new file mode 100644 index 0000000..ee5b562 Binary files /dev/null and b/assets/icon.iconset/icon_16x16.png differ diff --git a/assets/icon.iconset/icon_256x256.png b/assets/icon.iconset/icon_256x256.png new file mode 100644 index 0000000..0d8cbbe Binary files /dev/null and b/assets/icon.iconset/icon_256x256.png differ diff --git a/assets/icon.iconset/icon_32x32.png b/assets/icon.iconset/icon_32x32.png new file mode 100644 index 0000000..c0ce73f Binary files /dev/null and b/assets/icon.iconset/icon_32x32.png differ diff --git a/assets/icon.iconset/icon_512x512.png b/assets/icon.iconset/icon_512x512.png new file mode 100644 index 0000000..dc4d74a Binary files /dev/null and b/assets/icon.iconset/icon_512x512.png differ diff --git a/assets/line-chart_24x24.png b/assets/line-chart_24x24.png new file mode 100644 index 0000000..c9e359d Binary files /dev/null and b/assets/line-chart_24x24.png differ diff --git a/assets/line-chart_64x64.png b/assets/line-chart_64x64.png new file mode 100644 index 0000000..1c25aeb Binary files /dev/null and b/assets/line-chart_64x64.png differ 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