新增 Tkinter 单窗口操作界面和后台设备状态机,支持连接与断开 PLC、在线设置目标流量、实时显示传感器数据与下发总开度,并在监测及闭环阶段统一执行安全保护。 默认将 valve_model_004.json 内置到单文件 EXE,同时支持校验并选择外部阀模型;整理标定 JSON/PNG 到 valve_model 目录并同步配置路径。补充运行层离线测试、PyInstaller 构建配置、交付文件和完整的 README 使用及重新打包说明。
173 lines
5.5 KiB
Python
173 lines
5.5 KiB
Python
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()
|