493 lines
20 KiB
Python
493 lines
20 KiB
Python
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('<H', data)[0]
|
|
eng_pressure = raw_pressure * 1.219 - 246.676
|
|
elapsed_time = time.time() - self.start_time
|
|
|
|
self.time_data.append(elapsed_time)
|
|
self.pressure_data.append(eng_pressure)
|
|
|
|
if len(self.time_data) > 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())
|