Files
flow_control/gui_main.py
louis c1a4a2a229 完善流量控制 GUI 的开环扫点与手动操作流程
新增可配置开环扫点、多 CSV 前馈表拟合及结果文件生成;补充闭环停止、手动开度安全互锁和 PID 未启用状态显示,并同步更新自动化测试、使用文档、打包配置与 Windows 可执行程序。
2026-09-04 16:47:16 +08:00

1109 lines
42 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""气体流量控制桌面程序。"""
from __future__ import annotations
import argparse
import ctypes
from datetime import datetime
from pathlib import Path
from queue import Empty, Queue
import sys
from tempfile import TemporaryDirectory
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
import config
from control_runtime import (
BUILTIN_MODEL_NAME,
ControlWorker,
OpenLoopSettings,
application_directory,
build_open_loop_points,
build_runtime_logger,
load_model,
validate_device_settings,
validate_opening,
validate_open_loop_settings,
validate_target,
)
from identify_valve import DEFAULT_TAIL, create_diagnostic_plot, identify
WINDOW_TITLE = "气体流量控制"
class OpenLoopDialog:
"""开环扫点参数弹窗。"""
def __init__(self, parent, on_start):
self.parent = parent
self.on_start = on_start
defaults = OpenLoopSettings()
self.window = tk.Toplevel(parent)
self.window.title("开环扫点参数")
self.window.transient(parent)
self.window.resizable(False, False)
self.window.protocol("WM_DELETE_WINDOW", self._cancel)
self.values = {
"min_opening_pct": tk.StringVar(value=f"{defaults.min_opening_pct:g}"),
"max_opening_pct": tk.StringVar(value=f"{defaults.max_opening_pct:g}"),
"opening_step_pct": tk.StringVar(value=f"{defaults.opening_step_pct:g}"),
"random_seed": tk.StringVar(value=str(defaults.random_seed or "")),
"sample_period_s": tk.StringVar(value=f"{defaults.sample_period_s:g}"),
"steady_window_s": tk.StringVar(value=f"{defaults.steady_window_s:g}"),
"steady_std_pct": tk.StringVar(value=f"{defaults.steady_std_pct:g}"),
"max_wait_s": tk.StringVar(value=f"{defaults.max_wait_s:g}"),
"min_abs_std_slm": tk.StringVar(value=f"{defaults.min_abs_std_slm:g}"),
"fine_stroke_start": tk.StringVar(value=f"{defaults.fine_stroke_start:g}"),
"fine_stroke_step": tk.StringVar(value=f"{defaults.fine_stroke_step:g}"),
}
self.include_fine_scan = tk.BooleanVar(value=defaults.include_fine_scan)
self.summary_var = tk.StringVar()
self.fine_entries = []
self._build_ui()
for variable in self.values.values():
variable.trace_add("write", self._refresh_summary)
self.include_fine_scan.trace_add("write", self._fine_scan_changed)
self._fine_scan_changed()
self.window.update_idletasks()
x = parent.winfo_rootx() + (parent.winfo_width() - self.window.winfo_width()) // 2
y = parent.winfo_rooty() + (parent.winfo_height() - self.window.winfo_height()) // 2
self.window.geometry(f"+{max(0, x)}+{max(0, y)}")
self.window.grab_set()
self.window.focus_set()
def _build_ui(self):
outer = ttk.Frame(self.window, padding=14)
outer.pack(fill="both", expand=True)
ttk.Label(
outer,
text="开环扫点会停止闭环,并直接按扫描序列下发阀门开度。",
foreground="#8a4b00",
wraplength=560,
).pack(anchor="w", pady=(0, 9))
basic = ttk.LabelFrame(outer, text="扫描范围", padding=9)
basic.pack(fill="x")
self._add_field(basic, 0, 0, "起始开度", "min_opening_pct", "%")
self._add_field(basic, 0, 3, "结束开度", "max_opening_pct", "%")
self._add_field(basic, 1, 0, "开度间隔", "opening_step_pct", "%")
self._add_field(basic, 1, 3, "随机种子", "random_seed", "(留空=随机)")
steady = ttk.LabelFrame(outer, text="采样与稳态判定", padding=9)
steady.pack(fill="x", pady=(9, 0))
self._add_field(steady, 0, 0, "采样周期", "sample_period_s", "s")
self._add_field(steady, 0, 3, "稳态窗口", "steady_window_s", "s")
self._add_field(steady, 1, 0, "相对标准差", "steady_std_pct", "%")
self._add_field(steady, 1, 3, "绝对标准差下限", "min_abs_std_slm", "SLM")
self._add_field(steady, 2, 0, "单点最长等待", "max_wait_s", "s")
fine = ttk.LabelFrame(outer, text="闭阀端精细扫描", padding=9)
fine.pack(fill="x", pady=(9, 0))
ttk.Checkbutton(
fine,
text="追加精细行程点",
variable=self.include_fine_scan,
).grid(row=0, column=0, columnspan=3, sticky="w")
start_entry = self._add_field(
fine, 1, 0, "起始行程", "fine_stroke_start", "(终点 1000"
)
step_entry = self._add_field(
fine, 1, 3, "行程间隔", "fine_stroke_step", ""
)
self.fine_entries.extend((start_entry, step_entry))
summary = tk.Label(
outer,
textvariable=self.summary_var,
bg="#edf5ff",
fg="#174f85",
anchor="w",
justify="left",
padx=9,
pady=7,
)
summary.pack(fill="x", pady=(9, 0))
output_dir = application_directory() / "open_loop_data"
ttk.Label(
outer,
text=f"输出目录:{output_dir}(每次生成 CSV,存在采样时同时生成 PNG)",
foreground="#5f6368",
wraplength=560,
).pack(anchor="w", pady=(7, 0))
buttons = ttk.Frame(outer)
buttons.pack(fill="x", pady=(12, 0))
ttk.Button(buttons, text="取消", command=self._cancel, width=12).pack(
side="right"
)
ttk.Button(
buttons,
text="开始扫点",
command=self._start,
style="Primary.TButton",
width=14,
).pack(side="right", padx=(0, 8))
def _add_field(self, parent, row, column, label, key, unit):
ttk.Label(parent, text=label).grid(
row=row, column=column, sticky="w", pady=(5 if row else 0, 0)
)
entry = ttk.Entry(parent, textvariable=self.values[key], width=10)
entry.grid(
row=row,
column=column + 1,
sticky="w",
padx=(6, 4),
pady=(5 if row else 0, 0),
)
ttk.Label(parent, text=unit).grid(
row=row,
column=column + 2,
sticky="w",
padx=(0, 16),
pady=(5 if row else 0, 0),
)
return entry
def _settings(self):
return validate_open_loop_settings(
**{key: variable.get() for key, variable in self.values.items()},
include_fine_scan=self.include_fine_scan.get(),
)
def _refresh_summary(self, *_args):
try:
settings = self._settings()
point_count = len(build_open_loop_points(settings))
maximum_minutes = point_count * settings.max_wait_s / 60.0
self.summary_var.set(
f"共 {point_count} 个扫描点;按单点最长等待估算,上限约 "
f"{maximum_minutes:.1f} 分钟(稳定后会提前切换)。"
)
except ValueError as exc:
self.summary_var.set(f"参数待修正:{exc}")
def _fine_scan_changed(self, *_args):
state = "normal" if self.include_fine_scan.get() else "disabled"
for entry in self.fine_entries:
entry.configure(state=state)
self._refresh_summary()
def _start(self):
try:
settings = self._settings()
except ValueError as exc:
messagebox.showerror("扫点参数无效", str(exc), parent=self.window)
return
if self.on_start(settings):
self.window.grab_release()
self.window.destroy()
def _cancel(self):
try:
self.window.grab_release()
except tk.TclError:
pass
self.window.destroy()
class FlowControlApp:
"""单窗口操作界面。"""
STATE_TEXT = {
"DISCONNECTED": "未连接",
"CONNECTING": "正在连接",
"MONITORING": "已连接 / 监测中",
"CONTROLLING": "闭环运行中",
"OPEN_LOOP": "开环扫点中",
"FINALIZING": "正在生成结果",
"DISCONNECTING": "正在断开",
"STOPPING": "正在退出",
"FAULT": "故障 / 已断开",
}
STATE_COLORS = {
"DISCONNECTED": ("#5f6368", "#eef0f2"),
"CONNECTING": ("#875a00", "#fff3cd"),
"MONITORING": ("#0b6b3a", "#dff4e8"),
"CONTROLLING": ("#075cad", "#deefff"),
"OPEN_LOOP": ("#7a3e00", "#ffe9cc"),
"FINALIZING": ("#875a00", "#fff3cd"),
"DISCONNECTING": ("#875a00", "#fff3cd"),
"STOPPING": ("#875a00", "#fff3cd"),
"FAULT": ("#a61b1b", "#fde5e5"),
}
def __init__(self, root):
self.root = root
self.root.title(WINDOW_TITLE)
self.root.geometry("860x760")
self.root.minsize(780, 700)
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
self.logger = build_runtime_logger()
self.worker = ControlWorker(self.logger)
self.current_state = "DISCONNECTED"
self.closing = False
self.selected_model_path = None
self.model_info = load_model()
self.fit_events = Queue()
self.fit_in_progress = False
self.host_var = tk.StringVar(value=config.PLC_HOST)
self.port_var = tk.StringVar(value=str(config.PLC_PORT))
self.slave_var = tk.StringVar(value=str(config.PLC_SLAVE_ID))
self.model_var = tk.StringVar(value=self.model_info.display_name)
self.target_var = tk.StringVar(value="")
self.manual_opening_var = tk.StringVar(value="100")
self.active_target_var = tk.StringVar(value="--")
self.status_var = tk.StringVar(value="未连接")
self.detail_var = tk.StringVar(value="请选择模型并连接设备")
self.value_vars = {
"flow": tk.StringVar(value="--"),
"pressure_before": tk.StringVar(value="--"),
"pressure_after": tk.StringVar(value="--"),
"opening": tk.StringVar(value="--"),
"motor": tk.StringVar(value="--"),
"feedforward": tk.StringVar(value="--"),
"correction": tk.StringVar(value="--"),
"mode": tk.StringVar(value="--"),
}
self._configure_style()
self._build_ui()
self._apply_state("DISCONNECTED", "未连接")
self._append_log(
f"已加载 {self.model_info.display_name};双击程序不会自动连接设备"
)
self.worker.start()
self.root.after(80, self._poll_worker_events)
def _configure_style(self):
self.root.option_add("*Font", ("Microsoft YaHei UI", 10))
style = ttk.Style(self.root)
try:
style.theme_use("vista")
except tk.TclError:
pass
style.configure("Title.TLabel", font=("Microsoft YaHei UI", 20, "bold"))
style.configure("Section.TLabelframe.Label", font=("Microsoft YaHei UI", 11, "bold"))
style.configure("Primary.TButton", font=("Microsoft YaHei UI", 10, "bold"))
style.configure("Danger.TButton", font=("Microsoft YaHei UI", 10, "bold"))
def _build_ui(self):
outer = ttk.Frame(self.root, padding=(18, 14, 18, 14))
outer.pack(fill="both", expand=True)
ttk.Label(outer, text=WINDOW_TITLE, style="Title.TLabel").pack(anchor="w")
ttk.Label(
outer,
text="Easy521 PLC · Modbus TCP · 10 Hz 闭环控制",
foreground="#5f6368",
).pack(anchor="w", pady=(0, 10))
warning = tk.Label(
outer,
text=(
"安全提示:连接成功后及断开设备前,程序会命令阀门至 100% 开度"
f"(行程 {config.MOTOR_OPEN_POSITION:g})。"
),
bg="#fff4e5",
fg="#8a4b00",
padx=10,
pady=7,
anchor="w",
)
warning.pack(fill="x", pady=(0, 10))
connection = ttk.LabelFrame(
outer,
text="设备与模型",
padding=10,
style="Section.TLabelframe",
)
connection.pack(fill="x")
connection.columnconfigure(1, weight=1)
connection.columnconfigure(3, weight=1)
ttk.Label(connection, text="PLC 地址").grid(row=0, column=0, sticky="w")
self.host_entry = ttk.Entry(connection, textvariable=self.host_var, width=20)
self.host_entry.grid(row=0, column=1, sticky="ew", padx=(7, 14))
ttk.Label(connection, text="端口").grid(row=0, column=2, sticky="w")
self.port_entry = ttk.Entry(connection, textvariable=self.port_var, width=9)
self.port_entry.grid(row=0, column=3, sticky="w", padx=(7, 14))
ttk.Label(connection, text="站号").grid(row=0, column=4, sticky="w")
self.slave_entry = ttk.Entry(connection, textvariable=self.slave_var, width=7)
self.slave_entry.grid(row=0, column=5, sticky="w", padx=(7, 0))
ttk.Label(connection, text="A_eff 模型").grid(
row=1, column=0, sticky="w", pady=(10, 0)
)
self.model_label = ttk.Label(
connection,
textvariable=self.model_var,
foreground="#174f85",
)
self.model_label.grid(
row=1, column=1, columnspan=2, sticky="w", padx=(7, 10), pady=(10, 0)
)
self.open_loop_button = ttk.Button(
connection, text="开环扫点", command=self._open_open_loop_dialog
)
self.open_loop_button.grid(row=1, column=3, pady=(10, 0), padx=(0, 7))
self.model_browse_button = ttk.Button(
connection, text="选择 JSON…", command=self._choose_model
)
self.model_browse_button.grid(row=1, column=4, pady=(10, 0), padx=(0, 7))
self.model_builtin_button = ttk.Button(
connection, text="使用内置", command=self._use_builtin_model
)
self.model_builtin_button.grid(row=1, column=5, pady=(10, 0))
button_row = ttk.Frame(connection)
button_row.grid(row=2, column=0, columnspan=6, sticky="ew", pady=(12, 0))
self.connect_button = ttk.Button(
button_row,
text="连接设备",
command=self._connect,
style="Primary.TButton",
width=16,
)
self.connect_button.pack(side="left")
self.disconnect_button = ttk.Button(
button_row,
text="断开设备",
command=self._disconnect,
style="Danger.TButton",
width=16,
)
self.disconnect_button.pack(side="left", padx=(10, 0))
self.fit_model_button = ttk.Button(
button_row,
text="拟合前馈表",
command=self._fit_feedforward_model,
width=16,
)
self.fit_model_button.pack(side="right")
target_frame = ttk.LabelFrame(
outer,
text="目标流量",
padding=10,
style="Section.TLabelframe",
)
target_frame.pack(fill="x", pady=(10, 0))
target_frame.columnconfigure(1, weight=1)
ttk.Label(target_frame, text="目标值").grid(row=0, column=0, sticky="w")
target_input = ttk.Frame(target_frame)
target_input.grid(row=0, column=1, sticky="w", padx=(8, 5))
self.target_entry = ttk.Entry(
target_input,
textvariable=self.target_var,
width=9,
font=("Microsoft YaHei UI", 12),
)
self.target_entry.pack(side="left")
self.target_entry.bind("<Return>", lambda _event: self._set_target())
ttk.Label(target_input, text="SLM").pack(side="left", padx=(5, 0))
self.target_button = ttk.Button(
target_frame,
text="设置目标流量",
command=self._set_target,
style="Primary.TButton",
width=15,
)
self.target_button.grid(row=0, column=2, padx=(12, 0))
self.stop_control_button = ttk.Button(
target_frame,
text="停止流量控制",
command=self._stop_control,
width=15,
)
self.stop_control_button.grid(row=0, column=3, padx=(8, 0))
ttk.Label(target_frame, text="当前目标:").grid(
row=0, column=4, padx=(18, 0)
)
ttk.Label(
target_frame,
textvariable=self.active_target_var,
font=("Microsoft YaHei UI", 11, "bold"),
foreground="#075cad",
).grid(row=0, column=5, sticky="w")
ttk.Label(
target_frame,
text="手动开度",
).grid(row=1, column=0, sticky="w", pady=(9, 0))
manual_opening_input = ttk.Frame(target_frame)
manual_opening_input.grid(
row=1, column=1, sticky="w", padx=(8, 5), pady=(9, 0)
)
self.manual_opening_entry = ttk.Entry(
manual_opening_input,
textvariable=self.manual_opening_var,
width=9,
font=("Microsoft YaHei UI", 12),
)
self.manual_opening_entry.pack(side="left")
self.manual_opening_entry.bind(
"<Return>", lambda _event: self._set_manual_opening()
)
ttk.Label(manual_opening_input, text="%").pack(side="left", padx=(5, 0))
self.manual_opening_button = ttk.Button(
target_frame,
text="设置开度",
command=self._set_manual_opening,
width=15,
)
self.manual_opening_button.grid(
row=1, column=2, padx=(12, 0), pady=(9, 0)
)
ttk.Label(
target_frame,
text="仅在闭环和扫点均停止后生效",
foreground="#5f6368",
).grid(row=1, column=4, columnspan=2, sticky="w", padx=(24, 0), pady=(9, 0))
ttk.Label(
target_frame,
text=(
f"允许范围 {config.TARGET_FLOW_MIN_SLM:g}"
f"{config.TARGET_FLOW_MAX_SLM:g} SLM;首次设置即启动闭环"
),
foreground="#5f6368",
).grid(row=2, column=0, columnspan=6, sticky="w", pady=(7, 0))
telemetry = ttk.LabelFrame(
outer,
text="实时监视",
padding=10,
style="Section.TLabelframe",
)
telemetry.pack(fill="x", pady=(10, 0))
for column in range(4):
telemetry.columnconfigure(column, weight=1, uniform="card")
self._add_value_card(telemetry, 0, 0, "当前流量", "flow", "SLM")
self._add_value_card(telemetry, 0, 1, "阀前压力", "pressure_before", "kPa")
self._add_value_card(telemetry, 0, 2, "阀后压力", "pressure_after", "kPa")
self._add_value_card(
telemetry, 0, 3, "下发总开度", "opening", "%", prominent=True
)
self._add_value_card(telemetry, 1, 0, "电机行程", "motor", "")
self._add_value_card(telemetry, 1, 1, "前馈开度", "feedforward", "%")
self._add_value_card(telemetry, 1, 2, "PI 修正", "correction", "%")
self._add_value_card(telemetry, 1, 3, "PID 模式", "mode", "")
status_frame = ttk.LabelFrame(
outer,
text="运行状态",
padding=10,
style="Section.TLabelframe",
)
status_frame.pack(fill="both", expand=True, pady=(10, 0))
status_header = ttk.Frame(status_frame)
status_header.pack(fill="x")
self.status_badge = tk.Label(
status_header,
textvariable=self.status_var,
padx=10,
pady=4,
font=("Microsoft YaHei UI", 10, "bold"),
)
self.status_badge.pack(side="left")
ttk.Label(status_header, textvariable=self.detail_var).pack(
side="left", padx=(10, 0)
)
self.log_text = tk.Text(
status_frame,
height=3,
wrap="word",
state="disabled",
bg="#fafafa",
relief="solid",
borderwidth=1,
font=("Microsoft YaHei UI", 9),
)
self.log_text.pack(fill="both", expand=True, pady=(8, 0))
def _add_value_card(self, parent, row, column, title, key, unit, prominent=False):
frame = tk.Frame(
parent,
bg="#edf5ff" if prominent else "#f6f7f8",
highlightbackground="#b8c6d5" if prominent else "#d9dde1",
highlightthickness=1,
padx=10,
pady=8,
)
frame.grid(
row=row,
column=column,
sticky="nsew",
padx=(0 if column == 0 else 5, 0),
pady=(0 if row == 0 else 7, 0),
)
tk.Label(
frame,
text=title,
bg=frame["bg"],
fg="#4c545c",
font=("Microsoft YaHei UI", 9),
).pack(anchor="w")
value_row = tk.Frame(frame, bg=frame["bg"])
value_row.pack(anchor="w", pady=(3, 0))
tk.Label(
value_row,
textvariable=self.value_vars[key],
bg=frame["bg"],
fg="#075cad" if prominent else "#202124",
font=("Microsoft YaHei UI", 16 if prominent else 14, "bold"),
).pack(side="left")
if unit:
tk.Label(
value_row,
text=f" {unit}",
bg=frame["bg"],
fg="#5f6368",
font=("Microsoft YaHei UI", 9),
).pack(side="left", anchor="s", pady=(0, 2))
def _connect(self):
try:
settings = validate_device_settings(
self.host_var.get(), self.port_var.get(), self.slave_var.get()
)
# 在主线程先校验一次,避免无效模型进入连接流程。
self.model_info = load_model(self.selected_model_path)
except ValueError as exc:
messagebox.showerror("无法连接", str(exc), parent=self.root)
return
self._apply_state("CONNECTING", f"正在连接 {settings.host}:{settings.port}…")
self._append_log(f"请求连接 {settings.host}:{settings.port},站号 {settings.slave_id}")
self.worker.request_connect(settings, self.selected_model_path)
def _disconnect(self):
self._apply_state("DISCONNECTING", "正在安全断开设备…")
self._append_log("请求停止控制并安全断开设备")
self.worker.request_disconnect()
def _set_target(self):
try:
value = validate_target(self.target_var.get())
except ValueError as exc:
messagebox.showerror("目标流量无效", str(exc), parent=self.root)
return
self.detail_var.set(f"正在设置目标流量 {value:g} SLM…")
self.worker.request_target(value)
def _stop_control(self):
if self.current_state != "CONTROLLING":
return
self.detail_var.set("正在停止流量控制并下发 100% 开度…")
self._append_log("请求停止流量控制并下发 100% 开度")
self.stop_control_button.configure(state="disabled")
self.worker.request_stop_control()
def _set_manual_opening(self):
if self.current_state == "CONTROLLING":
messagebox.showwarning(
"请先停止流量控制",
"闭环流量控制正在运行。请先点击“停止流量控制”,再设置手动开度。",
parent=self.root,
)
return
if self.current_state == "OPEN_LOOP":
messagebox.showwarning(
"请先停止开环扫点",
"开环扫点正在运行。请先点击“停止扫点”,再设置手动开度。",
parent=self.root,
)
return
if self.current_state != "MONITORING":
messagebox.showwarning(
"无法设置开度",
"请先连接设备,再设置手动开度。",
parent=self.root,
)
return
try:
opening = validate_opening(self.manual_opening_var.get())
except ValueError as exc:
messagebox.showerror("手动开度无效", str(exc), parent=self.root)
return
self.detail_var.set(f"正在下发手动开度 {opening:g}%…")
self.worker.request_manual_opening(opening)
def _open_open_loop_dialog(self):
if self.current_state == "OPEN_LOOP":
self.detail_var.set("正在停止扫点并生成结果…")
self._append_log("请求提前停止开环扫点")
self.open_loop_button.configure(state="disabled")
self.worker.request_open_loop_stop()
return
if self.current_state not in {"MONITORING", "CONTROLLING"}:
messagebox.showwarning(
"无法开始扫点",
"请先连接设备并确认传感器数据正常。",
parent=self.root,
)
return
OpenLoopDialog(self.root, self._start_open_loop)
def _start_open_loop(self, settings):
if self.current_state not in {"MONITORING", "CONTROLLING"}:
messagebox.showwarning(
"无法开始扫点", "设备当前未处于可扫点状态。", parent=self.root
)
return False
if self.current_state == "CONTROLLING" and not messagebox.askyesno(
"切换到开环扫点",
"开始扫点将停止当前闭环控制,并按扫描序列直接下发阀门开度。是否继续?",
parent=self.root,
):
return False
point_count = len(build_open_loop_points(settings))
self.active_target_var.set("--")
self.detail_var.set(f"正在启动开环扫点,共 {point_count} 点…")
self._append_log(f"请求启动开环扫点,共 {point_count} 个扫描点")
self.worker.request_open_loop_start(settings)
return True
def _fit_feedforward_model(self):
if self.fit_in_progress:
return
initial_directory = application_directory() / "open_loop_data"
if not initial_directory.is_dir():
initial_directory = application_directory()
csv_paths = filedialog.askopenfilenames(
parent=self.root,
title="选择一份或多份开环扫点 CSV",
initialdir=str(initial_directory),
filetypes=(("开环扫点 CSV", "*.csv"), ("所有文件", "*.*")),
)
if not csv_paths:
return
default_name = datetime.now().strftime("valve_model_%Y%m%d_%H%M%S.json")
output_path = filedialog.asksaveasfilename(
parent=self.root,
title="保存拟合后的前馈表",
initialdir=str(Path(csv_paths[0]).resolve().parent),
initialfile=default_name,
defaultextension=".json",
filetypes=(("阀模型 JSON", "*.json"),),
confirmoverwrite=True,
)
if not output_path:
return
self.fit_in_progress = True
self.fit_model_button.configure(text="正在拟合…", state="disabled")
self._append_log(
f"开始合并拟合 {len(csv_paths)} 份开环 CSV;输出:{output_path}"
)
threading.Thread(
target=self._run_feedforward_fit,
args=(tuple(csv_paths), output_path),
name="feedforward-model-fit",
daemon=True,
).start()
def _run_feedforward_fit(self, csv_paths, output_path):
try:
model, steady, table, dropped, stats, area_points = identify(
csv_paths,
tail=DEFAULT_TAIL,
out_path=output_path,
)
image_path = str(Path(output_path).with_suffix(".png"))
plot_warning = None
try:
create_diagnostic_plot(
steady,
table,
dropped,
area_points,
image_path,
)
except Exception as exc:
image_path = None
plot_warning = str(exc)
self.logger.exception("前馈表诊断图生成失败")
self.fit_events.put(
{
"kind": "model_fit_finished",
"csv_count": len(csv_paths),
"output_path": str(Path(output_path).resolve()),
"image_path": image_path,
"point_count": len(model.area_table),
"steady_count": len(steady),
"dropped_count": len(dropped),
"stats": stats,
"plot_warning": plot_warning,
}
)
except Exception as exc:
self.logger.exception("拟合前馈表失败")
self.fit_events.put(
{
"kind": "model_fit_failed",
"message": str(exc),
}
)
def _choose_model(self):
path = filedialog.askopenfilename(
parent=self.root,
title="选择 A_eff 阀模型",
filetypes=(("JSON 阀模型", "*.json"), ("所有文件", "*.*")),
)
if not path:
return
try:
info = load_model(path)
except ValueError as exc:
messagebox.showerror("模型无效", str(exc), parent=self.root)
return
self.selected_model_path = str(info.path)
self.model_info = info
self.model_var.set(info.display_name)
monotonic_text = "单调" if info.is_monotonic else "非单调(按关闭端优先反解)"
self._append_log(f"已选择 {info.display_name}{monotonic_text}")
def _use_builtin_model(self):
try:
info = load_model()
except ValueError as exc:
messagebox.showerror("内置模型不可用", str(exc), parent=self.root)
return
self.selected_model_path = None
self.model_info = info
self.model_var.set(info.display_name)
self._append_log(f"已切换为 {info.display_name}")
def _poll_worker_events(self):
try:
while True:
event = self.worker.events.get_nowait()
self._handle_worker_event(event)
except Empty:
pass
try:
while True:
self._handle_fit_event(self.fit_events.get_nowait())
except Empty:
pass
if not self.closing:
self.root.after(80, self._poll_worker_events)
elif self.worker.is_alive():
self.root.after(80, self._poll_worker_events)
else:
self.root.destroy()
def _handle_worker_event(self, event):
kind = event["kind"]
if kind == "state":
state = event["state"]
message = event.get("message", "")
self._apply_state(state, message)
if state != "DISCONNECTED" or message != "未连接":
self._append_log(message)
elif kind == "telemetry":
self._update_telemetry(event)
elif kind == "target_applied":
target = event["target"]
self.active_target_var.set(f"{target:g} SLM")
self._append_log(f"目标流量已生效:{target:g} SLM")
elif kind == "control_stopped":
self.active_target_var.set("--")
self.value_vars["opening"].set(
self._format_number(event.get("opening_pct"), digits=2)
)
self.value_vars["motor"].set(
self._format_number(event.get("motor_position"), digits=1)
)
self.value_vars["feedforward"].set("--")
self.value_vars["correction"].set("--")
self.value_vars["mode"].set("未启用")
self._append_log("流量控制已停止;总开度已下发为 100%")
elif kind == "manual_opening_applied":
opening = event["opening_pct"]
self.manual_opening_var.set(f"{opening:g}")
self.value_vars["opening"].set(f"{opening:.2f}")
self.value_vars["motor"].set(f"{event['motor_position']:.1f}")
self.value_vars["feedforward"].set("--")
self.value_vars["correction"].set("--")
self.value_vars["mode"].set("未启用")
self._append_log(f"手动开度已生效:{opening:g}%")
elif kind == "model_active":
self._append_log(f"当前控制模型:{event['display_name']}")
elif kind == "open_loop_started":
self.active_target_var.set("--")
self._append_log(
f"开环扫点已启动,共 {event['total_steps']} 点;CSV"
f"{event['csv_path']}"
)
elif kind == "open_loop_progress":
self.detail_var.set(
f"扫点 {event['current_step']}/{event['total_steps']}"
f"开度 {event['opening_pct']:.2f}% / "
f"行程 {event['motor_position']:.1f}"
f"本点 {event['step_elapsed_s']:.1f} s"
)
elif kind == "open_loop_finished":
result = (
f"开环扫点{event['reason']},记录 {event['sample_count']} 条采样。\n"
f"CSV{event['csv_path']}"
)
if event.get("image_path"):
result += f"\nPNG{event['image_path']}"
self._append_log(result.replace("\n", ""))
if event.get("completed") and not self.closing:
messagebox.showinfo("开环扫点完成", result, parent=self.root)
elif kind == "warning":
message = event.get("message", "未知警告")
self.detail_var.set(message)
self._append_log("警告:" + message)
elif kind == "fault":
message = event.get("message", "未知故障")
self._apply_state("FAULT", message)
self._append_log("故障:" + message)
if not self.closing:
messagebox.showerror("设备或控制故障", message, parent=self.root)
elif kind == "shutdown_complete" and self.closing:
errors = event.get("errors") or []
if errors:
self._append_log("退出警告:" + "".join(errors))
def _handle_fit_event(self, event):
self.fit_in_progress = False
if hasattr(self, "fit_model_button"):
fit_enabled = self.current_state in {"DISCONNECTED", "FAULT", "MONITORING"}
self.fit_model_button.configure(
text="拟合前馈表",
state="normal" if fit_enabled and not self.closing else "disabled",
)
if event["kind"] == "model_fit_failed":
message = event.get("message", "未知错误")
self._append_log("拟合前馈表失败:" + message)
if not self.closing:
messagebox.showerror("拟合前馈表失败", message, parent=self.root)
return
message = (
f"已合并 {event['csv_count']} 份 CSV,读取 {event['steady_count']} 个稳态点,"
f"生成 {event['point_count']} 个 A_eff 标定点。\n"
f"JSON{event['output_path']}"
)
if event.get("image_path"):
message += f"\n诊断图:{event['image_path']}"
if event.get("dropped_count"):
message += f"\n非单调检查丢弃 {event['dropped_count']} 个点。"
if event.get("plot_warning"):
message += f"\n诊断图生成失败,但 JSON 已保留:{event['plot_warning']}"
self._append_log(message.replace("\n", ""))
if not self.closing:
messagebox.showinfo("拟合前馈表完成", message, parent=self.root)
def _update_telemetry(self, event):
self.value_vars["flow"].set(self._format_number(event.get("measured_flow_slm")))
self.value_vars["pressure_before"].set(
self._format_number(event.get("pressure_before_kpa"))
)
self.value_vars["pressure_after"].set(
self._format_number(event.get("pressure_kpa"))
)
self.value_vars["opening"].set(
self._format_number(event.get("opening_pct"), digits=2)
)
self.value_vars["motor"].set(
self._format_number(event.get("motor_position"), digits=1)
)
self.value_vars["feedforward"].set(
self._format_number(event.get("feedforward_pct"), digits=2)
)
correction = event.get("correction_pct")
self.value_vars["correction"].set(
"--" if correction is None else f"{float(correction):+.2f}"
)
self.value_vars["mode"].set(str(event.get("pid_mode") or "--"))
status = event.get("status", "")
if status and status not in {"OK", "MONITORING", "OPEN_LOOP"}:
self.detail_var.set(f"采样状态:{status}")
@staticmethod
def _format_number(value, digits=2):
if value is None:
return "--"
return f"{float(value):.{digits}f}"
def _apply_state(self, state, detail):
self.current_state = state
self.status_var.set(self.STATE_TEXT.get(state, state))
self.detail_var.set(detail)
foreground, background = self.STATE_COLORS.get(
state, ("#202124", "#eef0f2")
)
if hasattr(self, "status_badge"):
self.status_badge.configure(fg=foreground, bg=background)
editable = state in {"DISCONNECTED", "FAULT"}
connected = state in {"MONITORING", "CONTROLLING", "OPEN_LOOP"}
target_button_enabled = state in {"MONITORING", "CONTROLLING"}
target_entry_enabled = state in {"MONITORING", "CONTROLLING"}
stop_control_enabled = state == "CONTROLLING"
manual_button_enabled = state in {"MONITORING", "CONTROLLING", "OPEN_LOOP"}
manual_entry_enabled = state == "MONITORING"
open_loop_enabled = state in {"MONITORING", "CONTROLLING", "OPEN_LOOP"}
fit_enabled = state in {"DISCONNECTED", "FAULT", "MONITORING"}
self.open_loop_button.configure(
text="停止扫点" if state == "OPEN_LOOP" else "开环扫点"
)
self._set_state(self.host_entry, "normal" if editable else "disabled")
self._set_state(self.port_entry, "normal" if editable else "disabled")
self._set_state(self.slave_entry, "normal" if editable else "disabled")
self._set_state(
self.model_browse_button, "normal" if editable else "disabled"
)
self._set_state(
self.model_builtin_button, "normal" if editable else "disabled"
)
self._set_state(
self.open_loop_button, "normal" if open_loop_enabled else "disabled"
)
self._set_state(self.connect_button, "normal" if editable else "disabled")
self._set_state(
self.disconnect_button, "normal" if connected else "disabled"
)
self._set_state(
self.target_entry, "normal" if target_entry_enabled else "disabled"
)
self._set_state(
self.target_button, "normal" if target_button_enabled else "disabled"
)
self._set_state(
self.stop_control_button,
"normal" if stop_control_enabled else "disabled",
)
self._set_state(
self.manual_opening_entry,
"normal" if manual_entry_enabled else "disabled",
)
self._set_state(
self.manual_opening_button,
"normal" if manual_button_enabled else "disabled",
)
if hasattr(self, "fit_model_button"):
self.fit_model_button.configure(
state=(
"normal"
if fit_enabled and not self.fit_in_progress and not self.closing
else "disabled"
)
)
if state in {"DISCONNECTED", "FAULT"}:
self.active_target_var.set("--")
@staticmethod
def _set_state(widget, state):
widget.configure(state=state)
def _append_log(self, message):
timestamp = datetime.now().strftime("%H:%M:%S")
self.log_text.configure(state="normal")
self.log_text.insert("end", f"[{timestamp}] {message}\n")
self.log_text.see("end-1c")
self.log_text.configure(state="disabled")
def _on_close(self):
if self.closing:
return
if self.fit_in_progress:
messagebox.showinfo(
"正在拟合前馈表",
"请等待拟合完成后再关闭程序,以免输出文件不完整。",
parent=self.root,
)
return
self.closing = True
self._apply_state("STOPPING", "正在安全退出,请稍候…")
self._append_log("正在停止控制、恢复 100% 开度并关闭连接")
self.worker.request_shutdown()
def enable_dpi_awareness():
"""让 Windows 高 DPI 显示更清晰;失败时安全忽略。"""
if sys.platform != "win32":
return
try:
ctypes.windll.shcore.SetProcessDpiAwareness(1)
except (AttributeError, OSError):
try:
ctypes.windll.user32.SetProcessDPIAware()
except (AttributeError, OSError):
pass
def smoke_test():
"""供打包后自动检查资源和关键依赖,不连接设备。"""
config.validate_config()
info = load_model()
if info.point_count < 2 or info.path.name != BUILTIN_MODEL_NAME:
raise RuntimeError("内置阀模型校验失败")
from PcControl import Easy521ModbusClient # noqa: F401
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
with TemporaryDirectory(prefix="flow_control_smoke_") as temp_directory:
image_path = Path(temp_directory) / "matplotlib_smoke.png"
figure, axis = plt.subplots(figsize=(2, 1))
axis.plot([0, 1], [0, 1])
figure.savefig(image_path, dpi=50)
plt.close(figure)
if not image_path.is_file() or image_path.stat().st_size == 0:
raise RuntimeError("开环曲线图依赖校验失败")
return 0
def parse_args(argv=None):
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument(
"--smoke-test",
action="store_true",
help="仅检查内置资源和依赖,不打开界面、不连接设备",
)
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
if args.smoke_test:
return smoke_test()
enable_dpi_awareness()
root = tk.Tk()
try:
FlowControlApp(root)
except Exception as exc:
root.withdraw()
messagebox.showerror("程序启动失败", str(exc), parent=root)
root.destroy()
return 1
root.mainloop()
return 0
if __name__ == "__main__":
raise SystemExit(main())