新增气体流量控制 GUI 与 Windows 可执行程序

新增 Tkinter 单窗口操作界面和后台设备状态机,支持连接与断开 PLC、在线设置目标流量、实时显示传感器数据与下发总开度,并在监测及闭环阶段统一执行安全保护。

默认将 valve_model_004.json 内置到单文件 EXE,同时支持校验并选择外部阀模型;整理标定 JSON/PNG 到 valve_model 目录并同步配置路径。补充运行层离线测试、PyInstaller 构建配置、交付文件和完整的 README 使用及重新打包说明。
This commit is contained in:
2026-09-03 17:35:40 +08:00
parent d7ed4181df
commit ead625bf2d
51 changed files with 44578 additions and 24 deletions
+172
View File
@@ -0,0 +1,172 @@
import logging
from queue import Empty
import time
import config
from control_runtime import (
ControlWorker,
load_model,
validate_device_settings,
validate_target,
)
class FakeHardware:
def __init__(self, *, connect_ok=True):
self.connect_ok = connect_ok
self.connected = False
self.disconnect_count = 0
self.motor_commands = []
def connect(self):
self.connected = self.connect_ok
return self.connect_ok
def disconnect(self):
self.connected = False
self.disconnect_count += 1
def get_flow(self, _address):
return 20.0
def get_pressure(self, address):
if address == config.PRESSURE_BEFORE_ADDR:
return 200.0
return 100.0
def set_motor_position(self, position, channel=None):
self.motor_commands.append((float(position), channel))
return True
class OverpressureHardware(FakeHardware):
def get_pressure(self, address):
if address == config.PRESSURE_BEFORE_ADDR:
return config.MAX_PRESSURE_KPA + 1.0
return 100.0
def wait_for_event(worker, predicate, timeout=2.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
event = worker.events.get(timeout=0.1)
except Empty:
continue
if predicate(event):
return event
raise AssertionError("等待后台线程事件超时")
def make_logger():
logger = logging.getLogger(f"test-control-runtime-{time.monotonic_ns()}")
logger.addHandler(logging.NullHandler())
return logger
def test_validate_ui_values():
settings = validate_device_settings(" 192.168.1.88 ", "502", "1")
assert settings.host == "192.168.1.88"
assert settings.port == 502
assert settings.slave_id == 1
assert validate_target("50.5") == 50.5
def test_builtin_model_is_valve_model_004():
info = load_model()
assert info.is_builtin is True
assert info.path.name == "valve_model_004.json"
assert info.point_count == 80
assert info.model.motor_open == config.MOTOR_OPEN_POSITION
assert info.model.motor_closed == config.MOTOR_CLOSED_POSITION
def test_worker_connect_control_and_safe_disconnect():
hardware = FakeHardware()
worker = ControlWorker(make_logger(), hardware_factory=lambda _settings: hardware)
worker.start()
wait_for_event(
worker,
lambda event: event.get("kind") == "state"
and event.get("state") == "DISCONNECTED",
)
settings = validate_device_settings("192.168.1.88", 502, 1)
worker.request_connect(settings)
wait_for_event(
worker,
lambda event: event.get("kind") == "state"
and event.get("state") == "MONITORING",
)
assert hardware.connected is True
assert hardware.motor_commands[0] == (
config.MOTOR_OPEN_POSITION,
config.MOTOR_OUTPUT_ADDR,
)
worker.request_target(50.0)
wait_for_event(
worker,
lambda event: event.get("kind") == "target_applied",
)
telemetry = wait_for_event(
worker,
lambda event: event.get("kind") == "telemetry"
and event.get("target_flow_slm") == 50.0,
)
assert telemetry["measured_flow_slm"] == 20.0
assert 0.0 <= telemetry["opening_pct"] <= 100.0
assert telemetry["pid_mode"] in {"FAR", "NEAR"}
worker.request_disconnect()
wait_for_event(
worker,
lambda event: event.get("kind") == "state"
and event.get("state") == "DISCONNECTED",
)
assert hardware.connected is False
assert hardware.motor_commands[-1] == (
config.MOTOR_OPEN_POSITION,
config.MOTOR_OUTPUT_ADDR,
)
assert hardware.disconnect_count == 1
worker.request_shutdown()
wait_for_event(worker, lambda event: event.get("kind") == "shutdown_complete")
worker.thread.join(timeout=1.0)
assert not worker.is_alive()
def test_worker_connection_failure_reports_fault_without_hardware_access():
hardware = FakeHardware(connect_ok=False)
worker = ControlWorker(make_logger(), hardware_factory=lambda _settings: hardware)
worker.start()
wait_for_event(worker, lambda event: event.get("state") == "DISCONNECTED")
worker.request_connect(validate_device_settings("192.168.1.88", 502, 1))
fault = wait_for_event(worker, lambda event: event.get("kind") == "fault")
assert "无法连接 PLC" in fault["message"]
assert hardware.motor_commands == []
assert hardware.disconnect_count == 1
worker.request_shutdown()
wait_for_event(worker, lambda event: event.get("kind") == "shutdown_complete")
worker.thread.join(timeout=1.0)
assert not worker.is_alive()
def test_monitoring_stage_overpressure_triggers_safe_disconnect():
hardware = OverpressureHardware()
worker = ControlWorker(make_logger(), hardware_factory=lambda _settings: hardware)
worker.start()
wait_for_event(worker, lambda event: event.get("state") == "DISCONNECTED")
worker.request_connect(validate_device_settings("192.168.1.88", 502, 1))
wait_for_event(worker, lambda event: event.get("state") == "MONITORING")
fault = wait_for_event(worker, lambda event: event.get("kind") == "fault")
assert "超过上限" in fault["message"]
assert hardware.connected is False
assert hardware.motor_commands[-1][0] == config.MOTOR_OPEN_POSITION
worker.request_shutdown()
wait_for_event(worker, lambda event: event.get("kind") == "shutdown_complete")
worker.thread.join(timeout=1.0)
assert not worker.is_alive()