import sys import os import serial import struct import serial.tools.list_ports import pyqtgraph as pg import pandas as pd import time import json from PyQt6.QtWidgets import QApplication, QVBoxLayout, QWidget, QLineEdit, QPushButton, QLabel, QHBoxLayout from PyQt6.QtCore import QTimer from PyQt6.QtGui import QIcon from PyQt6.QtCore import QSettings # -------------------------- Arduino detection -------------------------- def find_arduino(): ports = list(serial.tools.list_ports.comports()) print(f"Found ports: {[p.description for p in ports]}") # DEBUG LOG for port in ports: print(f"Checking port: {port.device} - {port.description}") # DEBUG LOG if any(kw in port.description for kw in ["Arduino", "CH340", "USB Serial", "FTDI"]): print(f"Arduino found at: {port.device}") return port.device print("No Arduino found") return None arduino_port = find_arduino() ser = serial.Serial(arduino_port, 9600, timeout=10) if arduino_port else None # --------------------------- Config JSON helpers --------------------------- CONFIG_JSON_FILE = "presets.json" DEFAULT_PRESETS = { "Anode": {"setpoint_start": 160, "setpoint_end": 150, "setwindow": 60, "marker_interval": 10}, "Cathode": {"setpoint_start": 160, "setpoint_end": 150, "setwindow": 60, "marker_interval": 10}, "Coolant": {"setpoint_start": 160, "setpoint_end": 150, "setwindow": 60, "marker_interval": 10}, } def load_presets(): if os.path.exists(CONFIG_JSON_FILE): try: with open(CONFIG_JSON_FILE, "r") as f: data = json.load(f) return data except Exception as e: print("Failed to load preset JSON, using defaults:", e) return DEFAULT_PRESETS.copy() def save_presets(presets: dict): try: with open(CONFIG_JSON_FILE, "w") as f: json.dump(presets, f, indent=4) print("Presets saved to JSON.") except Exception as e: print("Failed to save presets:", e) # -------------------------- HMI Class -------------------------- class HMI(QWidget): def __init__(self): super().__init__() # -------------------------- Data folder -------------------------- self.data_dir = os.path.join(os.getcwd(), "Data_log") os.makedirs(self.data_dir, exist_ok=True) # -------------------------- Window Setup -------------------------- self.setWindowTitle("Leak Test") self.setWindowIcon(QIcon("ehg_icon.png")) self.setGeometry(100, 100, 900, 550) self.settings = QSettings("EHG", "LeakTestHMI") self.current_preset = None # Track the currently selected preset # -------------------------- Data Storage -------------------------- self.start_time = time.time() self.time_data = [] self.pressure_data = [] self.marker_items = [] # -------------------------- Default / Saved Configurations -------------------------- self.setpoint_start = float(self.settings.value("setpoint_start", 25)) self.setpoint_end = float(self.settings.value("setpoint_end", 12)) self.setwindow = float(self.settings.value("setwindow", 900)) self.marker_interval = float(self.settings.value("marker_interval", 20)) # Load presets from JSON file self.presets = load_presets() # -------------------------- Layouts -------------------------- main_layout = QVBoxLayout() self.setLayout(main_layout) # Date display self.date_label = QLabel() self.date_label.setStyleSheet("font-size: 12px; color: gray;") self.update_date_label() date_layout = QHBoxLayout() date_layout.addStretch() date_layout.addWidget(self.date_label) main_layout.addLayout(date_layout) self.datetime_timer = QTimer() self.datetime_timer.timeout.connect(self.update_date_label) self.datetime_timer.start(1000) # Plot Widget self.plot_widget = pg.PlotWidget() main_layout.addWidget(self.plot_widget) self.plot_widget.setTitle("Pressure") self.plot_widget.setLabel("left", "Pressure [kPa]") self.plot_widget.setLabel("bottom", "Time [s]") self.plot_widget.setBackground("w") self.plot = self.plot_widget.plot([], [], pen=pg.mkPen(color="k", width=2)) # Setpoint Lines self.setpoint_start_line = pg.InfiniteLine(pos=self.setpoint_start, angle=0, pen=pg.mkPen(color="r", width=2, style=pg.QtCore.Qt.PenStyle.DashLine)) self.setpoint_end_line = pg.InfiniteLine(pos=self.setpoint_end, angle=0, pen=pg.mkPen(color="b", width=2, style=pg.QtCore.Qt.PenStyle.DashLine)) self.plot_widget.addItem(self.setpoint_start_line) self.plot_widget.addItem(self.setpoint_end_line) # Cursors self.cursor_start = pg.InfiniteLine(pos=10, angle=90, pen=pg.mkPen(color="g", width=2, style=pg.QtCore.Qt.PenStyle.SolidLine), movable=True) self.cursor_end = pg.InfiniteLine(pos=910, angle=90, pen=pg.mkPen(color="g", width=2, style=pg.QtCore.Qt.PenStyle.SolidLine), movable=True) self.plot_widget.addItem(self.cursor_start) self.plot_widget.addItem(self.cursor_end) self.cursor_start.sigPositionChanged.connect(lambda: self.sync_cursors(self.cursor_start, self.cursor_end)) self.cursor_end.sigPositionChanged.connect(lambda: self.sync_cursors(self.cursor_end, self.cursor_start)) # -------------------------- Controls -------------------------- control_layout = QHBoxLayout() # Setpoints and dT self.setpoint_start_input = QLineEdit(str(self.setpoint_start)) self.setpoint_start_input.setFixedWidth(50) self.setpoint_start_input.editingFinished.connect(self.update_setpoint_start) self.setpoint_end_input = QLineEdit(str(self.setpoint_end)) self.setpoint_end_input.setFixedWidth(50) self.setpoint_end_input.editingFinished.connect(self.update_setpoint_end) self.setwindow_input = QLineEdit(str(self.setwindow)) self.setwindow_input.setFixedWidth(50) self.setwindow_input.editingFinished.connect(self.update_setwindow) control_layout.addWidget(QLabel("Setpoint_start [kPa]:")) control_layout.addWidget(self.setpoint_start_input) control_layout.addWidget(QLabel("Setpoint_end [kPa]:")) control_layout.addWidget(self.setpoint_end_input) control_layout.addWidget(QLabel("dT [sec]:")) control_layout.addWidget(self.setwindow_input) # Marker Interval self.marker_input = QLineEdit(str(self.marker_interval)) self.marker_input.setFixedWidth(50) self.marker_input.setPlaceholderText("Sample Time [sec]") self.marker_input.editingFinished.connect(self.update_marker_interval) control_layout.addWidget(QLabel("Sample Time [sec]:")) control_layout.addWidget(self.marker_input) # Save Configuration Button self.save_config_button = QPushButton("Save Configuration") self.save_config_button.clicked.connect(self.save_configuration) control_layout.addWidget(self.save_config_button) # Analyze Button self.save_button = QPushButton("Analyze") self.save_button.clicked.connect(self.analyze_and_save) control_layout.addWidget(self.save_button) main_layout.addLayout(control_layout) # Stack, Cell & Comments text fields stack_layout = QHBoxLayout() stack_layout.addWidget(QLabel("Stack No [ST#]:")) self.stack_input = QLineEdit("0") self.stack_input.setFixedWidth(80) stack_layout.addWidget(self.stack_input) stack_layout.addWidget(QLabel("Cell No [C#]:")) self.cell_input = QLineEdit("0") self.cell_input.setFixedWidth(80) stack_layout.addWidget(self.cell_input) stack_layout.addWidget(QLabel("Config:")) self.config_input = QLineEdit("") self.config_input.setFixedWidth(100) #self.config_input.setReadOnly(True) # auto-filled from preset stack_layout.addWidget(self.config_input) stack_layout.addWidget(QLabel("Comments:")) self.comments_input = QLineEdit("") self.comments_input.setFixedWidth(260) stack_layout.addWidget(self.comments_input) stack_layout.addStretch() main_layout.addLayout(stack_layout) # Preset Buttons preset_layout = QHBoxLayout() for name in self.presets: btn = QPushButton(f"Config {name}") btn.clicked.connect(lambda checked=False, n=name: self.reset_with_preset(n)) preset_layout.addWidget(btn) preset_layout.addStretch() main_layout.addLayout(preset_layout) # Current Configuration Label self.current_config_label = QLabel("Current Config: None") self.current_config_label.setStyleSheet("font-size: 14px; color: blue;") main_layout.addWidget(self.current_config_label) # Last Value Display self.actual_value_label = pg.TextItem("", anchor=(0, 1), color="black", fill=pg.mkBrush(255, 255, 255, 150)) self.plot_widget.addItem(self.actual_value_label) # Test Result Label self.result_label = QLabel("Test Result: N/A") self.result_label.setStyleSheet("font-size: 16px; font-weight: bold; color: black;") main_layout.addWidget(self.result_label) # -------------------------- Timers -------------------------- self.marker_timer = QTimer() self.marker_timer.timeout.connect(self.add_marker) self.marker_timer.start(int(self.marker_interval * 1000)) self.timer = QTimer() self.timer.timeout.connect(self.update_plot) self.timer.start(500) # -------------------------- Update Methods -------------------------- def update_date_label(self): self.date_label.setText(time.strftime("%Y-%m-%d %H:%M:%S")) def moving_average(self, data, window_size=5): if len(data) < window_size: return sum(data) / len(data) return sum(data[-window_size:]) / window_size def update_setpoint_start(self): try: self.setpoint_start = float(self.setpoint_start_input.text()) self.setpoint_start_line.setValue(self.setpoint_start) except ValueError: self.setpoint_start_input.setText("") def update_setpoint_end(self): try: self.setpoint_end = float(self.setpoint_end_input.text()) self.setpoint_end_line.setValue(self.setpoint_end) except ValueError: self.setpoint_end_input.setText("") def update_setwindow(self): try: self.setwindow = float(self.setwindow_input.text()) self.sync_cursors(self.cursor_start, self.cursor_end) except ValueError: self.setwindow_input.setText("") def update_marker_interval(self): try: self.marker_interval = float(self.marker_input.text()) self.marker_timer.setInterval(int(self.marker_interval * 1000)) except ValueError: self.marker_input.setText("") # -------------------------- Plot & Cursor Methods -------------------------- def update_plot(self): if ser and ser.in_waiting > 0: data = ser.read(2) if len(data) < 2: return try: raw_pressure = struct.unpack(' 100000: self.time_data.pop(0) self.pressure_data.pop(0) self.plot.setData([t for t in self.time_data], [self.moving_average(self.pressure_data[:i + 1]) for i in range(len(self.pressure_data))]) if self.time_data and self.pressure_data: self.actual_value_label.setText(f"{eng_pressure:.2f} kPa") self.actual_value_label.setPos(self.time_data[-1], self.pressure_data[-1]) except ValueError: pass def sync_cursors(self, moved_cursor, other_cursor): new_pos = moved_cursor.value() + self.setwindow if moved_cursor is self.cursor_start else moved_cursor.value() - self.setwindow other_cursor.blockSignals(True) other_cursor.setValue(new_pos) other_cursor.blockSignals(False) def add_marker(self): t = time.time() - self.start_time marker = pg.InfiniteLine( pos=t, angle=90, pen=pg.mkPen("m", width=1, style=pg.QtCore.Qt.PenStyle.DashLine) ) self.plot_widget.addItem(marker) self.marker_items.append(marker) def clear_markers(self): for marker in self.marker_items: try: self.plot_widget.removeItem(marker) except Exception: pass self.marker_items.clear() def autorange_plot(self): vb = self.plot_widget.getViewBox() vb.enableAutoRange(axis=pg.ViewBox.XYAxes, enable=True) # -------------------------- Preset & Configuration -------------------------- def reset_with_preset(self, preset_name): self.current_preset = preset_name self.current_config_label.setText(f"Current Config: {preset_name}") self.config_input.setText(preset_name) preset = self.presets[preset_name] self.setpoint_start_input.setText(str(preset["setpoint_start"])) self.setpoint_end_input.setText(str(preset["setpoint_end"])) self.setwindow_input.setText(str(preset["setwindow"])) self.marker_input.setText(str(preset["marker_interval"])) self.update_setpoint_start() self.update_setpoint_end() self.update_setwindow() self.update_marker_interval() self.reset_hmi() def save_configuration(self): try: if self.current_preset is None: print("No preset selected, nothing saved.") return # update preset values self.presets[self.current_preset]["setpoint_start"] = float(self.setpoint_start_input.text()) self.presets[self.current_preset]["setpoint_end"] = float(self.setpoint_end_input.text()) self.presets[self.current_preset]["setwindow"] = float(self.setwindow_input.text()) self.presets[self.current_preset]["marker_interval"] = float(self.marker_input.text()) # save to JSON file save_presets(self.presets) self.current_config_label.setText(f"Current Config: {self.current_preset} (saved)") print(f"Preset '{self.current_preset}' saved.") except ValueError: print("Invalid configuration values (not saved).") # -------------------------- Reset HMI -------------------------- def reset_hmi(self): self.start_time = time.time() self.time_data.clear() self.pressure_data.clear() self.plot.clear() self.cursor_start.setValue(0) self.cursor_end.setValue(self.setwindow) self.result_label.setText("Test Result: N/A") self.result_label.setStyleSheet("color: black;") self.autorange_plot() self.clear_markers() # -------------------------- Analyze & Save -------------------------- def analyze_and_save(self): try: start_time = self.cursor_start.value() end_time = self.cursor_end.value() if start_time >= end_time: self.result_label.setText("Invalid dT") return # Sample marker data marker_times = [] t = start_time while t <= end_time: marker_times.append(t) t += self.marker_interval sampled_times, sampled_pressures = [], [] for mt in marker_times: if not self.time_data: continue idx = min(range(len(self.time_data)), key=lambda i: abs(self.time_data[i] - mt)) sampled_times.append(self.time_data[idx]) sampled_pressures.append(self.pressure_data[idx]) if not sampled_pressures: self.result_label.setText("No data at marker points") return start_pressure = sampled_pressures[0] end_pressure = sampled_pressures[-1] pressure_drop = start_pressure - end_pressure time_interval = end_time - start_time drop_rate = pressure_drop / time_interval threshold = (self.setpoint_start - self.setpoint_end) / time_interval test_result = "PASS" if drop_rate <= threshold else "FAIL" self.result_label.setText(f"Test Result: {test_result} (Drop Rate: {drop_rate:.2f} kPa/s)") self.result_label.setStyleSheet("color: green;" if test_result == "PASS" else "color: red;") stack = self.stack_input.text().strip() cell = self.cell_input.text().strip() config = self.config_input.text().strip() or "NA" timestamp = time.strftime("%Y-%m-%d %H:%M:%S") summary_df = pd.DataFrame([[ timestamp, round(start_pressure, 1), round(end_pressure, 1), round(drop_rate, 3), test_result, stack, cell, config, self.comments_input.text() ]], columns=[ "Time", "Start Pressure", "End Pressure", "Drop Rate (kPa/s)", "Result", "Stack", "Cell", "Config", "Comments" ]) summary_path = os.path.join(self.data_dir, "leak_test_summary.csv") summary_df.to_csv(summary_path, mode="a", header=not os.path.exists(summary_path), index=False) detailed_df = pd.DataFrame({"Time (s)": [round(t, 1) for t in sampled_times], "Pressure (kPa)": [round(p, 1) for p in sampled_pressures]}) detailed_filename = ( f"leak_test_data_" f"ST{self.stack_input.text()}_" f"CELL{self.cell_input.text()}_" f"CFG{self.current_preset}_" f"{timestamp.replace(':', '-').replace(' ', '_')}.csv" ) detailed_path = os.path.join(self.data_dir, detailed_filename) detailed_df.to_csv(detailed_path, index=False) screenshot = self.grab() sc_filename = f"screenshot_{os.path.splitext(detailed_filename)[0]}.png" sc_path = os.path.join(self.data_dir, sc_filename) screenshot.save(sc_path) print(f"Screenshot saved to {sc_filename}") print(f"Saved test summary and sampled data.") except ValueError: self.result_label.setText("Invalid Criteria Input") # -------------------------- Close Event -------------------------- def closeEvent(self, event): if ser and ser.is_open: ser.close() print("Serial connection closed.") self.datetime_timer.stop() self.timer.stop() self.marker_timer.stop() print("Application closed.") event.accept() # -------------------------- Run Application -------------------------- if __name__ == "__main__": app = QApplication(sys.argv) window = HMI() window.show() sys.exit(app.exec())