arduino_ph_controller/gui/main.py
2025-07-13 18:17:17 +02:00

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