修改问题.md,且新增data_logger.py画图模块
This commit is contained in:
+1
-4
@@ -771,10 +771,7 @@ class MT2AM8Client:
|
|||||||
# Map the full travel range to the configured analog-output range.
|
# Map the full travel range to the configured analog-output range.
|
||||||
# The lower endpoint must include volthege_min; otherwise position 0
|
# The lower endpoint must include volthege_min; otherwise position 0
|
||||||
# produces raw value 0 and is rejected by write_analog_output().
|
# produces raw value 0 and is rejected by write_analog_output().
|
||||||
raw_value = int(
|
raw_value = int( voltage_distance / x_max * volthege_max )
|
||||||
volthege_min
|
|
||||||
+ voltage_distance / x_max * (volthege_max - volthege_min)
|
|
||||||
)
|
|
||||||
# print(f"设置电机行程为 {voltage_distance},模拟量输出值 {raw_value}")
|
# print(f"设置电机行程为 {voltage_distance},模拟量输出值 {raw_value}")
|
||||||
return self.write_analog_output(channel, raw_value)
|
return self.write_analog_output(channel, raw_value)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,8 +17,8 @@ PRESSURE_RANGE_KPA = 400.0
|
|||||||
FLOW_METER_RANGE_SLM = 300.0
|
FLOW_METER_RANGE_SLM = 300.0
|
||||||
|
|
||||||
# AI/AO 通道均从 0 开始。AI0 和 AO0 属于不同的寄存器区,可以同时使用。
|
# AI/AO 通道均从 0 开始。AI0 和 AO0 属于不同的寄存器区,可以同时使用。
|
||||||
FLOW_INPUT_CHANNEL = 0
|
FLOW_INPUT_CHANNEL = 1
|
||||||
PRESSURE_INPUT_CHANNEL = 1 # 没有压力传感器时改为 None
|
PRESSURE_INPUT_CHANNEL = 0 # 没有压力传感器时改为 None
|
||||||
MOTOR_OUTPUT_CHANNEL = 0
|
MOTOR_OUTPUT_CHANNEL = 0
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -63,7 +63,7 @@ MOTOR_OPEN_POSITION = 240.0
|
|||||||
# 物理读数允许范围;超过范围立即关阀。
|
# 物理读数允许范围;超过范围立即关阀。
|
||||||
FLOW_VALID_MIN_SLM = -2.0
|
FLOW_VALID_MIN_SLM = -2.0
|
||||||
FLOW_VALID_MAX_SLM = 120.0
|
FLOW_VALID_MAX_SLM = 120.0
|
||||||
MAX_PRESSURE_KPA = 350.0 # 必须按实际管路额定压力确认
|
MAX_PRESSURE_KPA = 400 # 必须按实际管路额定压力确认
|
||||||
|
|
||||||
# 短暂读取失败时保持上一输出,不下发新位置;连续达到阈值后关阀。
|
# 短暂读取失败时保持上一输出,不下发新位置;连续达到阈值后关阀。
|
||||||
MAX_CONSECUTIVE_FLOW_FAILURES = 3
|
MAX_CONSECUTIVE_FLOW_FAILURES = 3
|
||||||
@@ -73,6 +73,10 @@ MAX_CONSECUTIVE_PRESSURE_FAILURES = 3
|
|||||||
LOG_DIRECTORY = "logs"
|
LOG_DIRECTORY = "logs"
|
||||||
LOG_FILE_NAME = "flow_control.log"
|
LOG_FILE_NAME = "flow_control.log"
|
||||||
|
|
||||||
|
# 每次运行的逐周期数据和曲线图保存目录(相对于工程目录)。
|
||||||
|
DATA_DIRECTORY = "data"
|
||||||
|
PLOT_DPI = 160
|
||||||
|
|
||||||
|
|
||||||
def validate_config():
|
def validate_config():
|
||||||
"""在接触硬件前检查明显的配置错误。"""
|
"""在接触硬件前检查明显的配置错误。"""
|
||||||
@@ -94,4 +98,3 @@ def validate_config():
|
|||||||
raise ValueError("MAX_CONSECUTIVE_FLOW_FAILURES 必须至少为 1")
|
raise ValueError("MAX_CONSECUTIVE_FLOW_FAILURES 必须至少为 1")
|
||||||
if PRESSURE_INPUT_CHANNEL is not None and MAX_PRESSURE_KPA is None:
|
if PRESSURE_INPUT_CHANNEL is not None and MAX_PRESSURE_KPA is None:
|
||||||
raise ValueError("启用压力通道时必须配置 MAX_PRESSURE_KPA")
|
raise ValueError("启用压力通道时必须配置 MAX_PRESSURE_KPA")
|
||||||
|
|
||||||
|
|||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
"""逐周期保存控制数据,并在运行结束时生成三联曲线图。"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from datetime import datetime
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CSV_FIELDS = (
|
||||||
|
"time_s",
|
||||||
|
"timestamp",
|
||||||
|
"target_flow_slm",
|
||||||
|
"measured_flow_slm",
|
||||||
|
"opening_pct",
|
||||||
|
"pressure_kpa",
|
||||||
|
"error_slm",
|
||||||
|
"motor_position",
|
||||||
|
"actual_dt_s",
|
||||||
|
"status",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FlowRunRecorder:
|
||||||
|
"""把每个控制周期写入 CSV,并在结束时输出一张三联图。"""
|
||||||
|
|
||||||
|
def __init__(self, output_directory, *, plot_dpi=160):
|
||||||
|
output_dir = Path(output_directory)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
self.csv_path = output_dir / f"flow_run_{run_id}.csv"
|
||||||
|
self.image_path = output_dir / f"flow_run_{run_id}.png"
|
||||||
|
self.plot_dpi = int(plot_dpi)
|
||||||
|
|
||||||
|
self._file = self.csv_path.open("w", newline="", encoding="utf-8-sig")
|
||||||
|
self._writer = csv.DictWriter(self._file, fieldnames=CSV_FIELDS)
|
||||||
|
self._writer.writeheader()
|
||||||
|
self._file.flush()
|
||||||
|
self._first_timestamp = None
|
||||||
|
self.sample_count = 0
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
def record(self, result):
|
||||||
|
"""保存一个 ControlStepResult;每次写入后立即刷新到磁盘。"""
|
||||||
|
if self._closed:
|
||||||
|
raise RuntimeError("记录器已经关闭")
|
||||||
|
|
||||||
|
if self._first_timestamp is None:
|
||||||
|
self._first_timestamp = float(result.timestamp)
|
||||||
|
elapsed_s = max(0.0, float(result.timestamp) - self._first_timestamp)
|
||||||
|
|
||||||
|
self._writer.writerow(
|
||||||
|
{
|
||||||
|
"time_s": f"{elapsed_s:.6f}",
|
||||||
|
"timestamp": f"{float(result.timestamp):.6f}",
|
||||||
|
"target_flow_slm": self._format_value(result.target_flow_slm),
|
||||||
|
"measured_flow_slm": self._format_value(
|
||||||
|
result.measured_flow_slm
|
||||||
|
),
|
||||||
|
"opening_pct": self._format_value(result.opening_pct),
|
||||||
|
"pressure_kpa": self._format_value(result.pressure_kpa),
|
||||||
|
"error_slm": self._format_value(result.error_slm),
|
||||||
|
"motor_position": self._format_value(result.motor_position),
|
||||||
|
"actual_dt_s": self._format_value(result.actual_dt_s),
|
||||||
|
"status": result.status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._file.flush()
|
||||||
|
self.sample_count += 1
|
||||||
|
|
||||||
|
def finalize(self):
|
||||||
|
"""关闭 CSV 并生成 PNG;无采样点时只保留 CSV。"""
|
||||||
|
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 = []
|
||||||
|
target_flow = []
|
||||||
|
measured_flow = []
|
||||||
|
opening = []
|
||||||
|
pressure = []
|
||||||
|
|
||||||
|
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"]))
|
||||||
|
target_flow.append(self._float_or_nan(row["target_flow_slm"]))
|
||||||
|
measured_flow.append(
|
||||||
|
self._float_or_nan(row["measured_flow_slm"])
|
||||||
|
)
|
||||||
|
opening.append(self._float_or_nan(row["opening_pct"]))
|
||||||
|
pressure.append(self._float_or_nan(row["pressure_kpa"]))
|
||||||
|
|
||||||
|
figure, axes = plt.subplots(
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
figsize=(12, 10),
|
||||||
|
sharex=True,
|
||||||
|
constrained_layout=True,
|
||||||
|
)
|
||||||
|
figure.suptitle("Gas Flow Control Response", fontsize=15)
|
||||||
|
|
||||||
|
axes[0].plot(
|
||||||
|
time_s,
|
||||||
|
measured_flow,
|
||||||
|
color="#1565C0",
|
||||||
|
linewidth=1.4,
|
||||||
|
label="Measured flow",
|
||||||
|
)
|
||||||
|
axes[0].plot(
|
||||||
|
time_s,
|
||||||
|
target_flow,
|
||||||
|
color="#D84315",
|
||||||
|
linewidth=1.5,
|
||||||
|
linestyle="--",
|
||||||
|
label="Target flow",
|
||||||
|
)
|
||||||
|
axes[0].set_ylabel("Flow (SLM)")
|
||||||
|
axes[0].set_title("Flow response")
|
||||||
|
axes[0].legend(loc="best")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if any(math.isfinite(value) for value in pressure):
|
||||||
|
axes[2].plot(
|
||||||
|
time_s,
|
||||||
|
pressure,
|
||||||
|
color="#6A1B9A",
|
||||||
|
linewidth=1.4,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
axes[2].text(
|
||||||
|
0.5,
|
||||||
|
0.5,
|
||||||
|
"No pressure data",
|
||||||
|
transform=axes[2].transAxes,
|
||||||
|
ha="center",
|
||||||
|
va="center",
|
||||||
|
color="gray",
|
||||||
|
)
|
||||||
|
axes[2].set_ylabel("Pressure (kPa)")
|
||||||
|
axes[2].set_xlabel("Time (s)")
|
||||||
|
axes[2].set_title("Pressure")
|
||||||
|
|
||||||
|
for axis in axes:
|
||||||
|
axis.grid(True, alpha=0.3, linestyle="--")
|
||||||
|
axis.margins(x=0)
|
||||||
|
|
||||||
|
figure.savefig(self.image_path, dpi=self.plot_dpi, bbox_inches="tight")
|
||||||
|
plt.close(figure)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_value(value):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
number = float(value)
|
||||||
|
return "" if not math.isfinite(number) else f"{number:.6f}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _float_or_nan(value):
|
||||||
|
if value in (None, ""):
|
||||||
|
return math.nan
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return math.nan
|
||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
2026-08-12 17:41:42.305 INFO 正在连接 MT2-AM8 192.168.1.12:502,目标流量=50.000 SLM
|
||||||
|
2026-08-12 17:41:45.314 CRITICAL 控制故障:[DEVICE_CONNECT_FAILED] 阶段=STARTUP: 无法连接 MT2-AM8 | 上下文={'host': '192.168.1.12', 'port': 502, 'slave_id': 1}
|
||||||
|
2026-08-12 17:41:45.314 INFO 程序结束,退出码=2
|
||||||
@@ -15,6 +15,7 @@ import time
|
|||||||
|
|
||||||
import config
|
import config
|
||||||
from controllers import IncrementalPID
|
from controllers import IncrementalPID
|
||||||
|
from data_logger import FlowRunRecorder
|
||||||
from flow_control import FlowControlFault, FlowControlLoop
|
from flow_control import FlowControlFault, FlowControlLoop
|
||||||
|
|
||||||
|
|
||||||
@@ -131,6 +132,7 @@ def run(target_flow_slm):
|
|||||||
controller.set_target_flow(target_flow_slm)
|
controller.set_target_flow(target_flow_slm)
|
||||||
|
|
||||||
connected = False
|
connected = False
|
||||||
|
recorder = None
|
||||||
exit_code = 0
|
exit_code = 0
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -161,6 +163,11 @@ def run(target_flow_slm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
controller.start(initial_opening=0.0)
|
controller.start(initial_opening=0.0)
|
||||||
|
recorder = FlowRunRecorder(
|
||||||
|
Path(__file__).resolve().parent / config.DATA_DIRECTORY,
|
||||||
|
plot_dpi=config.PLOT_DPI,
|
||||||
|
)
|
||||||
|
logger.info("逐周期数据将保存到 %s", recorder.csv_path)
|
||||||
logger.info("闭环控制已启动;按 Ctrl+C 停止")
|
logger.info("闭环控制已启动;按 Ctrl+C 停止")
|
||||||
|
|
||||||
next_deadline = time.perf_counter()
|
next_deadline = time.perf_counter()
|
||||||
@@ -168,6 +175,7 @@ def run(target_flow_slm):
|
|||||||
while True:
|
while True:
|
||||||
now = time.perf_counter()
|
now = time.perf_counter()
|
||||||
result = controller.step(now=now)
|
result = controller.step(now=now)
|
||||||
|
recorder.record(result)
|
||||||
|
|
||||||
if now >= next_status_time or result.status != "OK":
|
if now >= next_status_time or result.status != "OK":
|
||||||
logger.info(format_status(result))
|
logger.info(format_status(result))
|
||||||
@@ -200,6 +208,19 @@ def run(target_flow_slm):
|
|||||||
except Exception:
|
except Exception:
|
||||||
exit_code = max(exit_code, 5)
|
exit_code = max(exit_code, 5)
|
||||||
logger.exception("断开 MT2-AM8 时发生异常")
|
logger.exception("断开 MT2-AM8 时发生异常")
|
||||||
|
|
||||||
|
if recorder is not None:
|
||||||
|
try:
|
||||||
|
image_path = recorder.finalize()
|
||||||
|
logger.info("控制数据已保存:%s", recorder.csv_path)
|
||||||
|
if image_path is not None:
|
||||||
|
logger.info("三联曲线图已保存:%s", image_path)
|
||||||
|
else:
|
||||||
|
logger.warning("本次运行没有采样点,因此未生成曲线图")
|
||||||
|
except Exception:
|
||||||
|
recorder.close()
|
||||||
|
exit_code = max(exit_code, 7)
|
||||||
|
logger.exception("保存控制数据曲线图失败;CSV 数据仍保留")
|
||||||
logger.info("程序结束,退出码=%d", exit_code)
|
logger.info("程序结束,退出码=%d", exit_code)
|
||||||
return exit_code
|
return exit_code
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
1.PcControl.py中set_motor_position方法对于电机行程到模拟量的映射是否考虑了volthege_min不为0的情况?原版未考虑,现版暂时考虑了进去
|
1.PcControl.py中set_motor_position方法对于电机行程到模拟量的映射是否考虑了volthege_min不为0的情况?原版未考虑,现版暂时考虑了进去
|
||||||
2.最大行程是1000还是1062.5?PcControl.py中是1000,而controllers.py中是1062.5
|
答:读取数据时考虑了。写入数据无需考虑。
|
||||||
3.init_v预置初始阀门开度是否有用到?read_motor_position读取电机行程,现在是否能做到?
|
2.最大行程是1000还是1062.5?PcControl.py中是1000,而controllers.py中是1062.5。
|
||||||
|
答:统一改为1000.
|
||||||
|
3.init_v预置初始阀门开度是否有用到?read_motor_position读取电机行程,现在是否能做到?
|
||||||
|
答:可以读取电机行程,尚未拉取最新PcControl.py。
|
||||||
Reference in New Issue
Block a user