新增可配置开环扫点、多 CSV 前馈表拟合及结果文件生成;补充闭环停止、手动开度安全互锁和 PID 未启用状态显示,并同步更新自动化测试、使用文档、打包配置与 Windows 可执行程序。
1109 lines
42 KiB
Python
1109 lines
42 KiB
Python
"""气体流量控制桌面程序。"""
|
||
|
||
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())
|