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