完善流量控制 GUI 的开环扫点与手动操作流程
新增可配置开环扫点、多 CSV 前馈表拟合及结果文件生成;补充闭环停止、手动开度安全互锁和 PID 未启用状态显示,并同步更新自动化测试、使用文档、打包配置与 Windows 可执行程序。
This commit is contained in:
@@ -1,12 +1,18 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from queue import Empty
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
from control_runtime import (
|
||||
ControlWorker,
|
||||
build_open_loop_points,
|
||||
load_model,
|
||||
validate_device_settings,
|
||||
validate_opening,
|
||||
validate_open_loop_settings,
|
||||
validate_target,
|
||||
)
|
||||
|
||||
@@ -46,6 +52,26 @@ class OverpressureHardware(FakeHardware):
|
||||
return 100.0
|
||||
|
||||
|
||||
class MemoryOpenLoopRecorder:
|
||||
def __init__(self):
|
||||
self.csv_path = Path("memory_open_loop.csv")
|
||||
self.image_path = Path("memory_open_loop.png")
|
||||
self.sample_count = 0
|
||||
self.rows = []
|
||||
self.finalize_count = 0
|
||||
|
||||
def record(self, **row):
|
||||
self.rows.append(row)
|
||||
self.sample_count += 1
|
||||
|
||||
def finalize(self):
|
||||
self.finalize_count += 1
|
||||
return self.image_path if self.sample_count else None
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def wait_for_event(worker, predicate, timeout=2.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
@@ -70,6 +96,9 @@ def test_validate_ui_values():
|
||||
assert settings.port == 502
|
||||
assert settings.slave_id == 1
|
||||
assert validate_target("50.5") == 50.5
|
||||
assert validate_opening("25.5") == 25.5
|
||||
with pytest.raises(ValueError, match="0~100"):
|
||||
validate_opening("100.1")
|
||||
|
||||
|
||||
def test_builtin_model_is_valve_model_004():
|
||||
@@ -81,6 +110,29 @@ def test_builtin_model_is_valve_model_004():
|
||||
assert info.model.motor_closed == config.MOTOR_CLOSED_POSITION
|
||||
|
||||
|
||||
def test_validate_and_build_gui_open_loop_points():
|
||||
settings = validate_open_loop_settings(
|
||||
min_opening_pct="0",
|
||||
max_opening_pct="10",
|
||||
opening_step_pct="5",
|
||||
random_seed="42",
|
||||
include_fine_scan=False,
|
||||
)
|
||||
points = build_open_loop_points(settings)
|
||||
assert sorted(opening for opening, _position in points) == [0.0, 5.0, 10.0]
|
||||
assert all(
|
||||
config.MOTOR_OPEN_POSITION <= position <= config.MOTOR_CLOSED_POSITION
|
||||
for _opening, position in points
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="开度范围"):
|
||||
validate_open_loop_settings(
|
||||
min_opening_pct=50,
|
||||
max_opening_pct=50,
|
||||
include_fine_scan=False,
|
||||
)
|
||||
|
||||
|
||||
def test_worker_connect_control_and_safe_disconnect():
|
||||
hardware = FakeHardware()
|
||||
worker = ControlWorker(make_logger(), hardware_factory=lambda _settings: hardware)
|
||||
@@ -137,6 +189,142 @@ def test_worker_connect_control_and_safe_disconnect():
|
||||
assert not worker.is_alive()
|
||||
|
||||
|
||||
def test_worker_runs_open_loop_scan_and_keeps_connection():
|
||||
hardware = FakeHardware()
|
||||
recorder = MemoryOpenLoopRecorder()
|
||||
worker = ControlWorker(
|
||||
make_logger(),
|
||||
hardware_factory=lambda _settings: hardware,
|
||||
open_loop_recorder_factory=lambda: recorder,
|
||||
)
|
||||
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")
|
||||
|
||||
settings = validate_open_loop_settings(
|
||||
min_opening_pct=0,
|
||||
max_opening_pct=1,
|
||||
opening_step_pct=1,
|
||||
random_seed=1,
|
||||
sample_period_s=0.005,
|
||||
steady_window_s=0.01,
|
||||
max_wait_s=0.03,
|
||||
include_fine_scan=False,
|
||||
)
|
||||
worker.request_open_loop_start(settings)
|
||||
started = wait_for_event(
|
||||
worker, lambda event: event.get("kind") == "open_loop_started"
|
||||
)
|
||||
assert started["total_steps"] == 2
|
||||
finished = wait_for_event(
|
||||
worker, lambda event: event.get("kind") == "open_loop_finished"
|
||||
)
|
||||
assert finished["completed"] is True
|
||||
assert finished["sample_count"] == recorder.sample_count
|
||||
assert recorder.sample_count >= 4
|
||||
assert recorder.finalize_count == 1
|
||||
wait_for_event(worker, lambda event: event.get("state") == "MONITORING")
|
||||
assert hardware.connected is True
|
||||
assert hardware.motor_commands[-1] == (
|
||||
config.MOTOR_OPEN_POSITION,
|
||||
config.MOTOR_OUTPUT_ADDR,
|
||||
)
|
||||
|
||||
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_stops_closed_loop_at_full_open_and_accepts_manual_opening():
|
||||
hardware = FakeHardware()
|
||||
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")
|
||||
|
||||
worker.request_target(50.0)
|
||||
wait_for_event(worker, lambda event: event.get("kind") == "target_applied")
|
||||
worker.request_manual_opening(25.0)
|
||||
warning = wait_for_event(
|
||||
worker,
|
||||
lambda event: event.get("kind") == "warning"
|
||||
and "先停止流量控制" in event.get("message", ""),
|
||||
)
|
||||
assert "先停止流量控制" in warning["message"]
|
||||
|
||||
worker.request_stop_control()
|
||||
stopped = wait_for_event(
|
||||
worker, lambda event: event.get("kind") == "control_stopped"
|
||||
)
|
||||
assert stopped["opening_pct"] == 100.0
|
||||
assert stopped["motor_position"] == config.MOTOR_OPEN_POSITION
|
||||
wait_for_event(worker, lambda event: event.get("state") == "MONITORING")
|
||||
assert hardware.motor_commands[-1] == (
|
||||
config.MOTOR_OPEN_POSITION,
|
||||
config.MOTOR_OUTPUT_ADDR,
|
||||
)
|
||||
|
||||
worker.request_manual_opening(25.0)
|
||||
manual = wait_for_event(
|
||||
worker, lambda event: event.get("kind") == "manual_opening_applied"
|
||||
)
|
||||
assert manual["opening_pct"] == 25.0
|
||||
assert manual["motor_position"] == pytest.approx(950.0)
|
||||
wait_for_event(
|
||||
worker,
|
||||
lambda event: event.get("kind") == "telemetry"
|
||||
and event.get("pid_mode") == "未启用",
|
||||
)
|
||||
assert hardware.motor_commands[-1] == (950.0, config.MOTOR_OUTPUT_ADDR)
|
||||
|
||||
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_disconnect_during_open_loop_finalizes_partial_data():
|
||||
hardware = FakeHardware()
|
||||
recorder = MemoryOpenLoopRecorder()
|
||||
worker = ControlWorker(
|
||||
make_logger(),
|
||||
hardware_factory=lambda _settings: hardware,
|
||||
open_loop_recorder_factory=lambda: recorder,
|
||||
)
|
||||
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")
|
||||
settings = validate_open_loop_settings(
|
||||
min_opening_pct=0,
|
||||
max_opening_pct=100,
|
||||
opening_step_pct=1,
|
||||
sample_period_s=0.02,
|
||||
steady_window_s=1,
|
||||
max_wait_s=1,
|
||||
include_fine_scan=False,
|
||||
)
|
||||
worker.request_open_loop_start(settings)
|
||||
wait_for_event(worker, lambda event: event.get("kind") == "open_loop_started")
|
||||
worker.request_disconnect()
|
||||
finished = wait_for_event(
|
||||
worker, lambda event: event.get("kind") == "open_loop_finished"
|
||||
)
|
||||
assert finished["completed"] is False
|
||||
assert finished["reason"] == "设备断开或程序退出"
|
||||
assert recorder.finalize_count == 1
|
||||
wait_for_event(worker, lambda event: event.get("state") == "DISCONNECTED")
|
||||
assert hardware.connected is False
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user