Verze po zapracovani prvni vlny pozadavku

This commit is contained in:
Martin Kudlacek
2025-03-10 17:10:04 +01:00
commit 5ac86d408c
34 changed files with 2220 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@@ -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

BIN
assets/icon.icns Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/line-chart_24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1007 B

BIN
assets/line-chart_64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -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'
}
)

View File

@@ -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'
)

10
requirements.txt Normal file
View File

@@ -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

0
src/__init__.py Normal file
View File

51
src/callbacks.py Normal file
View File

@@ -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)

View File

@@ -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()

64
src/channel.py Normal file
View File

@@ -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

View File

@@ -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)

63
src/channels_menu.py Normal file
View File

@@ -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()

29
src/chart_menu.py Normal file
View File

@@ -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)

37
src/config.py Normal file
View File

@@ -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
]

112
src/data_menu.py Normal file
View File

@@ -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)

266
src/detektor_data.py Normal file
View File

@@ -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

215
src/detektor_plot.py Normal file
View File

@@ -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()

139
src/detektor_region.py Normal file
View File

@@ -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)

27
src/generic_dialog.py Normal file
View File

@@ -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()

26
src/main.py Normal file
View File

@@ -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)

190
src/menubar.py Normal file
View File

@@ -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()

View File

@@ -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()

74
src/open_file.py Normal file
View File

@@ -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')

148
src/parsers.py Normal file
View File

@@ -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

62
src/quit_dialog.py Normal file
View File

@@ -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()

33
src/save_as_dialog.py Normal file
View File

@@ -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()

20
src/widgets.py Normal file
View File

@@ -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)

128
src/window.py Normal file
View File

@@ -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