914 lines
38 KiB
Python
914 lines
38 KiB
Python
import sys
|
|
import serial
|
|
import serial.tools.list_ports
|
|
from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
|
|
QWidget, QLabel, QPushButton, QComboBox, QDoubleSpinBox,
|
|
QGroupBox, QStatusBar, QMessageBox, QCheckBox)
|
|
from PyQt6.QtCore import QTimer, Qt
|
|
from PyQt6.QtGui import QFont
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
from matplotlib.figure import Figure
|
|
import datetime
|
|
from collections import deque
|
|
import numpy as np
|
|
|
|
class PHControllerGUI(QMainWindow):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.serial_connection = None
|
|
self.updating_tube_combo = False # Flag to prevent recursive tube commands
|
|
self.setWindowTitle("Arduino pH Controller")
|
|
self.setGeometry(100, 100, 900, 800) # Larger window for plot
|
|
|
|
# Central Widget
|
|
central_widget = QWidget()
|
|
self.setCentralWidget(central_widget)
|
|
main_layout = QVBoxLayout(central_widget)
|
|
|
|
# Connection Group
|
|
connection_group = QGroupBox("Verbindung")
|
|
connection_layout = QHBoxLayout()
|
|
|
|
self.port_combo = QComboBox()
|
|
self.refresh_ports()
|
|
|
|
self.connect_btn = QPushButton("Verbinden")
|
|
self.connect_btn.clicked.connect(self.toggle_connection)
|
|
|
|
connection_layout.addWidget(QLabel("Port:"))
|
|
connection_layout.addWidget(self.port_combo, 1)
|
|
connection_layout.addWidget(self.connect_btn)
|
|
connection_group.setLayout(connection_layout)
|
|
|
|
# Status Group
|
|
status_group = QGroupBox("Status")
|
|
status_layout = QHBoxLayout()
|
|
|
|
self.ph_label = QLabel("pH: --")
|
|
self.ph_label.setFont(QFont('Arial', 24))
|
|
self.ph_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
self.pump_status = QLabel("Pumpe: Aus")
|
|
self.pump_status.setFont(QFont('Arial', 16))
|
|
self.pump_status.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
self.flow_rate_label = QLabel("ml/sec: --")
|
|
self.flow_rate_label.setFont(QFont('Arial', 14))
|
|
self.flow_rate_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.flow_rate_label.setVisible(False) # Initially hidden
|
|
|
|
self.total_volume_label = QLabel("Gesamt: 0.0 ml")
|
|
self.total_volume_label.setFont(QFont('Arial', 12))
|
|
self.total_volume_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
self.total_volume_label.setVisible(False) # Initially hidden
|
|
|
|
status_layout.addWidget(self.ph_label)
|
|
status_layout.addWidget(self.pump_status)
|
|
status_layout.addWidget(self.flow_rate_label)
|
|
status_layout.addWidget(self.total_volume_label)
|
|
status_group.setLayout(status_layout)
|
|
|
|
# Control Group
|
|
control_group = QGroupBox("Steuerung")
|
|
control_layout = QVBoxLayout()
|
|
|
|
# Pump Control
|
|
pump_control_layout = QHBoxLayout()
|
|
self.pump_on_btn = QPushButton("Pumpe Ein")
|
|
self.pump_on_btn.clicked.connect(lambda: self.send_pump_command(1))
|
|
self.pump_off_btn = QPushButton("Pumpe Aus")
|
|
self.pump_off_btn.clicked.connect(lambda: self.send_pump_command(0))
|
|
|
|
# Volume reset option
|
|
self.reset_volume_checkbox = QCheckBox("Volumen bei Start zurücksetzen")
|
|
self.reset_volume_checkbox.setChecked(True) # Default: reset volume
|
|
|
|
pump_control_layout.addWidget(self.pump_on_btn)
|
|
pump_control_layout.addWidget(self.pump_off_btn)
|
|
pump_control_layout.addWidget(self.reset_volume_checkbox)
|
|
|
|
# Tube Selection
|
|
tube_layout = QHBoxLayout()
|
|
self.tube_combo = QComboBox()
|
|
self.tube_combo.addItems([
|
|
"0.13 mm", "0.19 mm", "0.25 mm", "0.38 mm", "0.44 mm",
|
|
"0.51 mm", "0.57 mm", "0.64 mm", "0.76 mm", "0.89 mm",
|
|
"0.95 mm", "1.02 mm", "1.09 mm", "1.14 mm", "1.22 mm",
|
|
"1.30 mm", "1.42 mm", "1.52 mm", "1.65 mm", "1.75 mm",
|
|
"1.85 mm", "2.06 mm", "2.29 mm", "2.54 mm", "2.79 mm", "3.17 mm"
|
|
])
|
|
self.tube_combo.currentIndexChanged.connect(self.send_tube_command)
|
|
|
|
tube_layout.addWidget(QLabel("Schlauch:"))
|
|
tube_layout.addWidget(self.tube_combo, 1)
|
|
|
|
# Calibration
|
|
calibration_layout = QHBoxLayout()
|
|
self.cal_ph4_btn = QPushButton("Kalibrieren pH4")
|
|
self.cal_ph7_btn = QPushButton("Kalibrieren pH7")
|
|
self.debug_btn = QPushButton("Debug Info")
|
|
self.cal_ph4_btn.clicked.connect(lambda: self.send_calibration_command(4))
|
|
self.cal_ph7_btn.clicked.connect(lambda: self.send_calibration_command(7))
|
|
self.debug_btn.clicked.connect(self.send_debug_command)
|
|
|
|
calibration_layout.addWidget(self.cal_ph4_btn)
|
|
calibration_layout.addWidget(self.cal_ph7_btn)
|
|
calibration_layout.addWidget(self.debug_btn)
|
|
|
|
# Auto Mode
|
|
auto_mode_layout = QHBoxLayout()
|
|
self.volume_spin = QDoubleSpinBox()
|
|
self.volume_spin.setRange(0.1, 1000)
|
|
self.volume_spin.setValue(10)
|
|
self.volume_spin.setSuffix(" ml")
|
|
|
|
self.auto_dose_btn = QPushButton("Automatisch dosieren")
|
|
self.auto_dose_btn.clicked.connect(self.start_auto_dose)
|
|
|
|
auto_mode_layout.addWidget(QLabel("Menge:"))
|
|
auto_mode_layout.addWidget(self.volume_spin)
|
|
auto_mode_layout.addWidget(self.auto_dose_btn)
|
|
|
|
# Add to control layout
|
|
control_layout.addLayout(pump_control_layout)
|
|
control_layout.addLayout(tube_layout)
|
|
control_layout.addLayout(calibration_layout)
|
|
control_layout.addLayout(auto_mode_layout)
|
|
control_group.setLayout(control_layout)
|
|
|
|
# Add groups to main layout
|
|
main_layout.addWidget(connection_group)
|
|
main_layout.addWidget(status_group)
|
|
main_layout.addWidget(control_group)
|
|
|
|
# pH Plot Group
|
|
plot_group = QGroupBox("pH-Verlauf")
|
|
plot_layout = QVBoxLayout()
|
|
|
|
# Plot controls
|
|
plot_controls_layout = QHBoxLayout()
|
|
self.clear_plot_btn = QPushButton("Diagramm löschen")
|
|
self.clear_plot_btn.clicked.connect(self.clear_ph_plot)
|
|
self.plot_enabled_checkbox = QCheckBox("pH-Aufzeichnung")
|
|
self.plot_enabled_checkbox.setChecked(True)
|
|
|
|
plot_controls_layout.addWidget(self.plot_enabled_checkbox)
|
|
plot_controls_layout.addWidget(self.clear_plot_btn)
|
|
plot_controls_layout.addStretch()
|
|
|
|
# pH Plot Widget
|
|
self.ph_plot = PHPlotWidget()
|
|
|
|
plot_layout.addLayout(plot_controls_layout)
|
|
plot_layout.addWidget(self.ph_plot)
|
|
plot_group.setLayout(plot_layout)
|
|
|
|
main_layout.addWidget(plot_group)
|
|
|
|
# Titration Plot Group
|
|
titration_group = QGroupBox("Titration")
|
|
titration_layout = QVBoxLayout()
|
|
|
|
# Titration controls
|
|
titration_controls_layout = QHBoxLayout()
|
|
|
|
# Titration parameters
|
|
self.titration_step_spin = QDoubleSpinBox()
|
|
self.titration_step_spin.setRange(0.1, 10.0)
|
|
self.titration_step_spin.setValue(0.5)
|
|
self.titration_step_spin.setSuffix(" ml")
|
|
self.titration_step_spin.setDecimals(1)
|
|
|
|
self.titration_wait_spin = QDoubleSpinBox()
|
|
self.titration_wait_spin.setRange(5.0, 60.0)
|
|
self.titration_wait_spin.setValue(10.0)
|
|
self.titration_wait_spin.setSuffix(" s")
|
|
self.titration_wait_spin.setDecimals(1)
|
|
|
|
self.start_titration_btn = QPushButton("Automatische Titration starten")
|
|
self.start_titration_btn.clicked.connect(self.toggle_auto_titration)
|
|
self.clear_titration_btn = QPushButton("Titrationsdaten löschen")
|
|
self.clear_titration_btn.clicked.connect(self.clear_titration_plot)
|
|
|
|
titration_controls_layout.addWidget(QLabel("Schritt:"))
|
|
titration_controls_layout.addWidget(self.titration_step_spin)
|
|
titration_controls_layout.addWidget(QLabel("Warten:"))
|
|
titration_controls_layout.addWidget(self.titration_wait_spin)
|
|
titration_controls_layout.addWidget(self.start_titration_btn)
|
|
titration_controls_layout.addWidget(self.clear_titration_btn)
|
|
|
|
# Titration Plot Widget
|
|
self.titration_plot = TitrationPlotWidget()
|
|
|
|
titration_layout.addLayout(titration_controls_layout)
|
|
titration_layout.addWidget(self.titration_plot)
|
|
titration_group.setLayout(titration_layout)
|
|
|
|
main_layout.addWidget(titration_group)
|
|
|
|
# Status Bar
|
|
self.status_bar = QStatusBar()
|
|
self.setStatusBar(self.status_bar)
|
|
self.status_bar.showMessage("Bereit")
|
|
|
|
# Timer for reading data
|
|
self.read_timer = QTimer()
|
|
self.read_timer.timeout.connect(self.read_serial_data)
|
|
self.read_timer.start(100) # Read incoming data every 100ms
|
|
|
|
# Timer for pH value requests
|
|
self.ph_request_timer = QTimer()
|
|
self.ph_request_timer.timeout.connect(self.request_ph_value)
|
|
|
|
# Timer for tube ID requests (every 5 seconds)
|
|
self.tube_request_timer = QTimer()
|
|
self.tube_request_timer.timeout.connect(self.request_tube_id)
|
|
|
|
# Timer for flow rate requests (when pump is running)
|
|
self.flow_rate_timer = QTimer()
|
|
self.flow_rate_timer.timeout.connect(self.request_flow_rate)
|
|
|
|
# Track pump state
|
|
self.pump_is_running = False
|
|
|
|
# Volume tracking
|
|
self.total_volume = 0.0 # Total volume pumped in ml
|
|
self.last_flow_rate = 0.0 # Last known flow rate
|
|
|
|
# Auto dosing
|
|
self.auto_dosing = False
|
|
self.target_volume = 0.0
|
|
self.auto_dose_timer = QTimer()
|
|
self.auto_dose_timer.timeout.connect(self.check_auto_dose_progress)
|
|
|
|
# Titration tracking
|
|
self.titration_active = False
|
|
self.titration_start_volume = 0.0
|
|
self.auto_titration_active = False
|
|
self.titration_step_size = 0.5 # ml per step
|
|
self.titration_wait_time = 10.0 # seconds to wait between steps
|
|
self.titration_timer = QTimer()
|
|
self.titration_timer.timeout.connect(self.titration_step)
|
|
self.titration_current_step = 0
|
|
self.titration_target_volume = 0.0
|
|
|
|
# Enable/disable controls based on connection
|
|
self.set_controls_enabled(False)
|
|
|
|
def refresh_ports(self):
|
|
self.port_combo.clear()
|
|
ports = serial.tools.list_ports.comports()
|
|
for port in ports:
|
|
self.port_combo.addItem(port.device)
|
|
|
|
def toggle_connection(self):
|
|
if self.serial_connection and self.serial_connection.is_open:
|
|
self.close_connection()
|
|
else:
|
|
self.open_connection()
|
|
|
|
def open_connection(self):
|
|
port = self.port_combo.currentText()
|
|
if not port:
|
|
QMessageBox.critical(self, "Fehler", "Kein Port ausgewählt!")
|
|
return
|
|
|
|
try:
|
|
self.serial_connection = serial.Serial(port, 9600, timeout=1)
|
|
self.connect_btn.setText("Trennen")
|
|
self.status_bar.showMessage(f"Verbunden mit {port}")
|
|
self.set_controls_enabled(True)
|
|
|
|
# Start pH value polling every 2 seconds
|
|
self.ph_request_timer.start(2000)
|
|
|
|
# Start tube ID polling every 5 seconds
|
|
self.tube_request_timer.start(5000)
|
|
|
|
# Request initial data after a short delay to ensure Arduino is ready
|
|
QTimer.singleShot(500, self.request_initial_data)
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Fehler", f"Verbindungsfehler: {str(e)}")
|
|
|
|
def close_connection(self):
|
|
# Turn off pump before closing connection for safety
|
|
if self.serial_connection and self.serial_connection.is_open:
|
|
try:
|
|
self.send_command("<12>") # Pump off for safety
|
|
# Give Arduino time to process the command
|
|
import time
|
|
time.sleep(0.1)
|
|
except:
|
|
pass # Ignore errors during shutdown
|
|
|
|
if self.serial_connection and self.serial_connection.is_open:
|
|
self.serial_connection.close()
|
|
self.serial_connection = None
|
|
|
|
# Stop pH polling
|
|
self.ph_request_timer.stop()
|
|
|
|
# Stop tube ID polling
|
|
self.tube_request_timer.stop()
|
|
|
|
# Stop flow rate polling
|
|
self.flow_rate_timer.stop()
|
|
|
|
# Stop auto dose timer
|
|
self.auto_dose_timer.stop()
|
|
self.auto_dosing = False
|
|
|
|
# Stop titration timer
|
|
self.titration_timer.stop()
|
|
self.auto_titration_active = False
|
|
|
|
self.connect_btn.setText("Verbinden")
|
|
self.status_bar.showMessage("Getrennt")
|
|
self.set_controls_enabled(False)
|
|
self.ph_label.setText("pH: --")
|
|
self.pump_status.setText("Pumpe: --")
|
|
self.flow_rate_label.setVisible(False)
|
|
self.total_volume_label.setVisible(False)
|
|
self.pump_is_running = False
|
|
self.total_volume = 0.0
|
|
self.last_flow_rate = 0.0
|
|
self.auto_dosing = False
|
|
self.auto_dose_timer.stop()
|
|
self.auto_titration_active = False
|
|
self.titration_timer.stop()
|
|
|
|
def set_controls_enabled(self, enabled):
|
|
self.pump_on_btn.setEnabled(enabled)
|
|
self.pump_off_btn.setEnabled(enabled)
|
|
self.tube_combo.setEnabled(enabled)
|
|
self.cal_ph4_btn.setEnabled(enabled)
|
|
self.cal_ph7_btn.setEnabled(enabled)
|
|
self.debug_btn.setEnabled(enabled)
|
|
self.auto_dose_btn.setEnabled(enabled)
|
|
self.volume_spin.setEnabled(enabled)
|
|
self.reset_volume_checkbox.setEnabled(enabled)
|
|
self.clear_plot_btn.setEnabled(enabled)
|
|
self.plot_enabled_checkbox.setEnabled(enabled)
|
|
self.start_titration_btn.setEnabled(enabled)
|
|
self.clear_titration_btn.setEnabled(enabled)
|
|
self.titration_step_spin.setEnabled(enabled)
|
|
self.titration_wait_spin.setEnabled(enabled)
|
|
|
|
def send_command(self, command):
|
|
print(command)
|
|
if self.serial_connection and self.serial_connection.is_open:
|
|
try:
|
|
self.serial_connection.write(command.encode('utf-8'))
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Fehler beim Senden: {str(e)}")
|
|
|
|
def request_initial_data(self):
|
|
"""Request initial data from Arduino after connection"""
|
|
if self.serial_connection and self.serial_connection.is_open:
|
|
# self.send_command("<14>") # Request current pH value
|
|
self.send_command("<13>") # Request current tube size
|
|
self.status_bar.showMessage("Initiale Daten angefragt...")
|
|
|
|
def request_ph_value(self):
|
|
"""Request pH value from Arduino every 2 seconds"""
|
|
if self.serial_connection and self.serial_connection.is_open:
|
|
self.send_command("<14>") # Request current pH value
|
|
|
|
def request_tube_id(self):
|
|
"""Request tube ID from Arduino every 5 seconds"""
|
|
if self.serial_connection and self.serial_connection.is_open:
|
|
self.send_command("<13>") # Request current tube size
|
|
|
|
def request_flow_rate(self):
|
|
"""Request flow rate from Arduino when pump is running"""
|
|
if self.serial_connection and self.serial_connection.is_open and self.pump_is_running:
|
|
self.send_command("<18>") # Request current flow rate
|
|
|
|
def update_total_volume(self, flow_rate):
|
|
"""Update the total volume based on current flow rate"""
|
|
if self.pump_is_running:
|
|
# Add volume from the last second (flow_rate is ml/sec)
|
|
self.total_volume += flow_rate * 1.0 # 1 second interval
|
|
self.total_volume_label.setText(f"Gesamt: {self.total_volume:.2f} ml")
|
|
self.last_flow_rate = flow_rate
|
|
|
|
def send_pump_command(self, state):
|
|
if state == 1:
|
|
self.send_command("<11>") # Pump on
|
|
self.pump_status.setText("Pumpe: Ein")
|
|
self.pump_is_running = True
|
|
self.flow_rate_label.setVisible(True)
|
|
self.total_volume_label.setVisible(True)
|
|
|
|
# Check if volume should be reset or continue accumulating
|
|
if (self.reset_volume_checkbox.isChecked() or self.auto_dosing) and not self.auto_titration_active:
|
|
# Reset volume counter (always reset for auto dosing, but NOT for titration)
|
|
self.total_volume = 0.0
|
|
self.total_volume_label.setText("Gesamt: 0.0 ml")
|
|
else:
|
|
# Continue with existing volume - just update display
|
|
self.total_volume_label.setText(f"Gesamt: {self.total_volume:.2f} ml")
|
|
|
|
self.flow_rate_timer.start(1000) # Request flow rate every second
|
|
else:
|
|
self.send_command("<12>") # Pump off
|
|
self.pump_status.setText("Pumpe: Aus")
|
|
self.pump_is_running = False
|
|
self.flow_rate_label.setVisible(False)
|
|
# Keep total_volume_label visible to show final amount
|
|
self.flow_rate_timer.stop()
|
|
|
|
# Stop auto dosing if it was running
|
|
if self.auto_dosing:
|
|
self.auto_dosing = False
|
|
self.auto_dose_timer.stop()
|
|
self.auto_dose_btn.setText("Automatisch dosieren")
|
|
self.auto_dose_btn.setEnabled(True)
|
|
QMessageBox.information(self, "Automatische Dosierung",
|
|
f"Dosierung abgeschlossen!\nGepumpte Menge: {self.total_volume:.2f} ml")
|
|
|
|
def send_tube_command(self, index):
|
|
# Only send command if not updating from Arduino response
|
|
if not self.updating_tube_combo:
|
|
# Send command in format <17{tubid}> for tube selection
|
|
self.send_command(f"<17{index}>")
|
|
self.status_bar.showMessage(f"Schlauchgröße {self.tube_combo.itemText(index)} ausgewählt")
|
|
|
|
def send_calibration_command(self, ph):
|
|
if ph == 4:
|
|
self.send_command("<15>") # Start pH4 calibration
|
|
QMessageBox.information(self, "Kalibrierung",
|
|
"pH4 Kalibrierung gestartet. Sensor in pH4.01-Lösung tauchen und warten bis der Wert stabil ist.")
|
|
else:
|
|
self.send_command("<16>") # Start pH7 calibration
|
|
QMessageBox.information(self, "Kalibrierung",
|
|
"pH7 Kalibrierung gestartet. Sensor in pH6.86-Lösung tauchen und warten bis der Wert stabil ist.")
|
|
|
|
def send_debug_command(self):
|
|
"""Send debug command to get calibration values"""
|
|
self.send_command("<19>") # Request debug info
|
|
self.status_bar.showMessage("Debug-Informationen angefragt...")
|
|
|
|
def start_auto_dose(self):
|
|
if self.auto_dosing:
|
|
# Stop auto dosing
|
|
self.auto_dosing = False
|
|
self.auto_dose_timer.stop()
|
|
self.send_pump_command(0) # Turn off pump
|
|
self.auto_dose_btn.setText("Automatisch dosieren")
|
|
# Re-enable manual pump controls
|
|
self.pump_on_btn.setEnabled(True)
|
|
self.pump_off_btn.setEnabled(True)
|
|
return
|
|
|
|
# Don't start if auto titration is running
|
|
if self.auto_titration_active:
|
|
QMessageBox.warning(self, "Warnung", "Automatische Titration läuft bereits!")
|
|
return
|
|
|
|
volume = self.volume_spin.value()
|
|
self.target_volume = volume
|
|
self.auto_dosing = True
|
|
|
|
# Update button text and disable manual pump controls during auto dosing
|
|
self.auto_dose_btn.setText("Stop Dosierung")
|
|
self.pump_on_btn.setEnabled(False)
|
|
self.pump_off_btn.setEnabled(False)
|
|
|
|
# Reset volume counter for accurate measurement
|
|
self.total_volume = 0.0
|
|
|
|
# Start pumping
|
|
self.send_pump_command(1)
|
|
|
|
# Start monitoring timer (check every 0.5 seconds for better precision)
|
|
self.auto_dose_timer.start(500)
|
|
|
|
self.status_bar.showMessage(f"Automatische Dosierung läuft... Ziel: {volume} ml")
|
|
|
|
def read_serial_data(self):
|
|
if not self.serial_connection or not self.serial_connection.is_open:
|
|
return
|
|
|
|
try:
|
|
while self.serial_connection.in_waiting:
|
|
line = self.serial_connection.readline().decode('utf-8').strip()
|
|
print(line)
|
|
if line.startswith('<') and line.endswith('>'):
|
|
data = line[1:-1]
|
|
if data.startswith('#'): # pH value
|
|
try:
|
|
ph_value = float(data[1:])
|
|
self.ph_label.setText(f"pH: {ph_value:.2f}")
|
|
# Add to plot if recording is enabled
|
|
if self.plot_enabled_checkbox.isChecked():
|
|
self.ph_plot.add_ph_value(ph_value)
|
|
# Add to titration plot if titration is active
|
|
if self.titration_active or self.auto_titration_active:
|
|
titrated_volume = self.total_volume - self.titration_start_volume
|
|
self.titration_plot.add_titration_point(titrated_volume, ph_value)
|
|
except ValueError:
|
|
pass
|
|
elif data.startswith('P'): # Pump status
|
|
pump_state = data[1:]
|
|
self.pump_status.setText(f"Pumpe: {'Ein' if pump_state == '1' else 'Aus'}")
|
|
elif data.startswith('T'): # Tube index
|
|
try:
|
|
tube_index = int(data[1:])
|
|
# Ensure tube index is within valid range (0-25)
|
|
if 0 <= tube_index <= 25:
|
|
self.updating_tube_combo = True # Prevent recursive commands
|
|
self.tube_combo.setCurrentIndex(tube_index)
|
|
self.updating_tube_combo = False
|
|
self.status_bar.showMessage(f"Schlauchgröße {self.tube_combo.currentText()} geladen")
|
|
except ValueError:
|
|
pass
|
|
elif data.startswith('F'): # Flow rate (ml/sec)
|
|
try:
|
|
flow_rate = float(data[1:])
|
|
self.flow_rate_label.setText(f"ml/sec: {flow_rate:.4f}")
|
|
# Update total volume
|
|
self.update_total_volume(flow_rate)
|
|
except ValueError:
|
|
pass
|
|
elif data.startswith('DEBUG'): # Debug information
|
|
# Show debug info in a message box
|
|
QMessageBox.information(self, "Debug Information",
|
|
f"Kalibrierungsdaten:\n{data}")
|
|
elif data.startswith('CAL'): # Calibration confirmation with details
|
|
# Show calibration info in status bar and message box
|
|
self.status_bar.showMessage(f"Kalibrierung abgeschlossen: {data}")
|
|
QMessageBox.information(self, "Kalibrierung",
|
|
f"Kalibrierung erfolgreich:\n{data}")
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Lesefehler: {str(e)}")
|
|
|
|
def closeEvent(self, event):
|
|
# Safety: Turn off pump before closing
|
|
if self.serial_connection and self.serial_connection.is_open:
|
|
try:
|
|
self.send_command("<12>") # Ensure pump is off
|
|
# Give Arduino time to process the command
|
|
import time
|
|
time.sleep(0.1)
|
|
except:
|
|
pass # Ignore errors during shutdown
|
|
|
|
# Stop all timers
|
|
self.read_timer.stop()
|
|
self.ph_request_timer.stop()
|
|
self.tube_request_timer.stop()
|
|
self.flow_rate_timer.stop()
|
|
self.auto_dose_timer.stop()
|
|
self.titration_timer.stop()
|
|
self.close_connection()
|
|
event.accept()
|
|
|
|
def clear_ph_plot(self):
|
|
"""Clear the pH plot data"""
|
|
self.ph_plot.clear_data()
|
|
self.status_bar.showMessage("pH-Diagramm gelöscht")
|
|
|
|
def toggle_titration(self):
|
|
"""Start or stop titration recording"""
|
|
if self.titration_active:
|
|
# Stop titration
|
|
self.titration_active = False
|
|
self.start_titration_btn.setText("Titration starten")
|
|
self.status_bar.showMessage("Titration gestoppt")
|
|
else:
|
|
# Start titration
|
|
self.titration_active = True
|
|
self.titration_start_volume = self.total_volume
|
|
self.start_titration_btn.setText("Titration stoppen")
|
|
self.status_bar.showMessage("Titration gestartet")
|
|
|
|
def toggle_auto_titration(self):
|
|
"""Start or stop automatic titration"""
|
|
if self.auto_titration_active:
|
|
# Stop auto titration
|
|
self.auto_titration_active = False
|
|
self.titration_timer.stop()
|
|
self.send_pump_command(0) # Turn off pump
|
|
self.start_titration_btn.setText("Automatische Titration starten")
|
|
# Re-enable controls
|
|
self.pump_on_btn.setEnabled(True)
|
|
self.pump_off_btn.setEnabled(True)
|
|
self.auto_dose_btn.setEnabled(True)
|
|
self.status_bar.showMessage("Automatische Titration gestoppt")
|
|
return
|
|
|
|
# Don't start if auto dosing is running
|
|
if self.auto_dosing:
|
|
QMessageBox.warning(self, "Warnung", "Automatische Dosierung läuft bereits!")
|
|
return
|
|
|
|
# Get parameters
|
|
self.titration_step_size = self.titration_step_spin.value()
|
|
self.titration_wait_time = self.titration_wait_spin.value()
|
|
|
|
# Start auto titration
|
|
self.auto_titration_active = True
|
|
self.titration_start_volume = self.total_volume # Remember starting volume
|
|
self.titration_current_step = 0
|
|
|
|
# Update button and disable controls
|
|
self.start_titration_btn.setText("Automatische Titration stoppen")
|
|
self.pump_on_btn.setEnabled(False)
|
|
self.pump_off_btn.setEnabled(False)
|
|
self.auto_dose_btn.setEnabled(False)
|
|
|
|
# Start first titration step
|
|
self.titration_step()
|
|
|
|
self.status_bar.showMessage(f"Automatische Titration gestartet - Schritt: {self.titration_step_size} ml, Warten: {self.titration_wait_time} s")
|
|
|
|
def titration_step(self):
|
|
"""Perform one titration step"""
|
|
if not self.auto_titration_active:
|
|
return
|
|
|
|
self.titration_current_step += 1
|
|
|
|
# Calculate step target volume (only the additional volume for this step)
|
|
step_target_volume = self.titration_step_size
|
|
|
|
# Calculate absolute target volume (total from start of titration)
|
|
absolute_target_volume = self.titration_start_volume + (self.titration_current_step * self.titration_step_size)
|
|
|
|
# Start pumping for this step
|
|
self.auto_dosing = True # Use auto dosing mechanism
|
|
self.target_volume = absolute_target_volume # Set absolute target
|
|
self.send_pump_command(1)
|
|
self.auto_dose_timer.start(500)
|
|
|
|
current_titrated_volume = self.total_volume - self.titration_start_volume
|
|
total_target_titrated = self.titration_current_step * self.titration_step_size
|
|
|
|
self.status_bar.showMessage(f"Titration Schritt {self.titration_current_step}: Dosiere {step_target_volume} ml (Titration gesamt: {total_target_titrated} ml)")
|
|
|
|
def check_auto_dose_progress(self):
|
|
"""Check if target volume has been reached during auto dosing"""
|
|
if not self.auto_dosing:
|
|
return
|
|
|
|
# Check if we've reached or exceeded the target volume
|
|
if self.total_volume >= self.target_volume:
|
|
# Stop dosing
|
|
self.auto_dosing = False
|
|
self.auto_dose_timer.stop()
|
|
self.send_pump_command(0) # Turn off pump
|
|
|
|
# If this was part of auto titration, schedule next step
|
|
if self.auto_titration_active:
|
|
# Wait for pH to stabilize, then continue with next step
|
|
wait_time_ms = int(self.titration_wait_time * 1000)
|
|
QTimer.singleShot(wait_time_ms, self.titration_step)
|
|
self.status_bar.showMessage(f"Warte {self.titration_wait_time} s auf pH-Stabilisierung...")
|
|
else:
|
|
# Re-enable manual controls for regular auto dosing
|
|
self.pump_on_btn.setEnabled(True)
|
|
self.pump_off_btn.setEnabled(True)
|
|
self.auto_dose_btn.setText("Automatisch dosieren")
|
|
|
|
# Show completion message
|
|
actual_volume = self.total_volume
|
|
difference = actual_volume - self.target_volume
|
|
|
|
message = f"Dosierung abgeschlossen!\n"
|
|
message += f"Zielvolumen: {self.target_volume:.2f} ml\n"
|
|
message += f"Tatsächliches Volumen: {actual_volume:.2f} ml\n"
|
|
message += f"Abweichung: {difference:+.2f} ml"
|
|
|
|
QMessageBox.information(self, "Automatische Dosierung", message)
|
|
self.status_bar.showMessage(f"Dosierung abgeschlossen: {actual_volume:.2f} ml von {self.target_volume:.2f} ml")
|
|
else:
|
|
# Update progress in status bar
|
|
progress = (self.total_volume / self.target_volume) * 100
|
|
if self.auto_titration_active:
|
|
current_titrated_volume = self.total_volume - self.titration_start_volume
|
|
target_titrated_volume = self.titration_current_step * self.titration_step_size
|
|
self.status_bar.showMessage(f"Titration Schritt {self.titration_current_step}: Gesamt {current_titrated_volume:.2f}/{target_titrated_volume:.2f} ml titriert ({progress:.1f}%)")
|
|
else:
|
|
self.status_bar.showMessage(f"Dosierung: {self.total_volume:.2f}/{self.target_volume:.2f} ml ({progress:.1f}%)")
|
|
|
|
def clear_titration_plot(self):
|
|
"""Clear the titration plot data"""
|
|
try:
|
|
# Clear the plot data
|
|
self.titration_plot.clear_data()
|
|
|
|
# Reset titration state variables
|
|
self.titration_active = False
|
|
self.titration_start_volume = 0.0
|
|
|
|
# Update button text if not in auto titration mode
|
|
if not self.auto_titration_active:
|
|
self.start_titration_btn.setText("Automatische Titration starten")
|
|
|
|
self.status_bar.showMessage("Titrationsdaten gelöscht")
|
|
except Exception as e:
|
|
self.status_bar.showMessage(f"Fehler beim Löschen der Titrationsdaten: {str(e)}")
|
|
QMessageBox.critical(self, "Fehler", f"Fehler beim Löschen der Titrationsdaten:\n{str(e)}")
|
|
|
|
class PHPlotWidget(QWidget):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.figure = Figure(figsize=(8, 4))
|
|
self.canvas = FigureCanvas(self.figure)
|
|
self.ax = self.figure.add_subplot(111)
|
|
|
|
# Data storage (keep last 300 points = 10 minutes at 2-second intervals)
|
|
self.times = deque(maxlen=300)
|
|
self.ph_values = deque(maxlen=300)
|
|
self.start_time = None # Track when recording started
|
|
|
|
# Setup plot
|
|
self.ax.set_xlabel('Zeit')
|
|
self.ax.set_ylabel('pH-Wert')
|
|
self.ax.set_title('pH-Verlauf')
|
|
self.ax.grid(True, alpha=0.3)
|
|
self.ax.set_ylim(0, 14) # pH range 0-14
|
|
|
|
# Layout
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(self.canvas)
|
|
self.setLayout(layout)
|
|
|
|
# Plot line
|
|
self.line, = self.ax.plot([], [], 'b-', linewidth=2, label='pH-Wert')
|
|
self.ax.legend()
|
|
|
|
# Initial draw
|
|
self.canvas.draw()
|
|
|
|
def add_ph_value(self, ph_value):
|
|
"""Add new pH value with current timestamp"""
|
|
current_time = datetime.datetime.now()
|
|
|
|
# Set start time on first value
|
|
if self.start_time is None:
|
|
self.start_time = current_time
|
|
|
|
# Calculate relative time in seconds
|
|
relative_time = (current_time - self.start_time).total_seconds()
|
|
|
|
self.times.append(relative_time)
|
|
self.ph_values.append(ph_value)
|
|
self.update_plot()
|
|
|
|
def update_plot(self):
|
|
"""Update the plot with current data"""
|
|
if len(self.times) > 0 and len(self.ph_values) > 0:
|
|
# Update line data
|
|
self.line.set_data(self.times, self.ph_values)
|
|
|
|
# Auto-scale x-axis to show recent data
|
|
if len(self.times) > 1:
|
|
self.ax.set_xlim(min(self.times), max(self.times))
|
|
|
|
# Auto-scale y-axis around data with some margin
|
|
if len(self.ph_values) > 0:
|
|
min_ph = min(self.ph_values)
|
|
max_ph = max(self.ph_values)
|
|
margin = 0.5
|
|
self.ax.set_ylim(max(0, min_ph - margin), min(14, max_ph + margin))
|
|
|
|
# Format x-axis for time display (seconds/minutes/hours)
|
|
self.format_time_axis()
|
|
|
|
# Redraw
|
|
self.canvas.draw()
|
|
|
|
def format_time_axis(self):
|
|
"""Format the x-axis to show time in appropriate units"""
|
|
if len(self.times) == 0:
|
|
return
|
|
|
|
max_time = max(self.times)
|
|
|
|
if max_time < 120: # Less than 2 minutes - show seconds
|
|
self.ax.set_xlabel('Zeit (Sekunden)')
|
|
# No need to change tick formatting for seconds
|
|
elif max_time < 7200: # Less than 2 hours - show minutes
|
|
self.ax.set_xlabel('Zeit (Minuten)')
|
|
# Convert tick labels to minutes
|
|
import matplotlib.ticker as ticker
|
|
def format_minutes(x, pos):
|
|
return f'{x/60:.1f}'
|
|
self.ax.xaxis.set_major_formatter(ticker.FuncFormatter(format_minutes))
|
|
else: # Show hours
|
|
self.ax.set_xlabel('Zeit (Stunden)')
|
|
# Convert tick labels to hours
|
|
import matplotlib.ticker as ticker
|
|
def format_hours(x, pos):
|
|
return f'{x/3600:.1f}'
|
|
self.ax.xaxis.set_major_formatter(ticker.FuncFormatter(format_hours))
|
|
|
|
def clear_data(self):
|
|
"""Clear all data points"""
|
|
self.times.clear()
|
|
self.ph_values.clear()
|
|
self.start_time = None # Reset start time
|
|
self.line.set_data([], [])
|
|
self.ax.set_xlim(0, 1)
|
|
self.ax.set_ylim(0, 14)
|
|
self.ax.set_xlabel('Zeit') # Reset to default label
|
|
# Reset tick formatter to default
|
|
import matplotlib.ticker as ticker
|
|
self.ax.xaxis.set_major_formatter(ticker.ScalarFormatter())
|
|
self.canvas.draw()
|
|
|
|
class TitrationPlotWidget(QWidget):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.figure = Figure(figsize=(8, 4))
|
|
self.canvas = FigureCanvas(self.figure)
|
|
self.ax = self.figure.add_subplot(111)
|
|
|
|
# Data storage
|
|
self.volumes = []
|
|
self.ph_values = []
|
|
|
|
# Setup plot
|
|
self.ax.set_xlabel('Dosierte Menge (ml)')
|
|
self.ax.set_ylabel('pH-Wert')
|
|
self.ax.set_title('Titrationskurve')
|
|
self.ax.grid(True, alpha=0.3)
|
|
self.ax.set_ylim(0, 14) # pH range 0-14
|
|
self.ax.set_xlim(0, 1) # Initial volume range
|
|
|
|
# Layout
|
|
layout = QVBoxLayout()
|
|
layout.addWidget(self.canvas)
|
|
self.setLayout(layout)
|
|
|
|
# Plot line and points
|
|
self.line, = self.ax.plot([], [], 'r-', linewidth=2, label='pH-Verlauf')
|
|
self.points = self.ax.scatter([], [], c='red', s=30, zorder=5, label='Messpunkte')
|
|
self.ax.legend()
|
|
|
|
# Initial draw
|
|
self.canvas.draw()
|
|
|
|
def add_titration_point(self, volume, ph_value):
|
|
"""Add new titration point (volume vs pH)"""
|
|
self.volumes.append(volume)
|
|
self.ph_values.append(ph_value)
|
|
self.update_plot()
|
|
|
|
def update_plot(self):
|
|
"""Update the plot with current data"""
|
|
if len(self.volumes) > 0 and len(self.ph_values) > 0:
|
|
# Update line data
|
|
self.line.set_data(self.volumes, self.ph_values)
|
|
|
|
# Update scatter points
|
|
self.points.set_offsets(list(zip(self.volumes, self.ph_values)))
|
|
|
|
# Auto-scale x-axis
|
|
if len(self.volumes) > 1:
|
|
max_vol = max(self.volumes)
|
|
self.ax.set_xlim(0, max_vol * 1.1) # Add 10% margin
|
|
|
|
# Auto-scale y-axis around data with some margin
|
|
if len(self.ph_values) > 0:
|
|
min_ph = min(self.ph_values)
|
|
max_ph = max(self.ph_values)
|
|
margin = 0.5
|
|
self.ax.set_ylim(max(0, min_ph - margin), min(14, max_ph + margin))
|
|
|
|
# Redraw
|
|
self.canvas.draw()
|
|
|
|
def clear_data(self):
|
|
"""Clear all titration data points"""
|
|
try:
|
|
# Clear data lists
|
|
self.volumes.clear()
|
|
self.ph_values.clear()
|
|
|
|
# Reset plot elements safely
|
|
self.line.set_data([], [])
|
|
|
|
# Clear scatter points properly - use empty 2D array
|
|
self.points.set_offsets(np.empty((0, 2)))
|
|
|
|
# Reset axis limits
|
|
self.ax.set_xlim(0, 1)
|
|
self.ax.set_ylim(0, 14)
|
|
|
|
# Redraw canvas
|
|
self.canvas.draw()
|
|
except Exception as e:
|
|
print(f"Fehler beim Löschen der Titrationsdaten: {str(e)}")
|
|
|
|
def main():
|
|
app = QApplication(sys.argv)
|
|
window = PHControllerGUI()
|
|
window.show()
|
|
sys.exit(app.exec())
|
|
|
|
if __name__ == "__main__":
|
|
main() |