# gui.py import base64 import matplotlib # from prompt_toolkit.key_binding.bindings.named_commands import self_insert import requests matplotlib.use('TkAgg') import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk import warnings import tkinter as tk from tkinter import ttk # from tkinter import scrolledtext import threading import time import os import pickle import datetime import json import threading import sys import numpy as np # import pandas as pd import serial.tools.list_ports from stable_baselines3 import SAC import torch # from PressureEnv import CustomPressureEnv from PcControl import Easy521ModbusClient, MotorModbusRTUClient # from zzp import SECRET_KEY # from PcControl import PressureModbusRTUClient, MotorModbusRTUClient from controllers import IncrementalPID from styles import apply_app_style from get_V import measure_volume from ind_collector import collect_data_with_prbs import logging sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.simulated_device import SimulatedDevice from api import base_url, data_record_url, the_folder warnings.filterwarnings('ignore') logging.getLogger("pymodbus").setLevel(logging.ERROR) # 优化matplotlib设置 matplotlib.rcParams['figure.max_open_warning'] = 20 matplotlib.rcParams['axes.linewidth'] = 0.5 matplotlib.rcParams['lines.linewidth'] = 1.0 plt.rcParams['font.sans-serif'] = [ 'Microsoft YaHei', # Windows 优先 (微软雅黑) 'SimHei', # Windows 备选 (黑体) 'PingFang SC', # macOS 优先 (苹方) 'Heiti TC', # macOS 备选 (黑体) 'sans-serif' # 最终兜底 ] plt.rcParams['axes.unicode_minus'] = False # 解决负号显示问题 def get_base_path(): """获取程序运行时的当前真实根目录""" if getattr(sys, 'frozen', False): return os.path.dirname(sys.executable) # exe所在目录 else: return os.path.dirname(os.path.abspath(__file__)) # py脚本所在目录 class ControlGUI: def __init__(self, root): self.root = root self.root.title("ReinLoop-V1.0 - 收敛有界") self.root.geometry("900x700") # 初始化Modbus客户端和NMPC控制器 self.modbus_client = None self.IncrementalPID = IncrementalPID(kp=1.0, ki=0.4, kd=0, dt=0.1, out_min=0, out_max=100) # 定义 (容积, 流量) 组合与模型的映射关系 # self.update_selectors_from_config() # 更新下拉菜单 self.condition_to_model_map = {} # 先初始化为空 # self.load_config_from_json() # 调用外部读取方法 # 控制标志 self.running = False self.control_thread = None # 数据记录 self.pressure_data = [] self.target_data = [] self.valve_data = [] self.time_data = [] self.cycle_count = 0 self.start_time = None # --- 数据记录与状态显示 --- # --- 数据收集与系统辨识相关 --- self.collect_data_var = tk.BooleanVar(value=False) # 数据收集开关 self.episode_data_raw = [] # 存放所有 Episode 的列表 self.current_episode = None # 当前正在记录的 Episode self.steady_count = 0 # 稳态计数器 self.last_target_rl = None # 记录上一个目标值,用于RL模型 self.last_target_record = None # 记录上一个目标值,用于切分 Episode # 绘图相关 self.current_fig = None self.current_ax1 = None self.current_ax2 = None self.current_canvas = None self.x_min_var = None self.x_max_var = None self.is_plotting = False self.setup_gui() self.scan_models_folder() self.confirmed_target_pressure = float(self.target_entry.get()) def safe_log(self, message): if hasattr(self, "log_text"): self.log_message(message) else: print(message) def update_selectors_from_config(self): """根据加载到的配置更新界面下拉框内容""" if not self.condition_to_model_map: return vols = sorted(list(set([k[0] for k in self.condition_to_model_map.keys()])), key=float) flows = sorted(list(set([k[1] for k in self.condition_to_model_map.keys()])), key=float) if hasattr(self, 'volume_selector'): self.volume_selector['values'] = vols self.flow_selector['values'] = flows def _update_pid_ui(self, kp, ki, kd=None): """实时更新界面上的 PID 参数显示""" # 必须先解除禁用状态才能修改文字 current_state = self.Kp_entry['state'] self.Kp_entry.config(state=tk.NORMAL) self.Ki_entry.config(state=tk.NORMAL) self.Kd_entry.config(state=tk.NORMAL) self.Kp_entry.delete(0, tk.END) self.Kp_entry.insert(0, f"{kp:.3f}") self.Ki_entry.delete(0, tk.END) self.Ki_entry.insert(0, f"{ki:.3f}") if kd is not None: self.Kd_entry.delete(0, tk.END) self.Kd_entry.insert(0, f"{kd:.3f}") # 恢复之前的状态 (如果是在RL模式下,它应该变回灰色的 DISABLED) self.Kp_entry.config(state=current_state) self.Ki_entry.config(state=current_state) self.Kd_entry.config(state=current_state) def setup_gui(self): """严格划分双标签页(页1:连接设置,页2:控制设置)""" # ========================================== # 1. 应用统一配色与全局 ttk 样式(定义见 styles.py) # ========================================== colors = apply_app_style(self.root) BG_COLOR = colors["BG_COLOR"] CARD_BG = colors["CARD_BG"] BORDER_COLOR = colors["BORDER_COLOR"] TEXT_MAIN = colors["TEXT_MAIN"] TEXT_MUTED = colors["TEXT_MUTED"] ACCENT_LIGHT = colors["ACCENT_LIGHT"] ACCENT_DARK = colors["ACCENT_DARK"] HOVER_BLUE = colors["HOVER_BLUE"] ACCENT_BLUE = colors["ACCENT_BLUE"] SUCCESS_GREEN = colors["SUCCESS_GREEN"] # ========================================== # 2. 创建顶部导航栏(深色横贯条:第一行系统名,第二行标签页) # 系统名与标签页同处一个深色容器内,浑然一体,无分界线 # ========================================== nav_frame = tk.Frame(self.root, bg=ACCENT_DARK) nav_frame.pack(fill=tk.X, side=tk.TOP) # --- 第一行:系统名 --- title_row = tk.Frame(nav_frame, bg=ACCENT_DARK) title_row.pack(fill=tk.X) title_label = tk.Label( title_row, text="ReinLoop", bg=ACCENT_DARK, fg="white", font=("Microsoft YaHei", 20, "bold") ) title_label.pack(side=tk.LEFT, padx=20, pady=(10, 4)) # --- 第二行:标签页(自定义按钮,仅颜色变化,尺寸恒定)--- tab_row = tk.Frame(nav_frame, bg=ACCENT_DARK) tab_row.pack(fill=tk.X) # ========================================== # 3. 创建核心双标签页容器(隐藏自带标签栏,由上方导航栏切换) # ========================================== self.notebook = ttk.Notebook(self.root) self.notebook.pack(expand=True, fill=tk.BOTH, padx=10, pady=10) # 构建三个独立的标签页 Frame self.tab1 = ttk.Frame(self.notebook, padding="15") self.tab2 = ttk.Frame(self.notebook, padding="15") self.tab3 = ttk.Frame(self.notebook, padding="15") self.notebook.add(self.tab1) self.notebook.add(self.tab2) self.notebook.add(self.tab3) # --- 自定义导航标签按钮 --- # 颜色:未选中=深色主题色(与导航栏融为一体),选中=浅色主题色,悬停=过渡色 self._nav_tab_buttons = [] nav_tabs = [("连接设置", self.tab1), ("控制设置", self.tab2), ("模型调试", self.tab3)] def _select_nav_tab(index): self.notebook.select(index) for i, btn in enumerate(self._nav_tab_buttons): if i == index: btn.config(bg=ACCENT_LIGHT, fg="white") else: btn.config(bg=ACCENT_DARK, fg="white") # 立即刷新空闲任务队列,强制新页面马上重绘 # (否则单击时事件队列为空,页面重绘会被延迟到下一个事件到来时才显示) self.notebook.update_idletasks() for idx, (label_text, _tab) in enumerate(nav_tabs): btn = tk.Label( tab_row, text=label_text, bg=ACCENT_DARK, fg="white", font=("Microsoft YaHei", 16, "bold"), padx=24, pady=8, cursor="hand2" ) btn.pack(side=tk.LEFT, padx=(20 if idx == 0 else 4, 0), pady=(0, 4)) btn.bind("", lambda e, i=idx: _select_nav_tab(i)) def _on_enter(e, b=btn, i=idx): if self.notebook.index(self.notebook.select()) != i: b.config(bg=HOVER_BLUE) def _on_leave(e, b=btn, i=idx): if self.notebook.index(self.notebook.select()) != i: b.config(bg=ACCENT_DARK) btn.bind("", _on_enter) btn.bind("", _on_leave) self._nav_tab_buttons.append(btn) # 默认选中第一个标签页 _select_nav_tab(0) # ========================================== # 3. 布局【页面 1:连接设置】 # ========================================== # 列配置:标签列固定宽度,控件列自适应 self.tab1.columnconfigure(0, minsize=140) self.tab1.columnconfigure(1, weight=1) _r = 0 # 行计数器 # ---------------- Modbus TCP 区 ---------------- ttk.Label(self.tab1, text="Modbus TCP", style="Section.TLabel").grid( row=_r, column=0, columnspan=2, sticky=tk.W, padx=12, pady=(8, 2)) _r += 1 # PLC 地址 ttk.Label(self.tab1, text="PLC地址:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) self.tcp_ip_entry = ttk.Entry(self.tab1, width=20) self.tcp_ip_entry.insert(0, "192.168.1.88") self.tcp_ip_entry.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) _r += 1 # 端口 ttk.Label(self.tab1, text="端口:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) self.tcp_port_entry = ttk.Entry(self.tab1, width=20) self.tcp_port_entry.insert(0, "502") self.tcp_port_entry.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) _r += 1 # 读取压力寄存器地址 ttk.Label(self.tab1, text="读取压力寄存器地址:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) self.pressure_addr_entry = ttk.Entry(self.tab1, width=20) self.pressure_addr_entry.insert(0, "504") self.pressure_addr_entry.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) _r += 1 # ---------------- Modbus RTU 区 ---------------- ttk.Label(self.tab1, text="Modbus RTU", style="Section.TLabel").grid( row=_r, column=0, columnspan=2, sticky=tk.W, padx=12, pady=(18, 2)) _r += 1 # 端口号(下拉) ttk.Label(self.tab1, text="端口号:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) self.serial_port_var = tk.StringVar() self.serial_port_cb = ttk.Combobox(self.tab1, textvariable=self.serial_port_var, width=18, state="readonly") self.serial_port_cb.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) _r += 1 # 波特率(下拉) ttk.Label(self.tab1, text="波特率:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) self.baudrate_var = tk.StringVar(value="115200") self.baudrate_cb = ttk.Combobox(self.tab1, textvariable=self.baudrate_var, width=18, state="readonly", values=["9600", "19200", "38400", "57600", "115200"]) self.baudrate_cb.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) _r += 1 # 站号 / 数据位 / 停止位(同一行) ttk.Label(self.tab1, text="站号:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) rtu_line = tk.Frame(self.tab1, bg=CARD_BG) rtu_line.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) self.rtu_slave_entry = ttk.Entry(rtu_line, width=6) self.rtu_slave_entry.insert(0, "4") self.rtu_slave_entry.pack(side=tk.LEFT) ttk.Label(rtu_line, text="数据位:").pack(side=tk.LEFT, padx=(20, 5)) self.databits_entry = ttk.Entry(rtu_line, width=6) self.databits_entry.insert(0, "8") self.databits_entry.pack(side=tk.LEFT) ttk.Label(rtu_line, text="停止位:").pack(side=tk.LEFT, padx=(20, 5)) self.stopbits_entry = ttk.Entry(rtu_line, width=6) self.stopbits_entry.insert(0, "1") self.stopbits_entry.pack(side=tk.LEFT) _r += 1 # 校验位(下拉) ttk.Label(self.tab1, text="校验位:").grid(row=_r, column=0, sticky=tk.W, padx=(12, 2), pady=8) self.parity_var = tk.StringVar(value="None") self.parity_cb = ttk.Combobox(self.tab1, textvariable=self.parity_var, width=18, state="readonly", values=["None", "Odd", "Even"]) self.parity_cb.grid(row=_r, column=1, sticky=tk.W, padx=(2, 10), pady=8) _r += 1 # 操作动作按钮组 btn_group = tk.Frame(self.tab1, bg=CARD_BG) btn_group.grid(row=_r, column=0, columnspan=2, sticky=tk.W, padx=10, pady=20) self.refresh_port_btn = ttk.Button(btn_group, text="🔄 刷新", command=self.refresh_serial_ports) self.refresh_port_btn.pack(side=tk.LEFT, padx=(0, 15)) self.connect_btn = ttk.Button(btn_group, text="连接设备", style="Action.TButton", command=self.toggle_connection) self.connect_btn.pack(side=tk.LEFT, padx=5) # 初始化调用一次串口刷新 self.refresh_serial_ports() # ========================================== # 4. 布局【页面 2:控制设置】 # ========================================== self.tab2.columnconfigure(0, weight=1) self.tab2.rowconfigure(3, weight=1) # 允许底部的数据图表与日志终端拉伸 # --- [A. 实时监控大字号仪表看板] --- status_frame = ttk.LabelFrame(self.tab2, text=" 系统状态 ", padding="10") status_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) status_frame.columnconfigure((0, 1, 2), weight=1, uniform="status_cards") # 当前压力 p_card = tk.Frame(status_frame, bg=CARD_BG, bd=1, relief="solid") p_card.grid(row=0, column=0, padx=6, pady=6, sticky="nsew") tk.Label(p_card, text="当前系统压力", bg=CARD_BG, fg=TEXT_MUTED, font=("Microsoft YaHei", 18)).pack(anchor="w", padx=10, pady=(8, 2)) self.current_pressure_var = tk.StringVar(value="0.0 kPa") tk.Label(p_card, textvariable=self.current_pressure_var, bg=CARD_BG, fg="#059669", font=("Century Gothic", 28, "bold")).pack(anchor="w", padx=10, pady=(0, 8)) # 目标压力 t_card = tk.Frame(status_frame, bg=CARD_BG, bd=1, relief="solid") t_card.grid(row=0, column=1, padx=6, pady=6, sticky="nsew") tk.Label(t_card, text="设定目标压力", bg=CARD_BG, fg=TEXT_MUTED, font=("Microsoft YaHei", 18)).pack(anchor="w", padx=10, pady=(8, 2)) self.target_pressure_var = tk.StringVar(value="0.0 kPa") tk.Label(t_card, textvariable=self.target_pressure_var, bg=CARD_BG, fg=ACCENT_BLUE, font=("Century Gothic", 28, "bold")).pack(anchor="w", padx=10, pady=(0, 8)) # 阀门开度 v_card = tk.Frame(status_frame, bg=CARD_BG, bd=1, relief="solid") v_card.grid(row=0, column=2, padx=6, pady=6, sticky="nsew") tk.Label(v_card, text="控制阀门开度", bg=CARD_BG, fg=TEXT_MUTED, font=("Microsoft YaHei", 18)).pack(anchor="w", padx=10, pady=(8, 2)) self.valve_opening_var = tk.StringVar(value="0.0 %") tk.Label(v_card, textvariable=self.valve_opening_var, bg=CARD_BG, fg="#D97706", font=("Century Gothic", 28, "bold")).pack(anchor="w", padx=10, pady=(0, 8)) # --- [B. 参数运行模态配置区] --- control_frame = ttk.LabelFrame(self.tab2, text=" 控制设置 ", padding="10") control_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) # 物理工况:环境容积 + 稳态流量 row0 = tk.Frame(control_frame, bg=CARD_BG) row0.pack(fill=tk.X, pady=5) ttk.Label(row0, text="物理工况:").pack(side=tk.LEFT, padx=(0, 15)) ttk.Label(row0, text="容积:").pack(side=tk.LEFT) self.volume_var = tk.StringVar(value="2") self.volume_entry = ttk.Entry(row0, textvariable=self.volume_var, width=6) self.volume_entry.pack(side=tk.LEFT, padx=5) ttk.Label(row0, text="L").pack(side=tk.LEFT, padx=(2, 20)) ttk.Label(row0, text="流量:").pack(side=tk.LEFT) self.flow_var = tk.StringVar(value="100") self.flow_entry = ttk.Entry(row0, textvariable=self.flow_var, width=6) self.flow_entry.pack(side=tk.LEFT, padx=5) ttk.Label(row0, text="L/min").pack(side=tk.LEFT, padx=2) # 目标压力设定 + 控制启停 row1 = tk.Frame(control_frame, bg=CARD_BG) row1.pack(fill=tk.X, pady=5) ttk.Label(row1, text="目标压力:").pack(side=tk.LEFT, padx=(0, 15)) self.target_entry = ttk.Entry(row1, width=10) self.target_entry.insert(0, "80.0") self.target_entry.pack(side=tk.LEFT, padx=5) ttk.Label(row1, text="kPa").pack(side=tk.LEFT, padx=(2, 10)) self.set_target_btn = ttk.Button(row1, text="设置目标", command=self.set_target_pressure) self.set_target_btn.pack(side=tk.LEFT, padx=5) # 控制模式单选组 row2 = tk.Frame(control_frame, bg=CARD_BG) row2.pack(fill=tk.X, pady=5) ttk.Label(row2, text="控制方式:").pack(side=tk.LEFT, padx=(0, 15)) self.control_mode_var = tk.StringVar(value="RL") self.radio_rl = ttk.Radiobutton(row2, text="智能自动", variable=self.control_mode_var, value="RL", command=self._on_mode_change) self.radio_rl.pack(side=tk.LEFT, padx=10) self.radio_pid = ttk.Radiobutton(row2, text="手动PID", variable=self.control_mode_var, value="PID", command=self._on_mode_change) self.radio_pid.pack(side=tk.LEFT, padx=10) self.radio_manual = ttk.Radiobutton(row2, text="设置开度", variable=self.control_mode_var, value="MANUAL", command=self._on_mode_change) self.radio_manual.pack(side=tk.LEFT, padx=10) # 强化学习决策模型加载组 self.rl_frame = tk.Frame(control_frame, bg=CARD_BG) self.rl_frame.pack(fill=tk.X, pady=5) ttk.Label(self.rl_frame, text="决策模型:").pack(side=tk.LEFT, padx=(0, 15)) self.model_combobox = ttk.Combobox(self.rl_frame, width=25, state="readonly") self.model_combobox.pack(side=tk.LEFT, padx=5) self.load_model_btn = ttk.Button(self.rl_frame, text="加载模型", command=self.load_rl_model) self.load_model_btn.pack(side=tk.LEFT, padx=5) self.refresh_models_btn = ttk.Button(self.rl_frame, text="🔄 刷新", width=6, command=self.scan_models_folder) self.refresh_models_btn.pack(side=tk.LEFT, padx=5) # 经典PID调节面板 self.pid_frame = tk.Frame(control_frame, bg=CARD_BG) self.pid_frame.pack(fill=tk.X, pady=5) ttk.Label(self.pid_frame, text="PID 调节:").pack(side=tk.LEFT, padx=(0, 15)) ttk.Label(self.pid_frame, text="Kp:").pack(side=tk.LEFT) self.Kp_entry = ttk.Entry(self.pid_frame, width=6) self.Kp_entry.pack(side=tk.LEFT, padx=5) ttk.Label(self.pid_frame, text="Ki:").pack(side=tk.LEFT) self.Ki_entry = ttk.Entry(self.pid_frame, width=6) self.Ki_entry.pack(side=tk.LEFT, padx=5) ttk.Label(self.pid_frame, text="Kd:").pack(side=tk.LEFT) self.Kd_entry = ttk.Entry(self.pid_frame, width=6) self.Kd_entry.pack(side=tk.LEFT, padx=5) self.update_pid_btn = ttk.Button(self.pid_frame, text="更新PID参数", command=self.update_pid_parameters) self.update_pid_btn.pack(side=tk.LEFT, padx=(10, 0)) # 设置开度 self.manual_frame = tk.Frame(control_frame, bg=CARD_BG) self.manual_frame.pack(fill=tk.X, pady=5) ttk.Label(self.manual_frame, text="设置开度:").pack(side=tk.LEFT, padx=(0, 15)) self.valve_entry = ttk.Entry(self.manual_frame, width=10) self.valve_entry.insert(0, " ") self.valve_entry.pack(side=tk.LEFT, padx=5) ttk.Label(self.manual_frame, text="%").pack(side=tk.LEFT, padx=(2, 10)) self.set_valve_btn = ttk.Button(self.manual_frame, text="设置", command=self.set_valve) self.set_valve_btn.pack(side=tk.LEFT, padx=5) # 控制启停行(最后一行,始终在底部) control_row = tk.Frame(control_frame, bg=CARD_BG) control_row.pack(fill=tk.X, pady=5, side=tk.BOTTOM) self.start_btn = ttk.Button(control_row, text="开始控制", style="Action.TButton", command=self.toggle_control) self.start_btn.pack(side=tk.LEFT, padx=5) self.plot_btn = ttk.Button(control_row, text="绘制曲线", command=self.plot_control_data) self.plot_btn.pack(side=tk.LEFT, padx=5) # self.chk_collect_data = tk.Checkbutton(control_row, text="同步收集数据集", variable=self.collect_data_var, bg=CARD_BG) self.chk_collect_data = tk.Checkbutton( control_row, text="同步收集数据集", variable=self.collect_data_var, bg=CARD_BG, activebackground=CARD_BG, selectcolor="#0354AE", # 勾选时背景色为蓝色 fg="#1F2937", # 文字颜色 activeforeground="#1F2937" ) self.chk_collect_data.pack(side=tk.LEFT, padx=(15, 0)) # --- [D. 预留空间] --- # 日志栏已移至底部导航栏 # ========================================== # 5. 布局【页面 3:模型调试】—— 与页面1一致:grid 排列、无外框 # ========================================== self.tab3.columnconfigure(0, weight=1) # --- [A. 系统辨识] --- identify_frame = ttk.LabelFrame(self.tab3, text=" 系统辨识 ", padding="10") identify_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=(0, 10)) # 压力上限(对应 measure_volume 的 p_max) pmax_row = tk.Frame(identify_frame, bg=CARD_BG) pmax_row.pack(fill=tk.X, pady=5) ttk.Label(pmax_row, text="压力上限:").pack(side=tk.LEFT) self.p_max_var = tk.StringVar(value="200") self.p_max_entry = ttk.Entry(pmax_row, textvariable=self.p_max_var, width=6) self.p_max_entry.pack(side=tk.LEFT, padx=5) ttk.Label(pmax_row, text="kPa").pack(side=tk.LEFT) # 过程升温(对应 measure_volume 的 t_delta,单位 °C) tdelta_row = tk.Frame(identify_frame, bg=CARD_BG) tdelta_row.pack(fill=tk.X, pady=5) ttk.Label(tdelta_row, text="过程升温:").pack(side=tk.LEFT) self.t_delta_var = tk.StringVar(value="30") self.t_delta_entry = ttk.Entry(tdelta_row, textvariable=self.t_delta_var, width=6) self.t_delta_entry.pack(side=tk.LEFT, padx=5) ttk.Label(tdelta_row, text="°C").pack(side=tk.LEFT) # 约束上界 / 下界(对应 measure_volume 的 fit_high / fit_low) constraint_row = tk.Frame(identify_frame, bg=CARD_BG) constraint_row.pack(fill=tk.X, pady=5) ttk.Label(constraint_row, text="约束上界:").pack(side=tk.LEFT) self.fit_high_var = tk.StringVar(value="150") self.fit_high_entry = ttk.Entry(constraint_row, textvariable=self.fit_high_var, width=6) self.fit_high_entry.pack(side=tk.LEFT, padx=5) ttk.Label(constraint_row, text="下界:").pack(side=tk.LEFT, padx=(20, 0)) self.fit_low_var = tk.StringVar(value="50") self.fit_low_entry = ttk.Entry(constraint_row, textvariable=self.fit_low_var, width=6) self.fit_low_entry.pack(side=tk.LEFT, padx=5) # 容积 + 测试按钮 volumn_row = tk.Frame(identify_frame, bg=CARD_BG) volumn_row.pack(fill=tk.X, pady=5) ttk.Label(volumn_row, text="容积:").pack(side=tk.LEFT) self.volume_var = tk.StringVar(value="") self.volume_entry = ttk.Entry(volumn_row, textvariable=self.volume_var, width=6) self.volume_entry.pack(side=tk.LEFT, padx=5) ttk.Label(volumn_row, text="L").pack(side=tk.LEFT, padx=(0, 20)) self.test_btn = ttk.Button(volumn_row, text="测试", command=self.get_V) self.test_btn.pack(side=tk.LEFT, padx=5) # 周期(对应 collect_data_with_prbs 的 t_c) period_row = tk.Frame(identify_frame, bg=CARD_BG) period_row.pack(fill=tk.X, pady=5) ttk.Label(period_row, text="周期:").pack(side=tk.LEFT) self.period_var = tk.StringVar(value="2.5") self.period_entry = ttk.Entry(period_row, textvariable=self.period_var, width=6) self.period_entry.pack(side=tk.LEFT, padx=5) ttk.Label(period_row, text="s").pack(side=tk.LEFT) # 阶数(对应 n_order) order_row = tk.Frame(identify_frame, bg=CARD_BG) order_row.pack(fill=tk.X, pady=5) ttk.Label(order_row, text="阶数:").pack(side=tk.LEFT) self.order_var = tk.StringVar(value="6") self.order_entry = ttk.Entry(order_row, textvariable=self.order_var, width=6) self.order_entry.pack(side=tk.LEFT, padx=5) # 序列(对应 levels)+ 开始辨识按钮 ident_row = tk.Frame(identify_frame, bg=CARD_BG) ident_row.pack(fill=tk.X, pady=5) ttk.Label(ident_row, text="序列:").pack(side=tk.LEFT) self.levels_var = tk.StringVar() self.levels_entry = ttk.Entry(ident_row, textvariable=self.levels_var, width=15) self.levels_entry.pack(side=tk.LEFT, padx=5) self.identify_btn = ttk.Button(ident_row, text="开始辨识", command=self.start_identification) self.identify_btn.pack(side=tk.LEFT, padx=5) # --- [B. 高级设置] --- advanced_frame = ttk.LabelFrame(self.tab3, text=" 高级设置 ", padding="10") advanced_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=(10, 0)) # 死区(控制用 dead_area + collect_data_with_prbs 的 dead_area) dz_row = tk.Frame(advanced_frame, bg=CARD_BG) dz_row.pack(fill=tk.X, pady=5) ttk.Label(dz_row, text="死区:").pack(side=tk.LEFT) self.dz_var = tk.StringVar(value="") self.dz_entry = ttk.Entry(dz_row, textvariable=self.dz_var, width=6) self.dz_entry.pack(side=tk.LEFT, padx=5) # 单步限幅(原 motor_max -> du_max) bound_row = tk.Frame(advanced_frame, bg=CARD_BG) bound_row.pack(fill=tk.X, pady=5) ttk.Label(bound_row, text="单步限幅:").pack(side=tk.LEFT) self.motor_max_var = tk.StringVar(value="") self.motor_max_entry = ttk.Entry(bound_row, textvariable=self.motor_max_var, width=6) self.motor_max_entry.pack(side=tk.LEFT, padx=5) # 总限幅(对应 collect_data_with_prbs 的 xa_full) xa_full_row = tk.Frame(advanced_frame, bg=CARD_BG) xa_full_row.pack(fill=tk.X, pady=5) ttk.Label(xa_full_row, text="总限幅:").pack(side=tk.LEFT) self.xa_full_var = tk.StringVar(value="749") self.xa_full_entry = ttk.Entry(xa_full_row, textvariable=self.xa_full_var, width=6) self.xa_full_entry.pack(side=tk.LEFT, padx=5) # ========================================== # 6. 底部导航栏(包含日志和连接状态) # ========================================== self.bottom_frame = tk.Frame(self.root, bg=BG_COLOR) self.bottom_frame.pack(fill=tk.X, side=tk.BOTTOM, padx=5, pady=1) # 设置两列权重:第一列(日志)占 3,第二列(状态)占 1,即比例 3:1 self.bottom_frame.columnconfigure(0, weight=3) # 日志区域 self.bottom_frame.columnconfigure(1, weight=1, minsize=150) # 状态区域 # 左侧容器:日志 log_container = tk.Frame(self.bottom_frame, bg=BG_COLOR) log_container.grid(row=0, column=0, sticky="nsew", padx=10, pady=5) # self.log_text = scrolledtext.ScrolledText( # log_container, height=2, # bg=BG_COLOR, fg="#059669", # insertbackground="#1F2937", selectbackground="#93C5FD", # highlightthickness=0, # relief="flat", borderwidth=0, font=("Consolas", 15) # ) self.log_text = tk.Text( log_container, height=1, fg=TEXT_MUTED, insertbackground="#1F2937", selectbackground="#93C5FD", highlightthickness=0, relief="flat", borderwidth=0, font=("Consolas", 15) ) self.log_text.pack(fill=tk.BOTH, expand=True) # 右侧容器:连接状态 status_container = tk.Frame(self.bottom_frame, bg=BG_COLOR) status_container.grid(row=0, column=1, sticky="nsew", padx=0, pady=5) self.connection_status_var = tk.StringVar(value="未连接") self.status_lbl = tk.Label( status_container, textvariable=self.connection_status_var, bg=BG_COLOR, fg="#EF4444", font=("Microsoft YaHei", 20, "bold") ) self.status_lbl.pack(expand=True, fill=tk.BOTH) # 联动更新初始的 PID/RL 输入框置灰状态 self._on_mode_change() def refresh_serial_ports(self): """扫描当前电脑可用的所有物理/虚拟串口并更新两个下拉框(压力表和电机)""" ports = [port.device for port in serial.tools.list_ports.comports()] # 更新压力表串口下拉框 # self.pressure_serial_cb['values'] = ports # if ports: # self.pressure_serial_cb.current(0) # 默认选中第一个可用串口 # else: # self.pressure_serial_cb.set("无可用串口") # self.safe_log("警告: 未检测到任何可用串口,请检查压力表线缆连接!") # 更新电机串口下拉框 self.serial_port_cb['values'] = ports if ports: self.serial_port_cb.current(0) # 默认选中第一个可用串口 # self.log_message(f"已扫描到 {len(ports)} 个串口") # 可选:为了避免启动时日志太啰嗦,这行可以注释掉 else: self.serial_port_cb.set("无可用串口") self.safe_log("警告: 未检测到任何可用串口,请检查电机线缆连接!") def scan_models_folder(self): """从云端 model_config 文件夹扫描模型文件(不再扫描本地)""" def fetch_models(): try: payload = {"type": "listModels", "folder": f"{the_folder}/model_config"} resp = requests.post(data_record_url, json=payload, timeout=10) result = resp.json() if result.get("success"): files = result.get("files", []) file_list = result.get("fileList", []) # 建立 文件名 -> fileID 的映射(downloadModel 云函数需要 fileID) self.model_file_map = { item.get("fileName"): item.get("fileID") for item in file_list if item.get("fileName") } def _update_combobox(): if files: self.model_combobox['values'] = files self.model_combobox.current(0) # 默认选中第一个 self.root.after(0, lambda: self.log_message(f"成功刷新")) else: self.model_combobox['values'] = [] self.model_combobox.set("无模型文件") self.root.after(0, _update_combobox) else: err = result.get('errMsg') self.root.after(0, lambda err=err: self.log_message(f"获取模型列表失败: {err}")) except Exception as e: msg = str(e) self.root.after(0, lambda msg=msg: self.log_message(f"扫描模型异常: {msg}")) threading.Thread(target=fetch_models, daemon=True).start() # """扫描 model_config 文件夹下的所有模型文件(支持 .onnx, .pt, .pth)""" # base_path = get_base_path() # models_dir = os.path.join(base_path, "model_config") # if not os.path.exists(models_dir): # os.makedirs(models_dir, exist_ok=True) # self.model_combobox['values'] = [] # self.model_combobox.set("") # return # # 支持的模型文件扩展名 # extensions = ('.enc', '.sys', '.zip', '.mlr') # # extensions = ('.pt', '.pth', '.zip', 'rar', '.onnx') # model_files = [] # for f in os.listdir(models_dir): # if f.lower().endswith(extensions): # model_files.append(f) # 只保存文件名,完整路径在加载时拼接 # model_files.sort() # self.model_combobox['values'] = model_files # if model_files: # self.model_combobox.current(0) # 默认选中第一个 # else: # self.model_combobox.set("无模型文件") def _on_mode_change(self): mode = self.control_mode_var.get() if mode == "PID": # 显示PID栏,隐藏其他 self.rl_frame.pack_forget() self.pid_frame.pack(fill=tk.X, pady=5) self.manual_frame.pack_forget() self.Kp_entry.config(state=tk.NORMAL) self.Ki_entry.config(state=tk.NORMAL) self.Kd_entry.config(state=tk.NORMAL) self.update_pid_btn.config(state=tk.NORMAL) self.volume_entry.config(state=tk.NORMAL) self.flow_entry.config(state=tk.NORMAL) self.model_combobox.config(state=tk.DISABLED) self.load_model_btn.config(state=tk.DISABLED) self.refresh_models_btn.config(state=tk.DISABLED) # self.collect_data_var.set(False) self.chk_collect_data.config(state=tk.NORMAL) elif mode == "RL": # RL # 显示决策模型栏,隐藏其他 self.rl_frame.pack(fill=tk.X, pady=5) self.pid_frame.pack_forget() self.manual_frame.pack_forget() self.Kp_entry.config(state=tk.DISABLED) self.Ki_entry.config(state=tk.DISABLED) self.Kd_entry.config(state=tk.DISABLED) self.update_pid_btn.config(state=tk.DISABLED) self.volume_entry.config(state=tk.NORMAL) self.flow_entry.config(state=tk.NORMAL) self.model_combobox.config(state="readonly") self.load_model_btn.config(state=tk.NORMAL) self.refresh_models_btn.config(state=tk.NORMAL) self.chk_collect_data.config(state=tk.NORMAL) elif mode == "MANUAL": # 手动设置开度 # 显示设置开度栏,隐藏其他 self.rl_frame.pack_forget() self.pid_frame.pack_forget() self.manual_frame.pack(fill=tk.X, pady=5) self.Kp_entry.config(state=tk.DISABLED) self.Ki_entry.config(state=tk.DISABLED) self.Kd_entry.config(state=tk.DISABLED) self.update_pid_btn.config(state=tk.DISABLED) self.volume_entry.config(state=tk.NORMAL) self.flow_entry.config(state=tk.NORMAL) self.model_combobox.config(state=tk.DISABLED) self.load_model_btn.config(state=tk.DISABLED) self.refresh_models_btn.config(state=tk.DISABLED) self.collect_data_var.set(False) self.chk_collect_data.config(state=tk.DISABLED) def load_rl_model(self): """从下拉框选择的文件名加载 RL 模型""" selected = self.model_combobox.get() if not selected or selected == "无模型文件": self.log_message("错误:请先选择一个有效的模型") return def download_and_load(): try: # 1. 取出该模型对应的 fileID(downloadModel 云函数只认 fileID) file_id = getattr(self, "model_file_map", {}).get(selected) if not file_id: self.root.after(0, lambda: self.log_message("获取模型下载链接失败: 缺少 fileID,请先刷新模型列表")) return else: self.root.after(0, lambda: self.log_message(f"正在加载模型: {selected}...")) # print(f"Debug: 选中的模型文件 {selected} 对应的 fileID 是 {file_id}") # 2. 请求云函数获取模型文件的临时下载 URL payload = { "type": "downloadModel", "fileID": file_id } resp = requests.post(data_record_url, json=payload, timeout=15) result = resp.json() if not result.get("success"): err = result.get('errMsg') self.root.after(0, lambda err=err: self.log_message(f"获取模型下载链接失败: {err}")) return url = result['url'] # 3. 下载模型文件(二进制内容) model_resp = requests.get(url, timeout=30) if model_resp.status_code != 200: code = model_resp.status_code self.root.after(0, lambda code=code: self.log_message(f"下载模型文件失败: HTTP {code}")) return model_bytes = model_resp.content # 4. 直接加载到内存(无需解密) import io model_stream = io.BytesIO(model_bytes) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = SAC.load(model_stream, device=device) # 5. 保存模型实例 self.rl_model = model self.root.after(0, lambda: self.log_message(f"成功加载模型: {selected}")) except Exception as e: msg = str(e) self.root.after(0, lambda msg=msg: self.log_message(f"加载模型失败: {msg}")) threading.Thread(target=download_and_load, daemon=True).start() # base_path = get_base_path() # model_path = os.path.join(base_path, "model_config", selected) # if not os.path.exists(model_path): # self.log_message(f"错误:模型文件不存在 -> {model_path}") # return # # if not selected.lower().endswith('.sys'): # # self.log_message(f"错误:仅支持 .sys 格式的文件,当前文件为 {selected}") # # return # try: # # 获取当前界面输入的容积和流量(用于构建临时环境) # # vol_str = self.volume_var.get().strip() # # flow_str = self.flow_var.get().strip() # # if not vol_str or not flow_str: # # self.log_message("错误:请先填写容积(L)和流量(L/min)") # # return # # V = float(vol_str) # # Q_in = float(flow_str) # # temp_env = CustomPressureEnv(Q_in=Q_in, V=V, dt=self.IncrementalPID.dt) # # 加载 SAC 模型 # import io # from cryptography.fernet import Fernet # # 1. 把你刚才生成的密钥硬编码写在这里 # cipher = Fernet(SECRET_KEY) # # 2. 读取硬盘上的加密乱码文件 # with open(model_path, 'rb') as f: # encrypted_data = f.read() # # 3. 在内存中瞬间解密 # decrypted_data = cipher.decrypt(encrypted_data) # # 4. 🌟 核心技巧:将内存中的字节数组伪装成一个“文件对象” # model_stream = io.BytesIO(decrypted_data) # # 5. 直接让 SAC 从内存流中加载模型,不接触硬盘! # device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # model = SAC.load(model_stream, device=device) # # model = SAC.load(model_path, env=temp_env, device=device) # # 将加载的模型和临时环境保存到实例变量中 # self.rl_model = model # # self.rl_env = temp_env # 保留环境引用,以便后续获取归一化参数 # self.log_message(f"成功加载模型: {selected}") # except Exception as e: # self.log_message(f"加载模型失败: {e}") def log_message(self, message): """添加消息到日志(线程安全优化版)- 一次只显示一条""" def _append_log(): # 清除之前的日志,只显示最新一条 self.log_text.delete('1.0', tk.END) self.log_text.insert(tk.END, f"{time.strftime('%H:%M:%S')} - {message}") # 🚨 绝对不要在这里使用 self.root.update() 🚨 # 将打印任务打包,丢给主线程的事件队列去安全执行,绝对不阻塞当前控制线程 self.root.after(0, _append_log) def start_identification(self): """启动辨识数据采集""" if self.running: self.log_message("错误:请先停止控制再进行辨识") return # 新增:检查是否正在辨识中 if hasattr(self, 'identifying') and self.identifying: self.log_message("辨识正在进行中,请等待完成") return if not self.modbus_client or not self.modbus_client.connect: self.log_message("错误:请先连接压力表") return if not hasattr(self, 'motor') or not self.motor.connect: self.log_message("错误:请先连接电机") return q_input = self.flow_var.get().strip() V_val = float(self.volume_var.get()) if self.volume_var.get() else 0 if not q_input: self.log_message("错误:请先在控制设置中输入流量") return try: q_in_val = float(q_input) except ValueError: self.log_message("错误:流量输入必须是有效数字") return self.identifying = True # 设置辨识中标志 self.log_message("开始辨识数据采集...") def collect_thread(): try: dt = 0.1 # 阶数 n_order(页面3 阶数输入框) try: n_order = int(self.order_var.get()) except (ValueError, AttributeError): self.log_message("警告: 阶数输入无效,使用默认值 6") n_order = 6 # 周期 t_c(页面3 周期输入框,单位 s) try: t_c = float(self.period_var.get()) except (ValueError, AttributeError): self.log_message("警告: 周期输入无效,使用默认值 2.5") t_c = 2.5 # 死区 dead_area(页面3 高级设置-死区) dz_str = self.dz_var.get().strip() try: dead_area = float(dz_str) if dz_str else 240 except ValueError: self.log_message("警告: 死区输入无效,使用默认值 240") dead_area = 240 # 总限幅 xa_full(页面3 高级设置-总限幅) try: xa_full = float(self.xa_full_var.get()) except (ValueError, AttributeError): self.log_message("警告: 总限幅输入无效,使用默认值 749") xa_full = 749 levels_str = self.levels_var.get().strip() try: levels = [int(x.strip()) for x in levels_str.split(',')] if len(levels) < 2: self.log_message("警告: 序列至少需要2个值,使用默认值") levels = [10, 20, 30, 40, 50, 60, 70, 80] except ValueError: self.log_message("警告: 序列输入格式错误,使用默认值") levels = [10, 20, 30, 40, 50, 60, 70, 80] def _on_sample(t, u_cmd, p): self.root.after(0, lambda u=u_cmd, p=p: [ self.valve_opening_var.set(f"{u:.1f} %"), self.current_pressure_var.set(f"{p:.1f} kPa") ]) result = collect_data_with_prbs( self.modbus_client, self.motor, q_in_val=q_in_val, dt=dt, n_order=n_order, t_c=t_c, levels=levels, dead_area=dead_area, xa_full=xa_full, # save_dir=os.path.join(get_base_path(), "ind_data"), V_val=V_val, should_stop=lambda: self.identifying is False, log=self.log_message, on_sample=_on_sample, ) if result['success']: # ========== 直接上传内存中的 CSV 数据 ========== csv_data = result.get('csv_data') filename = result.get('filename') if csv_data and filename: # 编码为 base64 file_base64 = base64.b64encode(csv_data).decode('utf-8') payload = { "type": "uploadDataFile", "fileName": filename, "fileBase64": file_base64, "folder": f"{the_folder}/ind_data" } try: resp = requests.post(data_record_url, json=payload, timeout=30) resp_json = resp.json() if resp_json.get("success"): self.root.after(0, lambda: self.log_message(f"辨识数据上传成功")) else: self.root.after(0, lambda: self.log_message(f"辨识数据上传失败: {resp_json.get('errMsg')}")) except Exception as e: err_msg = f"上传辨识数据异常: {e}" self.root.after(0, lambda msg=err_msg: self.log_message(msg)) self.root.after(0, lambda: self.log_message("辨识数据采集完成")) else: self.root.after(0, lambda: self.log_message("辨识未采集到数据")) except Exception as e: # 🚀 顺手加上打印完整的崩溃调用栈,以后如果再错就能一眼看出是哪行代码的问题 import traceback self.log_message(f"辨识数据采集详细错误: {traceback.format_exc()}") self.log_message(f"辨识数据采集失败: {e}") finally: self.identifying = False # 清除辨识中标志 self.log_message("辨识结束") thread = threading.Thread(target=collect_thread) thread.daemon = True thread.start() def get_V(self): """获取体积""" if self.running: self.log_message("错误:请先停止控制再进行测试") return # 新增:检查是否正在辨识中 if hasattr(self, 'identifying') and self.identifying: self.log_message("测试正在进行中,请等待完成") return if not self.modbus_client or not self.modbus_client.connect: self.log_message("错误:请先连接压力表") return if not hasattr(self, 'motor') or not self.motor.connect: self.log_message("错误:请先连接电机") return q_input = self.flow_var.get().strip() if not q_input: self.log_message("错误:请先在控制设置中输入流量") return try: q_in_val = float(q_input) except ValueError: self.log_message("错误:流量输入必须是有效数字") return # 约束上界 / 下界(页面3 约束上界/下界 -> measure_volume 的 fit_high / fit_low) try: fit_high = float(self.fit_high_var.get()) fit_low = float(self.fit_low_var.get()) except ValueError: self.log_message("错误:约束上界/下界必须是有效数字") return # 压力上限 p_max、过程升温 t_delta(页面3 系统辨识) try: p_max = float(self.p_max_var.get()) except ValueError: self.log_message("错误:压力上限必须是有效数字") return try: t_delta = float(self.t_delta_var.get()) except ValueError: self.log_message("错误:过程升温必须是有效数字") return self.identifying = True # 设置辨识中标志 self.log_message("开始测量容积...") def volume_thread(): try: result = measure_volume( self.modbus_client, self.motor, q_in_slm=q_in_val, dt=self.IncrementalPID.dt, p_max=p_max, fit_low=fit_low, fit_high=fit_high, t_delta=t_delta, should_stop=lambda: self.identifying is False, log=self.log_message, on_sample=lambda t, p: self.root.after( 0, lambda p=p: self.current_pressure_var.set(f"{p:.1f} kPa")), ) if result['success']: timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') vol = result['volume_L'] payload_data = result['payload_data'] json_str = json.dumps(payload_data, indent=2, ensure_ascii=False) json_bytes = json_str.encode('utf-8') file_base64 = base64.b64encode(json_bytes).decode('utf-8') filename = f"volume_test_{vol:.2f}L_{timestamp}.json" # 上传到云存储的 ind_data 文件夹(与辨识数据同一位置) upload_payload = { "type": "uploadDataFile", "fileName": filename, "fileBase64": file_base64, "folder": f"{the_folder}/V_config" } try: resp = requests.post(data_record_url, json=upload_payload, timeout=30) resp_json = resp.json() if resp_json.get("success"): self.root.after(0, lambda: self.log_message(f"体积测量成功")) else: self.root.after(0, lambda: self.log_message(f"体积测量失败")) except Exception as e: err_msg = f"体积测量异常: {e}" self.root.after(0, lambda msg=err_msg: self.log_message(msg)) # 把测得的等效体积自动填回容积输入框 self.root.after(0, lambda: self.volume_var.set(f"{vol:.2f}")) self.root.after(0, lambda: self.log_message(f"测量完成,系统等效体积 V = {vol:.4f} L")) else: self.root.after(0, lambda: self.log_message("测量失败:有效数据点不足,无法计算体积")) except Exception as e: import traceback self.log_message(f"容积测量详细错误: {traceback.format_exc()}") self.log_message(f"容积测量失败: {e}") finally: self.identifying = False # 清除辨识中标志 self.log_message("测量结束") thread = threading.Thread(target=volume_thread) thread.daemon = True thread.start() def toggle_connection(self): """切换Modbus连接状态""" if self.modbus_client and self.modbus_client.connect: self.disconnect_plc() else: self.connect_plc() def connect_plc(self): """连接到PLC及电机""" if os.environ.get("REINLOOP_SIMULATION", "").strip().lower() in { "1", "true", "yes", "on"}: # 模拟路径:不创建真实 TCP/RTU 客户端。 self.simulation_mode = True self.modbus_client = SimulatedDevice() self.motor = self.modbus_client self.modbus_client.connect() self.connection_status_var.set("模拟已连接") self.connect_btn.config(text="断开连接") self.log_message("模拟设备已连接(未访问真实 TCP/串口硬件)") return # ---------------- 读取 Modbus TCP 参数 ---------------- ip_address = self.tcp_ip_entry.get().strip() try: tcp_port = int(self.tcp_port_entry.get()) except ValueError: self.log_message("错误: TCP端口必须是整数") return try: pressure_addr = int(self.pressure_addr_entry.get()) except ValueError: self.log_message("错误: 压力寄存器地址必须是整数") return # ---------------- 读取 Modbus RTU 参数 ---------------- motor_port = self.serial_port_var.get() if not motor_port or motor_port == "无可用串口": self.log_message("错误: 请先在下拉框选择有效的端口号!") return try: baudrate = int(self.baudrate_var.get()) rtu_slave = int(self.rtu_slave_entry.get()) databits = int(self.databits_entry.get()) stopbits = int(self.stopbits_entry.get()) except ValueError: self.log_message("错误: 波特率/站号/数据位/停止位必须是整数") return # 校验位 None/Odd/Even -> pymodbus 的 N/O/E parity = {"None": "N", "Odd": "O", "Even": "E"}.get(self.parity_var.get(), "N") try: # Modbus TCP:读压力 self.modbus_client = Easy521ModbusClient(ip_address, port=tcp_port, current_p_addr=pressure_addr) # Modbus RTU:控电机 self.motor = MotorModbusRTUClient( port=motor_port, slave_id=rtu_slave, baudrate=baudrate, bytesize=databits, parity=parity, stopbits=stopbits ) if self.modbus_client.connect(): self.connection_status_var.set("已连接") self.connect_btn.config(text="断开连接") self.log_message(f"成功连接到PLC: {ip_address}:{tcp_port}") else: self.log_message(f"连接PLC失败: {ip_address}:{tcp_port}") return # PLC连不上直接退出,不连电机了 except Exception as e: self.log_message(f"连接错误: {str(e)}") return # ==================== 优化:去除 exit(1) 防闪退 ==================== if not self.motor.connect(): self.log_message(f"电机串口 ({motor_port}) 连接失败,请检查线缆或占用情况!") self.disconnect_plc() # 回滚状态 return time.sleep(1) # 增加短暂延时,等待驱动器接口就绪 if not self.motor.init(): self.log_message("电机初始化失败!") self.motor.disconnect() self.disconnect_plc() # 回滚状态 return self.log_message(f"电机串口 ({motor_port}) 连接并初始化成功!") def disconnect_plc(self): """断开PLC连接""" if self.modbus_client: self.modbus_client.disconnect() self.modbus_client = None self.connection_status_var.set("未连接") self.connect_btn.config(text="连接设备") self.log_message("已断开连接") # 电机可能尚未创建(如 TCP 阶段就失败),加保护避免 AttributeError if getattr(self, "motor", None): self.motor.disconnect() self.motor = None def set_target_pressure(self): """设置目标压力并同步到PLC""" try: target = float(self.target_entry.get()) if 0 <= target <= 300: self.confirmed_target_pressure = target self.log_message(f"目标压力设置为: {target} kPa") # # 更新本地控制器 # self.IncrementalPID.target_pressure = target # # 如果PLC已连接,将目标压力同步写入D42寄存器 # if self.modbus_client and self.modbus_client.connected: # try: # # 安全获取目标地址 # target_addr = self.safe_int_convert(self.target_addr_entry.get(), 42) # # 写入目标压力到PLC # success = self.modbus_client.write_float(target_addr, float(target)) # if success: # self.log_message(f"目标压力设置为: {target} kPa (已同步到PLC)") # # 更新显示 # self.target_pressure_var.set(f"{target:.1f} kPa") # else: # self.log_message(f"目标压力设置为: {target} kPa (但PLC写入失败)") # except Exception as e: # self.log_message(f"目标压力设置为: {target} kPa (但PLC写入错误: {str(e)})") # else: # self.log_message(f"目标压力设置为: {target} kPa (未连接PLC)") # # 更新显示 # self.target_pressure_var.set(f"{target:.1f} kPa") else: self.log_message("错误: 目标压力必须在0-300 kPa范围内") except ValueError: self.log_message("错误: 请输入有效的数字") def set_valve(self): """设置开度给阀门(仅手动模式)""" try: valve = float(self.valve_entry.get()) if 0 <= valve <= 120: self.confirmed_valve = valve self.log_message(f"阀门开度设置为: {valve}%") else: self.log_message("错误: 目标阀开度超出范围") except ValueError: self.log_message("错误: 请输入有效的数字") def toggle_control(self): """开始/停止控制""" if not self.running: self.start_control() else: self.stop_control() def update_pid_parameters(self): """更新PID参数""" try: self.IncrementalPID.kp = float(self.Kp_entry.get()) self.IncrementalPID.ki = float(self.Ki_entry.get()) self.IncrementalPID.kd = float(self.Kd_entry.get()) self.IncrementalPID._calculate_coefficients() self.log_message( f"PID参数更新为: Kp={self.IncrementalPID.kp}, Ki={self.IncrementalPID.ki}, Kd={self.IncrementalPID.kd}") except ValueError as e: self.log_message(f"PID参数输入错误: {e}") def start_control(self): """开始控制循环""" if not self.modbus_client or not self.modbus_client.connect: self.log_message("错误: 请先连接压力表") # self.log_message("错误: 请先连接PLC") return # ======================================================== # 🚀 新增:强制前置校验 # 如果处于 RL 模式,必须确认模型已成功加载,否则绝对不允许启动 # ======================================================== if self.control_mode_var.get() == "RL": if not hasattr(self, 'rl_model') or self.rl_model is None: self.log_message("❌ 启动失败: 强化学习模型未加载!") self.log_message("请先选择工况并点击【加载模型】按钮,然后再点击开始控制。") return # ======================================================== # ===== 消除魔法数字:根据当前压力预置 PID 初始阀位 ===== try: # pressure_addr = self.safe_int_convert(self.pressure_addr_entry.get(), 18) # p_init = self.modbus_client.read_float(pressure_addr) # if p_init is not None: # 这里的公式假设 300kPa 对应 100% 开度 (线性前馈) # 如果你在 env.calculate_feedforward_valve 里有更精确的公式,请替换这里 # initial_valve = max(0.0, min(100.0, (p_init / 300.0) * 100.0)) position_x = self.motor.read_current_position() initial_valve = self.IncrementalPID.init_v(position_x) self.IncrementalPID.output = initial_valve self.log_message(f"预置初始阀位 {initial_valve:.1f}%") # self.log_message(f"初始化:当前压力 {p_init:.1f}kPa,预置初始阀位 {initial_valve:.1f}%") except Exception as e: self.log_message(f"读取初始开度失败,将使用 80% 启动: {e}") self.IncrementalPID.output = 80.0 # ======================================================== self.cached_mode = self.control_mode_var.get() self.cached_collect_data = self.collect_data_var.get() # 提前把字符串转成浮点数存好 flow_str = self.flow_var.get().strip() if not flow_str: self.log_message("错误:请先在控制设置中输入流量") return try: self.cached_flow = float(flow_str) except ValueError: self.log_message("错误:流量输入必须是有效数字") return vol_str = self.volume_var.get() self.cached_volume = float(vol_str) if vol_str else 0.0 dz_str = self.dz_var.get().strip() self.cached_dz = float(dz_str) if dz_str else None # 如果输入了 dz 值,则使用输入的值,否则使用默认值 240 if self.cached_dz is not None: self.IncrementalPID.dead_area = self.cached_dz motor_max_str = self.motor_max_var.get().strip() self.cached_motor_max = float(motor_max_str) if motor_max_str else None self.running = True self.start_btn.config(text="停止控制") self.cycle_count = 0 self.start_time = time.time() self.pressure_data = [] self.target_data = [] self.valve_data = [] self.time_data = [] # --- 新增:初始化数据收集 --- self.episode_data_raw = [] self.current_episode = None self.last_target_rl = None # 记录上一个目标值,用于RL模型 self.last_target_record = None # 记录上一个目标值,用于切分 Episode # 写入M100为True # try: # control_flag_addr = self.safe_int_convert(self.control_flag_addr_entry.get(), 100) # success = self.modbus_client.write_coil(control_flag_addr, True) # if success: # self.log_message(f"已写入控制标志位 M{control_flag_addr} = True") # else: # self.log_message(f"写入控制标志位 M{control_flag_addr} 失败") # except Exception as e: # self.log_message(f"写入控制标志位错误: {str(e)}") # 在单独线程中运行控制循环 # 🌟 1. 挂一块小黑板(共享元组),初始化为 0 self.latest_display_data = (0.0, 0.0, 0.0) # 🌟 2. 告诉一号员工(主线程):每 100 毫秒去小黑板看一眼数据 self.root.after(50, self._ui_refresh_timer) self.control_thread = threading.Thread(target=self.control_loop, daemon=True) self.control_thread.start() def _ui_refresh_timer(self): """一号员工(主线程)专属:只负责看黑板、画界面""" if not self.running: return # 如果停止了,就不看了 # 从小黑板上读取最新数据 current_pressure, target_pressure, valve_opening = self.latest_display_data # 刷新界面显示 self.current_pressure_var.set(f"{current_pressure:.1f} kPa") self.target_pressure_var.set(f"{target_pressure:.1f} kPa") self.valve_opening_var.set(f"{valve_opening:.1f} %") # 设个闹钟,100毫秒后再次执行自己 self.root.after(50, self._ui_refresh_timer) def stop_control(self): """停止控制循环""" self.running = False self.start_btn.config(text="开始控制") self.log_message("停止控制") # --- 修复:只要有收集到数据就保存,不受复选框当前状态限制 --- # 把最后一个还没闭合的 episode 加入列表 if self.current_episode and len(self.current_episode['pressures']) > 0: self.episode_data_raw.append(self.current_episode) self.current_episode = None if self.episode_data_raw: self._save_and_upload_data() # 写入M100为False # if self.modbus_client and self.modbus_client.connect: # try: # control_flag_addr = self.safe_int_convert(self.control_flag_addr_entry.get(), 100) # success = self.modbus_client.write_coil(control_flag_addr, False) # if success: # self.log_message(f"已写入控制标志位 M{control_flag_addr} = False") # else: # self.log_message(f"写入控制标志位 M{control_flag_addr} 失败") # except Exception as e: # self.log_message(f"写入控制标志位错误: {str(e)}") def _save_and_upload_data(self): """本地保存数据,并(可选)异步回传到服务器""" try: # 1. 本地落盘 vol = self.cached_flow flow = self.cached_flow # base_dir = get_base_path() # save_dir = os.path.join(base_dir, f'data_record/data_{flow}SLM_{vol}L') # os.makedirs(save_dir, exist_ok=True) timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') filename = f'episode_raw_data_{timestamp}.pkl' # filepath = os.path.join(save_dir, filename) # with open(filepath, 'wb') as f: # pickle.dump(self.episode_data_raw, f) # self.log_message(f"已成功收集并保存 {len(self.episode_data_raw)} 段数据至 {filepath}") # 上传到微信云存储(新建线程防止阻塞 GUI) # 将内存数据序列化为 bytes,再转 base64 data_bytes = pickle.dumps(self.episode_data_raw) file_base64 = base64.b64encode(data_bytes).decode('utf-8') def upload_to_wechat(): try: payload = { "type": "uploadDataFile", "fileName": filename, "fileBase64": file_base64, "folder": f"{the_folder}/data_record/data_{flow}SLM_{vol}L" # 指定存储在云存储的 data_record 文件夹下 } resp = requests.post(data_record_url, json=payload, timeout=30) result = resp.json() if result.get("success"): self.root.after(0, lambda: self.log_message(f"成功收集数据")) else: self.root.after(0, lambda: self.log_message(f"收集数据失败: {result.get('errMsg')}")) except Exception as e: err_msg = f"收集数据异常: {e}" self.root.after(0, lambda msg=err_msg: self.log_message(msg)) threading.Thread(target=upload_to_wechat, daemon=True).start() except Exception as e: self.log_message(f"保存收集数据时发生错误: {e}") finally: self.episode_data_raw = [] # 清空内存 def reset_controller(self): """重置控制器""" if self.running: self.log_message("请先停止控制再重置控制器") return self.IncrementalPID.reset() self.log_message("控制器已重置") def safe_int_convert(self, value, default=0): """安全地将值转换为整数""" try: return int(value) except (ValueError, TypeError): return default def control_loop(self): """控制主循环""" initial_loop = True while self.running: cycle_start = time.perf_counter() # 记录周期开始时间 try: # 安全地获取地址值 # pressure_addr = self.safe_int_convert(self.pressure_addr_entry.get(), 18) # target_addr = self.safe_int_convert(self.target_addr_entry.get(), 42) # valve_addr = self.safe_int_convert(self.valve_addr_entry.get(), 40) current_time = time.time() elapsed_time = current_time - self.start_time if self.start_time else 0 self.time_data.append(elapsed_time) # 读取当前压力值 current_pressure = self.modbus_client.get_current_p() # print(f"t1-读压力用时:{time.perf_counter()-t1}") # current_pressure = self.modbus_client.read_float(pressure_addr) t2 = time.perf_counter() # 建议在高速循环中把这行打印注释掉,否则日志和控制台会刷屏导致软件卡顿 # self.log_message(f"当前压力:{current_pressure}") if initial_loop: target_pressure = float(self.target_entry.get()) set_valve = float(self.valve_entry.get()) initial_loop = False if current_pressure is not None: # plc_target = self.modbus_client.read_float(target_addr) # target_pressure = plc_target if plc_target is not None else float(self.target_entry.get()) # 使用确认后的目标压力(只有点击"设置目标"按钮才会更新) target_pressure = self.confirmed_target_pressure # ======================================================== current_mode = self.cached_mode # current_mode = self.control_mode_var.get() # print(f"t2={time.perf_counter()-t1}") if current_mode == "PID": # 1. 纯 PID 控制模式 self.IncrementalPID.update_pressure_values(current_pressure, target_pressure) valve_opening = self.IncrementalPID.update() xa = 749 * (100 - valve_opening) / 100 success = self.motor.set_position(xa) # success = self.motor.set_position(valve_opening) elif current_mode == "RL": if hasattr(self, 'rl_model') and self.rl_model is not None: t3 = time.perf_counter() volume_val = self.cached_volume flow_rate = self.cached_flow # # --- A. 获取容积 (框里只有数字,直接转 float) --- # volume_str = self.volume_var.get() # volume_val = float(volume_str) if volume_str else 0.0 # # --- B. 获取流量 (框里只有数字,直接转 float) --- # flow_str = self.flow_var.get() # flow_rate = float(flow_str) if flow_str else 0.0 # dz_str = self.dz_var.get().strip() # if dz_str: # self.IncrementalPID.dead_area = float(dz_str) # print(f"t3={time.perf_counter() - t1}") # --- C. 调用 RL 模型预测参数增量 --- if self.last_target_rl is None: self.last_target_rl = target_pressure position_x = self.motor.read_current_position() self.IncrementalPID.output = self.IncrementalPID.init_v(position_x) # self.IncrementalPID.output = self.rl_env.calculate_feedforward_valve(current_pressure) obs = np.array([flow_rate / 100, current_pressure / 100, (target_pressure - current_pressure) / 100], dtype=np.float32) self.log_message(f"obs:{obs}") action, _ = self.rl_model.predict(obs, deterministic=True) # --- D. 更新 PID 参数 --- action_space = self.rl_model.action_space self.Kp_0 = action_space.high[0] self.Ki_0 = action_space.high[1] self.IncrementalPID.kp = self.Kp_0 + action[0] self.IncrementalPID.ki = self.Ki_0 + action[1] # self.IncrementalPID.kp = self.rl_env.Kp_0 + action[0] # self.IncrementalPID.ki = self.rl_env.Ki_0 + action[1] self.log_message( f"Kp={self.IncrementalPID.kp:.4f}, Ki={self.IncrementalPID.ki:.4f}, Kd={self.IncrementalPID.kd:.4f}") self.IncrementalPID._calculate_coefficients() self.root.after(0, self._update_pid_ui, self.IncrementalPID.kp, self.IncrementalPID.ki, self.IncrementalPID.kd) # error = -(target_pressure - current_pressure) # dkp, dki = self.rl_controller.predict(current_pressure, error) # new_kp = max(0.0, min(10.0, 1.0 + dkp)) # new_ki = max(0.0, min(20.0, 0.4 + dki)) # self.IncrementalPID.kp = new_kp # self.IncrementalPID.ki = new_ki # self.IncrementalPID._calculate_coefficients() # self.root.after(0, self._update_pid_ui, new_kp, new_ki) elif self.last_target_rl != target_pressure: # 更新 last_target,确保只在目标压力真正变化时调用一次 RL 模型 self.last_target_rl = target_pressure obs = np.array([flow_rate/100, current_pressure/100, (target_pressure-current_pressure)/100], dtype=np.float32) self.log_message(f"obs: {obs}") action, _ = self.rl_model.predict(obs, deterministic=True) t4 = time.perf_counter() # --- D. 更新 PID 参数 --- # Kp_0 = action_space.high[0] # Ki_0 = action_space.high[1] self.IncrementalPID.kp = self.Kp_0 + action[0] self.IncrementalPID.ki = self.Ki_0 + action[1] # self.IncrementalPID.kp = self.rl_env.Kp_0 + action[0] # self.IncrementalPID.ki = self.rl_env.Ki_0 + action[1] self.log_message(f"Kp={self.IncrementalPID.kp:.4f}, Ki={self.IncrementalPID.ki:.4f}, Kd={self.IncrementalPID.kd:.4f}") self.IncrementalPID._calculate_coefficients() self.root.after(0, self._update_pid_ui, self.IncrementalPID.kp, self.IncrementalPID.ki, self.IncrementalPID.kd) # print(f"t4={time.perf_counter() - t1:.3f}") # 降低 UI 刷新频率:每 10 个控制周期 (0.05秒) 更新一次界面,防止 Tkinter 卡死 # if self.cycle_count % 10 == 0: # self.root.after(0, self._update_pid_ui, new_kp, new_ki) t5 = time.perf_counter() # --- E. 算新开度 --- # 如果输入了电机限幅值,则更新 IncrementalPID.motor_max # motor_max_str = self.motor_max_var.get().strip() # if motor_max_str: # self.IncrementalPID.du_max = float(motor_max_str) * self.IncrementalPID.dt if self.cached_motor_max is not None: self.IncrementalPID.du_max = self.cached_motor_max * self.IncrementalPID.dt else: self.IncrementalPID.get_du_max(target_pressure) self.IncrementalPID.update_pressure_values(current_pressure, target_pressure) valve_opening = self.IncrementalPID.update() xa = self.IncrementalPID.dead_area + (100 - valve_opening) * (750 - self.IncrementalPID.dead_area) / 100 # va = (750 - xa) / 750 * 100 # self.log_message(f"xa: {xa:.4f}") success = self.motor.set_position(xa) # print(f"t5-写开度用时:{time.perf_counter() - t1:.3f}") else: # 极端异常兜底:按理说有前置拦截不会走到这里 self.log_message("⚠️ 致命错误:控制线程中丢失模型实例!正在紧急停机。") self.root.after(0, self.stop_control) # valve_opening = 0.0 # 输出安全阀位 elif current_mode == "MANUAL": # valve_opening = float(self.valve_entry.get()) xa = self.IncrementalPID.dead_area + (100 - set_valve) * (750 - self.IncrementalPID.dead_area) / 100 success = self.motor.set_position(xa) else: # valve_opening = 0.0 self.log_message("错误!") # ======================================================== # ... [此处是原有的获取 valve_opening 并写入 PLC 的代码] ... # 写入阀门开度到PLC # success = self.modbus_client.write_float(valve_addr, float(valve_opening)) # success = self.motor.set_position(valve_opening) # ======================================================== # 新增:训练数据收集逻辑 # ======================================================== t6 = time.perf_counter() # if self.collect_data_var.get(): # vol_str = self.volume_var.get() # flow_str = self.flow_var.get() if self.cached_collect_data: Q_in = self.cached_flow V = self.cached_volume # Q_in = float(flow_str) if flow_str else 0.0 # 根据你的设定转换为标准单位 # # Q_in = float(flow_str) * 1000 if flow_str else 0.0 # 根据你的设定转换为标准单位 # V = float(vol_str) if vol_str else 0.0 # 检查是否需要开启新的 Episode(目标改变或刚启动) if self.current_episode is None or target_pressure != self.last_target_record: if self.current_episode is not None: self.episode_data_raw.append(self.current_episode) self.log_message( f"Episode 结束,已记录 {len(self.current_episode['pressures'])} 个点") self.current_episode = { 'pid': [float(self.IncrementalPID.kp), float(self.IncrementalPID.ki), float(self.IncrementalPID.kd)], 'target_pressure': target_pressure, 'Q_in': Q_in, 'V': V, 'steps': [], 'pressures': [], 'errors': [], 'valves': [] } self.last_target_record = target_pressure self.steady_count = 0 # 记录当前步的数据 error = -(target_pressure - current_pressure) self.current_episode['steps'].append(self.cycle_count) self.current_episode['pressures'].append(current_pressure) self.current_episode['errors'].append(error) self.current_episode['valves'].append(float(valve_opening)) # print(f"t6-记录数据用时:{time.perf_counter() - t1:.3f}") # 可选:判断稳态提前结束 Episode(类似 DataCollector.py 的逻辑) # if 0 <= target_pressure - current_pressure <= 1: # self.steady_count += 1 # else: # self.steady_count = 0 # if self.steady_count > 40: # 假设 40 个 step 为稳态 # ... # ======================================================== # 更新UI并记录数据 self.pressure_data.append(current_pressure) self.target_data.append(target_pressure) self.valve_data.append(valve_opening) # 🌟 2. 把算出来的最新数据写到小黑板上,然后就可以拍拍屁股走人了 self.latest_display_data = (current_pressure, target_pressure, valve_opening) # self.root.after(0, self.update_display, current_pressure, target_pressure, valve_opening) self.cycle_count += 1 # print(f"t6-记录数据用时:{time.perf_counter() - t1:.3f}") else: self.log_message("读取当前压力失败,检查地址和连接") time.sleep(self.IncrementalPID.dt) # 读取失败时短暂等待 except Exception as e: self.log_message(f"控制循环错误: {str(e)}") import traceback self.log_message(f"详细错误: {traceback.format_exc()}") time.sleep(self.IncrementalPID.dt) # 控制周期时间补偿 (保持控制频率恒定) # print(f"cycle time:{time.perf_counter() - cycle_start}") elapsed_time = time.perf_counter() - cycle_start sleep_time = max(0.001, self.IncrementalPID.dt - elapsed_time) # 确保最小等待1ms time.sleep(sleep_time) total_time = time.perf_counter() - cycle_start print(f"total time:{total_time}") if total_time > 0.11: print(f"=================================超时!本循环用时{total_time}") # self.log_message(f"超时!本循环用时{total_time}") print("\n") def update_display(self, current_pressure, target_pressure, valve_opening): """更新显示并记录数据 (带滚动窗口限制)""" self.current_pressure_var.set(f"{current_pressure:.1f} kPa") self.target_pressure_var.set(f"{target_pressure:.1f} kPa") self.valve_opening_var.set(f"{valve_opening:.1f} %") # 1. 记录数据用于绘图 current_time = time.time() if self.start_time is not None: elapsed_time = current_time - self.start_time else: self.start_time = current_time elapsed_time = 0 self.time_data.append(elapsed_time) self.pressure_data.append(current_pressure) self.target_data.append(target_pressure) self.valve_data.append(valve_opening) # ======================================================== # 2. 新增:限制绘图数据的最大长度(只保留最近10分钟) # 控制周期 0.05s,10分钟 = 600秒 = 12000个控制周期 # ======================================================== # max_points = 12000 # if len(self.time_data) > max_points: # print(1111) # # 列表切片,丢弃最前面的老点,只保留最后 12000 个新点 # self.time_data = self.time_data[-max_points:] # self.pressure_data = self.pressure_data[-max_points:] # self.target_data = self.target_data[-max_points:] # self.valve_data = self.valve_data[-max_points:] def plot_control_data(self): """绘制控制数据曲线 - 修复白屏问题""" if not self.pressure_data: self.log_message("没有可绘制的数据") return # 防止重复点击 if self.is_plotting: return self.is_plotting = True self.plot_btn.config(state=tk.DISABLED) self.log_message("正在生成图表...") # 在新线程中创建绘图窗口 plot_thread = threading.Thread(target=self._create_plot_window, daemon=True) plot_thread.start() def _create_plot_window(self): """在新线程中创建绘图窗口""" try: # 确保matplotlib使用正确的设置 matplotlib.use('TkAgg') # plt.rcParams['font.sans-serif'] = ['SimHei'] # plt.rcParams['axes.unicode_minus'] = False # 在主线程中创建窗口 self.root.after(0, self._safe_create_plot_window) except Exception as e: self.root.after(0, self._plot_error, str(e)) def _safe_create_plot_window(self): """安全创建绘图窗口(在主线程中执行)""" try: # 创建新的Toplevel窗口 plot_window = tk.Toplevel(self.root) plot_window.title("控制数据曲线图") plot_window.geometry("1100x800") # 添加加载提示 loading_label = ttk.Label(plot_window, text="正在加载图表...", font=("Arial", 12)) loading_label.pack(pady=20) plot_window.update() # 创建主框架 main_frame = ttk.Frame(plot_window) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # 创建控制面板 control_frame = ttk.Frame(main_frame) control_frame.pack(fill=tk.X, pady=(0, 10)) # X轴范围控制 ttk.Label(control_frame, text="时间轴范围 (秒):").pack(side=tk.LEFT, padx=(0, 5)) self.x_min_var = tk.StringVar(value="0") x_min_entry = ttk.Entry(control_frame, textvariable=self.x_min_var, width=10) x_min_entry.pack(side=tk.LEFT, padx=5) ttk.Label(control_frame, text="到").pack(side=tk.LEFT, padx=5) if self.time_data: self.x_max_var = tk.StringVar(value=f"{max(self.time_data):.1f}") else: self.x_max_var = tk.StringVar(value="10") x_max_entry = ttk.Entry(control_frame, textvariable=self.x_max_var, width=10) x_max_entry.pack(side=tk.LEFT, padx=5) ttk.Button(control_frame, text="应用", command=lambda: self._apply_x_limits()).pack(side=tk.LEFT, padx=10) ttk.Button(control_frame, text="重置", command=lambda: self._reset_view()).pack(side=tk.LEFT, padx=5) ttk.Button(control_frame, text="全部", command=lambda: self._show_all_data()).pack(side=tk.LEFT, padx=5) ttk.Button(control_frame, text="最后30秒", command=lambda: self._zoom_last_n_seconds(30)).pack(side=tk.LEFT, padx=5) # 创建图形 fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 7), dpi=100) # 确保有足够的数据 if not self.time_data or len(self.time_data) < 2: loading_label.config(text="数据不足,无法绘制图表") self._reenable_plot_button() return # 压力曲线 ax1.plot(self.time_data, self.pressure_data, 'b-o', linewidth=1.5, markersize=3, alpha=0.8, label='实际压力') ax1.plot(self.time_data, self.target_data, 'r--', linewidth=1.5, alpha=0.8, label='目标压力') ax1.set_ylabel('压力 (kPa)', fontsize=12) ax1.set_title('压力控制性能', fontsize=14, fontweight='bold') ax1.legend(loc='upper right', fontsize=10) ax1.grid(True, alpha=0.3) # 阀门开度曲线 ax2.plot(self.time_data, self.valve_data, 'm-o', linewidth=1.5, markersize=3, alpha=0.8, label='实际阀门指令') ax2.set_xlabel('时间 (秒)', fontsize=12) ax2.set_ylabel('阀门开度 (%)', fontsize=12) ax2.legend(loc='upper right', fontsize=10) ax2.set_ylim([0, 105]) ax2.grid(True, alpha=0.3) # 共享X轴 ax2.sharex(ax1) plt.tight_layout() # 移除加载提示 loading_label.destroy() # 创建画布 canvas_frame = ttk.Frame(main_frame) canvas_frame.pack(fill=tk.BOTH, expand=True) canvas = FigureCanvasTkAgg(fig, master=canvas_frame) canvas.draw() # 添加导航工具栏 toolbar_frame = ttk.Frame(canvas_frame) toolbar_frame.pack(fill=tk.X, pady=(0, 5)) toolbar = NavigationToolbar2Tk(canvas, toolbar_frame) toolbar.update() # 将画布放置到窗口中 canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True) # 添加提示标签 hint_label = ttk.Label(canvas_frame, text="提示: 使用工具栏缩放/平移 | 拖动矩形区域可局部放大", font=("Arial", 9), foreground="gray") hint_label.pack(side=tk.BOTTOM, pady=(5, 0)) # 存储图表对象 self.current_fig = fig self.current_ax1 = ax1 self.current_ax2 = ax2 self.current_canvas = canvas # 配置窗口关闭事件 def on_closing(): try: plt.close(fig) plot_window.destroy() self.current_fig = None self.current_ax1 = None self.current_ax2 = None self.current_canvas = None except: pass finally: self.is_plotting = False self._reenable_plot_button() plot_window.protocol("WM_DELETE_WINDOW", on_closing) # 确保窗口正确显示 plot_window.update() plot_window.deiconify() self.log_message("图表已生成") except Exception as e: self.log_message(f"创建图表时出错: {str(e)}") import traceback traceback.print_exc() finally: self.is_plotting = False self._reenable_plot_button() def _apply_x_limits(self): """应用X轴范围限制""" if not self.current_canvas or not self.current_ax1: return try: x_min = float(self.x_min_var.get()) x_max = float(self.x_max_var.get()) if x_min >= x_max: return self.current_ax1.set_xlim([x_min, x_max]) self.current_ax2.set_xlim([x_min, x_max]) self.current_canvas.draw() except ValueError: pass def _reset_view(self): """重置视图""" if not self.current_canvas or not self.current_ax1 or not self.time_data: return x_min = min(self.time_data) x_max = max(self.time_data) x_range = x_max - x_min margin = x_range * 0.05 if x_range > 0 else 0.1 self.current_ax1.set_xlim([x_min - margin, x_max + margin]) self.current_ax2.set_xlim([x_min - margin, x_max + margin]) self.x_min_var.set(f"{x_min - margin:.1f}") self.x_max_var.set(f"{x_max + margin:.1f}") self.current_canvas.draw() def _show_all_data(self): """显示所有数据""" if not self.current_canvas or not self.current_ax1 or not self.time_data: return x_min = min(self.time_data) x_max = max(self.time_data) self.current_ax1.set_xlim([x_min, x_max]) self.current_ax2.set_xlim([x_min, x_max]) self.x_min_var.set(f"{x_min:.1f}") self.x_max_var.set(f"{x_max:.1f}") self.current_canvas.draw() def _zoom_last_n_seconds(self, n_seconds): """缩放到最后N秒的数据""" if not self.current_canvas or not self.current_ax1 or not self.time_data: return x_max = max(self.time_data) x_min = max(0, x_max - n_seconds) self.current_ax1.set_xlim([x_min, x_max]) self.current_ax2.set_xlim([x_min, x_max]) self.x_min_var.set(f"{x_min:.1f}") self.x_max_var.set(f"{x_max:.1f}") self.current_canvas.draw() def _plot_error(self, error_msg): """处理绘图错误""" self.log_message(f"绘图错误: {error_msg}") self._reenable_plot_button() def _reenable_plot_button(self): """重新启用绘制按钮""" if self.plot_btn and self.plot_btn.winfo_exists(): self.plot_btn.config(state=tk.NORMAL)