Files
flow_control/test_valve.py
T
louis cfeedcf887 完善流量控制前馈、稳态判定与阀门标定流程
- 前馈冻结改用可配置的 3 秒流量绝对误差滑窗,修复采样抖动造成的重复解冻,并保留下游扰动后的自动更新
- 支持非单调阀特性反解、最小可测面积以下直接全关,以及闭阀端精细扫点与辨识开关
- 调整双压力判稳、最长等待时间、在线入口全开收尾和闭环四联图
- 更新配置、README、阀模型及离线测试,归档本轮实验数据与诊断产物
2026-09-02 16:14:42 +08:00

428 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""手动设置阀门开度,并每秒显示阀前、阀后传感器数据和电机行程。"""
import contextlib
import csv
from datetime import datetime
import io
import math
from pathlib import Path
import sys
import time
try:
import msvcrt
except ImportError: # 本项目的交互方式仅适用于 Windows 终端
msvcrt = None
import config
from flow_control import opening_to_motor_position
SAMPLE_PERIOD_S = 1.0
KEY_POLL_PERIOD_S = 0.05
INITIAL_OPENING_PCT = 100.0
OUTPUT_DIRECTORY = Path(__file__).resolve().parent / "test_valve_data"
class ManualValveRecorder:
"""逐次保存手动测试数据,并在结束时生成三联图。"""
CSV_FIELDS = (
"time_s",
"timestamp",
"flow_after_slm",
"opening_pct",
"pressure_before_kpa",
"pressure_after_kpa",
"motor_position",
)
def __init__(self, output_directory):
output_directory.mkdir(parents=True, exist_ok=True)
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
self.csv_path = output_directory / f"test_valve_{run_id}.csv"
self.image_path = output_directory / f"test_valve_{run_id}.png"
self._file = self.csv_path.open("w", newline="", encoding="utf-8-sig")
self._writer = csv.DictWriter(self._file, fieldnames=self.CSV_FIELDS)
self._writer.writeheader()
self._file.flush()
self._start_time = None
self.sample_count = 0
self._closed = False
def record(
self,
sample_time,
flow_after,
opening,
pressure_before,
pressure_after,
position,
):
if self._start_time is None:
self._start_time = sample_time
self._writer.writerow(
{
"time_s": f"{sample_time - self._start_time:.6f}",
"timestamp": datetime.now().isoformat(timespec="milliseconds"),
"flow_after_slm": self._format_number(flow_after),
"opening_pct": self._format_number(opening),
"pressure_before_kpa": self._format_number(pressure_before),
"pressure_after_kpa": self._format_number(pressure_after),
"motor_position": self._format_number(position),
}
)
self._file.flush()
self.sample_count += 1
def finalize(self):
self.close()
if self.sample_count == 0:
return None
self._create_plot()
return self.image_path
def close(self):
if not self._closed:
self._file.flush()
self._file.close()
self._closed = True
def _create_plot(self):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
time_s = []
flow_after = []
opening = []
pressure_before = []
pressure_after = []
with self.csv_path.open("r", newline="", encoding="utf-8-sig") as file:
for row in csv.DictReader(file):
time_s.append(float(row["time_s"]))
flow_after.append(self._float_or_nan(row["flow_after_slm"]))
opening.append(self._float_or_nan(row["opening_pct"]))
pressure_before.append(
self._float_or_nan(row["pressure_before_kpa"])
)
pressure_after.append(
self._float_or_nan(row["pressure_after_kpa"])
)
figure, axes = plt.subplots(
3,
1,
figsize=(12, 10),
sharex=True,
constrained_layout=True,
)
figure.suptitle("Manual Valve Test", fontsize=15)
axes[0].plot(time_s, flow_after, color="#1565C0", linewidth=1.4)
axes[0].set_ylabel("Flow after valve (SLM)")
axes[0].set_title("Flow after valve")
axes[1].plot(time_s, opening, color="#2E7D32", linewidth=1.4)
axes[1].set_ylabel("Opening (%)")
axes[1].set_title("Valve opening")
axes[1].set_ylim(-2, 102)
axes[2].plot(
time_s,
pressure_before,
color="#6A1B9A",
linewidth=1.4,
label="Pressure before valve",
)
axes[2].plot(
time_s,
pressure_after,
color="#D84315",
linewidth=1.4,
label="Pressure after valve",
)
axes[2].set_ylabel("Pressure (kPa)")
axes[2].set_xlabel("Time (s)")
axes[2].set_title("Pressure before and after valve")
axes[2].legend()
for axis in axes:
axis.grid(True, alpha=0.3, linestyle="--")
axis.margins(x=0)
figure.savefig(self.image_path, dpi=config.PLOT_DPI, bbox_inches="tight")
plt.close(figure)
@staticmethod
def _format_number(value):
if value is None:
return ""
try:
number = float(value)
except (TypeError, ValueError):
return ""
return f"{number:.6f}" if math.isfinite(number) else ""
@staticmethod
def _float_or_nan(value):
try:
return float(value)
except (TypeError, ValueError):
return math.nan
def format_value(value, unit):
"""将传感器读数格式化;读取失败时显示 --。"""
if value is None:
return f"-- {unit}"
try:
number = float(value)
except (TypeError, ValueError):
return f"-- {unit}"
if not math.isfinite(number):
return f"-- {unit}"
return f"{number:.2f} {unit}"
def format_analog_raw(value, physical_range, analog_raw_max):
"""把物理量反算为 PLC 的 0~analog_raw_max 模拟量原始值。"""
try:
number = float(value)
full_scale = float(physical_range)
raw_maximum = float(analog_raw_max)
except (TypeError, ValueError):
return "--"
if (
not math.isfinite(number)
or not math.isfinite(full_scale)
or not math.isfinite(raw_maximum)
or full_scale <= 0.0
or raw_maximum <= 0.0
):
return "--"
return str(round(number / full_scale * raw_maximum))
def motor_position_for(opening_pct):
return opening_to_motor_position(
opening_pct,
config.MOTOR_OPEN_POSITION,
config.MOTOR_CLOSED_POSITION,
)
def set_opening(hardware, opening_pct):
"""校验并下发开度,成功时返回对应的电机行程。"""
opening = float(opening_pct)
if not math.isfinite(opening) or not 0.0 <= opening <= 100.0:
raise ValueError("开度必须是 0~100 之间的有限数值")
position = motor_position_for(opening)
if not hardware.set_motor_position(
position,
channel=config.MOTOR_OUTPUT_ADDR,
):
raise RuntimeError("电机位置写入失败")
return opening, position
def read_sensors(hardware, quiet=False):
"""读取阀后流量及阀前、阀后压力;输入模式中隐藏硬件输出。"""
output = io.StringIO()
stream = output if quiet else sys.stdout
with contextlib.redirect_stdout(stream):
flow_after = hardware.get_flow(config.FLOW_AFTER_ADDR)
pressure_before = (
None
if config.PRESSURE_BEFORE_ADDR is None
else hardware.get_pressure(config.PRESSURE_BEFORE_ADDR)
)
pressure_after = (
None
if config.PRESSURE_AFTER_ADDR is None
else hardware.get_pressure(config.PRESSURE_AFTER_ADDR)
)
return flow_after, pressure_before, pressure_after
def main():
if msvcrt is None:
print("本程序需要在 Windows 终端中运行。")
return 1
try:
from PcControl import Easy521ModbusClient, raw_max as analog_raw_max
except ModuleNotFoundError as exc:
print(f"无法导入硬件客户端: {exc}")
return 2
hardware = Easy521ModbusClient(
host=config.PLC_HOST,
port=config.PLC_PORT,
slave_id=config.PLC_SLAVE_ID,
flow_addr=config.FLOW_AFTER_ADDR,
flow_range=config.FLOW_AFTER_RANGE_SLM,
pressure_before_addr=config.PRESSURE_BEFORE_ADDR,
pressure_before_range=config.PRESSURE_BEFORE_RANGE_KPA,
pressure_after_addr=config.PRESSURE_AFTER_ADDR,
pressure_after_range=config.PRESSURE_AFTER_RANGE_KPA,
motor_output_addr=config.MOTOR_OUTPUT_ADDR,
)
connected = False
opening = INITIAL_OPENING_PCT
position = motor_position_for(opening)
input_mode = False
input_buffer = ""
recorder = None
exit_code = 0
try:
print(
f"正在连接 PLC {config.PLC_HOST}:{config.PLC_PORT} ..."
)
connected = bool(hardware.connect())
if not connected:
print("连接失败。")
return 3
# 先全开,再创建记录器和启动采样计时。
opening, position = set_opening(hardware, 100.0)
print(
f"连接后已设置为 100% 开度(行程 {position:.1f}),"
"不计入采样计时。"
)
recorder = ManualValveRecorder(OUTPUT_DIRECTORY)
print("手动阀门控制已启动:按 S 输入新开度,按 Ctrl+C 退出。")
print(
f"连接时按已知状态记录:开度 {opening:.2f}%(行程 {position:.1f});"
"未向电机写入位置。"
)
print(f"CSV 将保存到:{recorder.csv_path}")
next_sample_time = time.perf_counter()
while True:
while msvcrt.kbhit():
ch = msvcrt.getch()
if not input_mode:
if ch == b"\x03":
raise KeyboardInterrupt
if ch.decode("ascii", errors="ignore").lower() == "s":
input_mode = True
input_buffer = ""
print(
f"当前开度={opening:.2f}%,请输入新开度 0~100 "
"(回车确认, Esc 取消): ",
end="",
flush=True,
)
continue
if ch in (b"\r", b"\n"):
print()
input_mode = False
raw = input_buffer.strip()
if not raw:
print("未输入开度,保持原开度。")
continue
try:
opening, position = set_opening(hardware, float(raw))
except (ValueError, RuntimeError) as exc:
print(f"开度切换失败:{exc};保持原开度。")
else:
print(
f"开度已切换为 {opening:.2f}%"
f"(行程 {position:.1f})。"
)
elif ch == b"\x1b":
input_mode = False
print(" (已取消,保持原开度)")
elif ch in (b"\x08", b"\x7f"):
if input_buffer:
input_buffer = input_buffer[:-1]
print("\b \b", end="", flush=True)
elif ch == b"\x03":
raise KeyboardInterrupt
else:
text = ch.decode("ascii", errors="ignore")
if text and text.isprintable():
input_buffer += text
print(text, end="", flush=True)
now = time.perf_counter()
if now >= next_sample_time:
flow_after, pressure_before, pressure_after = read_sensors(
hardware, quiet=input_mode
)
recorder.record(
now,
flow_after,
opening,
pressure_before,
pressure_after,
position,
)
if not input_mode:
timestamp = time.strftime("%H:%M:%S")
print(
f"[{timestamp}] "
f"阀后流量={format_value(flow_after, 'SLM')}"
f"(模拟量={format_analog_raw(flow_after, config.FLOW_AFTER_RANGE_SLM, analog_raw_max)} "
f"阀前压力={format_value(pressure_before, 'kPa')}"
f"(模拟量={format_analog_raw(pressure_before, config.PRESSURE_BEFORE_RANGE_KPA, analog_raw_max)} "
f"阀后压力={format_value(pressure_after, 'kPa')}"
f"(模拟量={format_analog_raw(pressure_after, config.PRESSURE_AFTER_RANGE_KPA, analog_raw_max)} "
f"开度={opening:.2f}% 行程={position:.1f}"
)
# 不追补错过的终端输出;输入期间的样本只写入 CSV。
next_sample_time = time.perf_counter() + SAMPLE_PERIOD_S
time.sleep(KEY_POLL_PERIOD_S)
except KeyboardInterrupt:
if input_mode:
print()
print("收到 Ctrl+C,正在保存数据并退出;不改变当前阀门开度。")
except Exception as exc:
if input_mode:
print()
print(f"程序异常:{exc}")
exit_code = 4
finally:
if connected:
try:
opening, position = set_opening(hardware, 100.0)
print(
f"断开前已设置为 100% 开度(行程 {position:.1f}),"
"不计入采样计时。"
)
except Exception as exc:
print(f"警告:断开前设置阀门 100% 开度失败:{exc}")
exit_code = max(exit_code, 4)
try:
hardware.disconnect()
except Exception as exc:
print(f"警告:断开设备失败:{exc}")
exit_code = max(exit_code, 5)
if recorder is not None:
try:
image_path = recorder.finalize()
print(f"CSV 已保存:{recorder.csv_path}")
if image_path is not None:
print(f"三联图已保存:{image_path}")
except Exception as exc:
recorder.close()
print(f"警告:生成图表失败:{exc}")
exit_code = max(exit_code, 6)
return exit_code
if __name__ == "__main__":
raise SystemExit(main())