Verze po zapracovani prvni vlny pozadavku
30
.gitignore
vendored
Normal 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
BIN
assets/icon.iconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
assets/icon.iconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 658 B |
BIN
assets/icon.iconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
assets/icon.iconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/icon.iconset/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/line-chart_24x24.png
Normal file
|
After Width: | Height: | Size: 1007 B |
BIN
assets/line-chart_64x64.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
76
build-config/macos_build.spec
Normal 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'
|
||||
}
|
||||
)
|
||||
58
build-config/windows_build.spec
Normal 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
@@ -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
51
src/callbacks.py
Normal 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)
|
||||
86
src/change_start_dialog.py
Normal 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
@@ -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
|
||||
198
src/channel_calibration_dialog.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
78
src/moving_average_dialog.py
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||