diff --git a/PcControl.py b/PcControl.py index 59fd2ea..400ed89 100644 --- a/PcControl.py +++ b/PcControl.py @@ -771,10 +771,7 @@ class MT2AM8Client: # Map the full travel range to the configured analog-output range. # The lower endpoint must include volthege_min; otherwise position 0 # produces raw value 0 and is rejected by write_analog_output(). - raw_value = int( - volthege_min - + voltage_distance / x_max * (volthege_max - volthege_min) - ) + raw_value = int( voltage_distance / x_max * volthege_max ) # print(f"设置电机行程为 {voltage_distance},模拟量输出值 {raw_value}") return self.write_analog_output(channel, raw_value) diff --git a/__pycache__/PcControl.cpython-313.pyc b/__pycache__/PcControl.cpython-313.pyc new file mode 100644 index 0000000..b50871e Binary files /dev/null and b/__pycache__/PcControl.cpython-313.pyc differ diff --git a/__pycache__/config.cpython-313.pyc b/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000..8343c1f Binary files /dev/null and b/__pycache__/config.cpython-313.pyc differ diff --git a/__pycache__/controllers.cpython-313.pyc b/__pycache__/controllers.cpython-313.pyc new file mode 100644 index 0000000..94db4ed Binary files /dev/null and b/__pycache__/controllers.cpython-313.pyc differ diff --git a/__pycache__/data_logger.cpython-313.pyc b/__pycache__/data_logger.cpython-313.pyc new file mode 100644 index 0000000..abdeb28 Binary files /dev/null and b/__pycache__/data_logger.cpython-313.pyc differ diff --git a/__pycache__/flow_control.cpython-313.pyc b/__pycache__/flow_control.cpython-313.pyc new file mode 100644 index 0000000..1d02d59 Binary files /dev/null and b/__pycache__/flow_control.cpython-313.pyc differ diff --git a/config.py b/config.py index e5dadf2..3458f71 100644 --- a/config.py +++ b/config.py @@ -17,8 +17,8 @@ PRESSURE_RANGE_KPA = 400.0 FLOW_METER_RANGE_SLM = 300.0 # AI/AO 通道均从 0 开始。AI0 和 AO0 属于不同的寄存器区,可以同时使用。 -FLOW_INPUT_CHANNEL = 0 -PRESSURE_INPUT_CHANNEL = 1 # 没有压力传感器时改为 None +FLOW_INPUT_CHANNEL = 1 +PRESSURE_INPUT_CHANNEL = 0 # 没有压力传感器时改为 None MOTOR_OUTPUT_CHANNEL = 0 # --------------------------------------------------------------------------- @@ -63,7 +63,7 @@ MOTOR_OPEN_POSITION = 240.0 # 物理读数允许范围;超过范围立即关阀。 FLOW_VALID_MIN_SLM = -2.0 FLOW_VALID_MAX_SLM = 120.0 -MAX_PRESSURE_KPA = 350.0 # 必须按实际管路额定压力确认 +MAX_PRESSURE_KPA = 400 # 必须按实际管路额定压力确认 # 短暂读取失败时保持上一输出,不下发新位置;连续达到阈值后关阀。 MAX_CONSECUTIVE_FLOW_FAILURES = 3 @@ -73,6 +73,10 @@ MAX_CONSECUTIVE_PRESSURE_FAILURES = 3 LOG_DIRECTORY = "logs" LOG_FILE_NAME = "flow_control.log" +# 每次运行的逐周期数据和曲线图保存目录(相对于工程目录)。 +DATA_DIRECTORY = "data" +PLOT_DPI = 160 + def validate_config(): """在接触硬件前检查明显的配置错误。""" @@ -94,4 +98,3 @@ def validate_config(): raise ValueError("MAX_CONSECUTIVE_FLOW_FAILURES 必须至少为 1") if PRESSURE_INPUT_CHANNEL is not None and MAX_PRESSURE_KPA is None: raise ValueError("启用压力通道时必须配置 MAX_PRESSURE_KPA") - diff --git a/data_logger.py b/data_logger.py new file mode 100644 index 0000000..ab6897e --- /dev/null +++ b/data_logger.py @@ -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 + diff --git a/logs/flow_control.log b/logs/flow_control.log new file mode 100644 index 0000000..b0de144 --- /dev/null +++ b/logs/flow_control.log @@ -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 diff --git a/main.py b/main.py index 642ba80..2d02fdb 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,7 @@ import time import config from controllers import IncrementalPID +from data_logger import FlowRunRecorder from flow_control import FlowControlFault, FlowControlLoop @@ -131,6 +132,7 @@ def run(target_flow_slm): controller.set_target_flow(target_flow_slm) connected = False + recorder = None exit_code = 0 try: logger.info( @@ -161,6 +163,11 @@ def run(target_flow_slm): ) 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 停止") next_deadline = time.perf_counter() @@ -168,6 +175,7 @@ def run(target_flow_slm): while True: now = time.perf_counter() result = controller.step(now=now) + recorder.record(result) if now >= next_status_time or result.status != "OK": logger.info(format_status(result)) @@ -200,6 +208,19 @@ def run(target_flow_slm): except Exception: exit_code = max(exit_code, 5) 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) return exit_code diff --git a/问题.md b/问题.md index e3ef3c1..0542926 100644 --- a/问题.md +++ b/问题.md @@ -1,3 +1,6 @@ 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读取电机行程,现在是否能做到? \ No newline at end of file +答:读取数据时考虑了。写入数据无需考虑。 +2.最大行程是1000还是1062.5?PcControl.py中是1000,而controllers.py中是1062.5。 +答:统一改为1000. +3.init_v预置初始阀门开度是否有用到?read_motor_position读取电机行程,现在是否能做到? +答:可以读取电机行程,尚未拉取最新PcControl.py。 \ No newline at end of file