From 5888b4cccea82486d2f87d8c915d7b76084b1282 Mon Sep 17 00:00:00 2001 From: louissun Date: Tue, 1 Sep 2026 17:03:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=8E=20MT2-AM8=20=E6=A8=A1=E6=8B=9F?= =?UTF-8?q?=E9=87=8F=E6=A8=A1=E5=9D=97=E8=BF=81=E7=A7=BB=E5=88=B0=20Easy52?= =?UTF-8?q?1=20PLC=EF=BC=88Modbus=20TCP=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 读取 D700(阀后流量)、D710(阀后压力)、D720(阀前压力), 读用功能码 03,0~32000 线性映射到各自量程(300 SLM / 400 kPa / 300 kPa) - 输出 D730(电机位置,0~10V),写用功能码 06,行程仍为 0~1000、死区 800 - 复用 MT2 骨架,保留 get_flow/get_pressure/set_motor_position 接口, channel 参数改为直接 Modbus 地址 - config.py 新增 PLC 设置,保留 MT2AM8 设置为死代码(MT2AM8Client 类保留) - 同步更新 README.md 中的硬件接线、配置默认值与模块职责描述 --- .gitignore | 3 +- PcControl.py | 283 ++++++++++++++------------------------------ README.md | 70 +++++------ config.py | 55 +++++---- flow_control.py | 8 +- identify_valve.py | 226 ++++++++++++++++++++++++++++------- main.py | 49 ++++---- open_loop.py | 45 +++---- test_valve.py | 29 +++-- test_valve_model.py | 113 ++++++++++++++---- 10 files changed, 507 insertions(+), 374 deletions(-) diff --git a/.gitignore b/.gitignore index 78b5b14..424a8e7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ __pycache__ 阻塞流压力判断.md 压差压比对流量影响的突发奇想.md MPC与PID控制系统对比分析.md -plan.md \ No newline at end of file +plan.md +valve_pipeline.html \ No newline at end of file diff --git a/PcControl.py b/PcControl.py index 400ed89..691ccc1 100644 --- a/PcControl.py +++ b/PcControl.py @@ -20,9 +20,10 @@ def _motor_log(msg: str): except Exception: pass -volthege_min = 819 # 模拟量映射最小值(对应 0V/4mA) -volthege_max = 4095 # 模拟量映射最大值(对应 10V/20mA) -x_max = 1000 # 最大行程 +volthege_min = 819 # 模拟量映射最小值(对应 0V/4mA)—— MT2 旧用 +volthege_max = 4095 # 模拟量映射最大值(对应 10V/20mA)—— MT2 旧用 +x_max = 1000 # 电机最大行程(PLC 沿用 0~1000 坐标,死区由 config 给定) +raw_max = 32000 # PLC 模拟量映射最大值(0~32000,对应量程满量程) impulse_max = 163840 # 电机脉冲最大值(对应 x_max) @@ -42,49 +43,60 @@ def set_motor_limits(volthege_min_val=None, volthege_max_val=None, x_max_val=Non if x_max_val is not None: x_max = x_max_val -# ---------- 原有的 PLC Modbus TCP 客户端类 ---------- +# ---------- PLC(Easy521)Modbus TCP 客户端 ---------- class Easy521ModbusClient: - # 参数来源(GUI 页面1 Modbus TCP 区): - # host <- PLC地址 (默认 192.168.1.88) - # port <- 端口 (默认 502) - # current_p_addr <- 读取压力寄存器地址 (默认 504) - def __init__(self, host="192.168.1.88", port=502, slave_id=1, current_p_addr=504): + """ + 信捷 Easy521 PLC 的 Modbus TCP 通讯类。 + + - D 寄存器为直接 Modbus 地址:读用功能码 03(保持寄存器),写用功能码 06。 + - 模拟量映射:0~raw_max(32000)线性对应各量程满量程。 + - 电机行程仍沿用 0~x_max(1000)坐标(死区由 config 给定),映射到 0~raw_max 输出。 + + 对外接口沿用 MT2AM8Client 的 get_flow / get_pressure / set_motor_position, + 只是这里的 ``channel`` 参数是寄存器地址。 + """ + + def __init__( + self, + host="192.168.1.88", + port=502, + slave_id=1, + flow_addr=700, + flow_range=300.0, + pressure_before_addr=720, + pressure_before_range=300.0, + pressure_after_addr=710, + pressure_after_range=400.0, + motor_output_addr=730, + ): self.host = host self.port = port self.slave_id = slave_id + self.flow_addr = int(flow_addr) + self.flow_range = float(flow_range) + self.pressure_before_addr = int(pressure_before_addr) + self.pressure_before_range = float(pressure_before_range) + self.pressure_after_addr = int(pressure_after_addr) + self.pressure_after_range = float(pressure_after_range) + self.motor_output_addr = int(motor_output_addr) self.client = ModbusTcpClient( host=host, port=port, timeout=3, - retries=3 + retries=3, ) self.connected = False - self.current_p_addr0 = current_p_addr - self.current_p_addr = current_p_addr - # self.current_p_addr = 18 - self.target_p_addr = 42 - self.u_addr = 514 - # self.u_addr = 40 - # self.output_postion = 514 - self.control_flag_addr = 100 - self.M901_ADDR = 901 - self.M902_ADDR = 902 - self.M903_ADDR = 903 - self.M904_ADDR = 904 - self.M905_ADDR = 905 - self.M906_ADDR = 906 - self.q_addr = 512 def connect(self): try: - connection = self.client.connect() - if connection: - print(f"成功连接到 {self.host}:{self.port}") + conn = self.client.connect() + if conn: + print(f"成功连接到 PLC {self.host}:{self.port}") self.connected = True else: - print(f"无法连接到 {self.host}:{self.port}") + print(f"无法连接到 PLC {self.host}:{self.port}") self.connected = False - return connection + return conn except Exception as e: print(f"连接错误: {e}") self.connected = False @@ -95,181 +107,70 @@ class Easy521ModbusClient: self.connected = False print("连接已关闭") - def read_float(self, address): + def _read_register(self, address): + """读单个 16 位保持寄存器(功能码 03),返回原始整数,失败返回 None。""" try: - address = int(address) - result = self.client.read_input_registers( - address=address, - count=2, - slave=self.slave_id + result = self.client.read_holding_registers( + address=int(address), + count=1, + slave=self.slave_id, ) if not result.isError(): - decoder = BinaryPayloadDecoder.fromRegisters( - result.registers, - byteorder=Endian.BIG, - wordorder=Endian.LITTLE - ) - return decoder.decode_32bit_float() - else: - print(f"读取寄存器错误: {result}") - return None + return result.registers[0] + print(f"读取寄存器 D{int(address)} 错误: {result}") + return None except Exception as e: - print(f"读取浮点数时发生错误: {e}") + print(f"读取寄存器 D{int(address)} 异常: {e}") return None - def write_float(self, address, float_value): + def _write_register(self, address, value): + """写单个 16 位保持寄存器(功能码 06)。""" try: - address = int(address) - builder = BinaryPayloadBuilder(byteorder=Endian.LITTLE, wordorder=Endian.BIG) - builder.add_32bit_float(float_value) - payload = builder.to_registers() - result = self.client.write_registers( - address=address, - values=payload, - slave=self.slave_id + result = self.client.write_register( + address=int(address), + value=int(value), + slave=self.slave_id, ) - return not result.isError() - except Exception as e: - print(f"写入浮点数时发生错误: {e}") - return False - - def write_coil(self, address, value): - try: - address = int(address) - result = self.client.write_coil(address=address, value=value, slave=self.slave_id) - return not result.isError() - except Exception as e: - print(f"写入线圈时发生错误: {e}") - return False - - def get_current_p(self): - return self.read_float(self.current_p_addr) - - def get_current_p0(self): - return self.read_float(self.current_p_addr0) - - def get_target_p(self): - return self.read_float(self.target_p_addr) - - def get_current_q(self): - return self.read_float(self.q_addr) - - def write_u(self, float_value): - try: - address = int(self.u_addr) - builder = BinaryPayloadBuilder(byteorder=Endian.BIG, wordorder=Endian.LITTLE) - builder.add_32bit_float(float_value) - payload = builder.to_registers() - result = self.client.write_registers( - address=address, - values=payload, - slave=self.slave_id - ) - return not result.isError() - except Exception as e: - print(f"写入浮点数时发生错误: {e}") - return False - - def start_control(self): - success = self.write_coil(self.control_flag_addr, True) - if success: - print("成功写入控制标志位True") - else: - print("写入控制标志位失败") - - def stop_control(self): - success = self.write_coil(self.control_flag_addr, False) - if success: - print("成功写入控制标志位False") - else: - print("写入控制标志位失败") - - def read_rtu_flow(self, port='COM3', slave_id=2, baudrate=9600, bytesize=8, parity='N', stopbits=1): - client = ModbusSerialClient( - port=port, - baudrate=baudrate, - bytesize=bytesize, - parity=parity, - stopbits=stopbits, - timeout=3 - ) - if not client.connect(): - print(f"无法连接到串口 {port}") - return None - - try: - result = client.read_holding_registers(address=22, count=2, slave=slave_id) if result.isError(): - print(f"RTU 读取寄存器错误: {result}") - return None - decoder = BinaryPayloadDecoder.fromRegisters( - result.registers, - byteorder=Endian.BIG, - wordorder=Endian.BIG - ) - value = decoder.decode_32bit_uint() / 100 - return value + print(f"写入寄存器 D{int(address)} 错误: {result}") + return False + return True except Exception as e: + print(f"写入寄存器 D{int(address)} 异常: {e}") + return False + + def get_flow(self, address): + """读阀后流量,raw / raw_max * flow_range -> SLM。""" + raw = self._read_register(address) + if raw is None: return None - finally: - client.close() + return raw / raw_max * self.flow_range - def start_up(self): - if self.write_coil(self.M901_ADDR, True): - print("M901 置位 TRUE") - time.sleep(1) - if self.write_coil(self.M901_ADDR, False): - print("M901 复位 FALSE") - else: - print("警告:M901 复位失败") + def get_pressure(self, address): + """读压力,按寄存器地址匹配量程(阀前 300 / 阀后 400)-> kPa。""" + address = int(address) + if address == self.pressure_before_addr: + rng = self.pressure_before_range + elif address == self.pressure_after_addr: + rng = self.pressure_after_range else: - print("警告:M901 置位失败") - time.sleep(1) + print(f"未知压力寄存器 D{address},按阀后量程兜底") + rng = self.pressure_after_range + raw = self._read_register(address) + if raw is None: + return None + return raw / raw_max * rng - if self.write_coil(self.M902_ADDR, True): - print("M902 置位 TRUE") - time.sleep(1) - if self.write_coil(self.M902_ADDR, False): - print("M902 复位 FALSE") - else: - print("警告:M902 复位失败") - else: - print("警告:M902 置位失败") - time.sleep(1) - - if self.write_coil(self.M905_ADDR, True): - print("M905 (初始开度) 置位 TRUE") - time.sleep(1) - if self.write_coil(self.M905_ADDR, False): - print("M905 (初始开度) 复位 FALSE") - else: - print("警告:M905 (初始开度) 复位失败") - else: - print("警告:M905 (初始开度) 置位失败") - time.sleep(1) - - if self.write_coil(self.M903_ADDR, True): - print("M903 置位 TRUE(持续)") - else: - print("警告:M903 置位失败") - - if self.write_coil(self.M904_ADDR, True): - print("M904 置位 TRUE(持续)") - else: - print("警告:M904 置位失败") - time.sleep(1) - - if self.write_coil(self.M906_ADDR, True): - print("M906 (归零) 置位 TRUE") - time.sleep(1) - if self.write_coil(self.M906_ADDR, False): - print("M906 (归零) 复位 FALSE") - else: - print("警告:M906 (归零) 复位失败") - else: - print("警告:M906 (归零) 置位失败") - - print("初始化完成,M903 和 M904 已保持为 TRUE。") + def set_motor_position(self, position, channel=None): + """把 0~x_max 的行程映射到 0~raw_max 后写入电机输出寄存器。""" + if channel is None: + channel = self.motor_output_addr + position = float(position) + if not 0 <= position <= x_max: + print(f"行程需在 0~{x_max:.0f} 之间") + return False + raw_value = int(position / x_max * raw_max) + return self._write_register(channel, raw_value) # ---------- 新增:独立的电机 Modbus RTU 客户端类(含报文打印) ---------- diff --git a/README.md b/README.md index 4292666..d5250f4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 气体流量控制与阀门开环标定 -本项目基于艾莫迅 **MT2-AM8 模拟量采集/输出模块**,提供三个可独立运行的在线入口和两个离线工具: +本项目基于信捷 **Easy521 PLC**(Modbus TCP)提供三个可独立运行的在线入口和两个离线工具: - [main.py](main.py):以 10 Hz 周期运行单回路气体流量闭环控制,支持增量式 PID,并可选“前馈反解打底 + PI 修残差”与按阀前压力缩放增益的增益调度; - [open_loop.py](open_loop.py):随机扫描阀门开度,采集阀前/阀后的压力与阀后流量,用于开环标定、动态特性观察和临界压力比分析。 @@ -12,11 +12,11 @@ ## 重要安全说明 -> 本项目会直接控制真实阀门。首次运行前必须核对 IP、站号、AI/AO 通道、传感器量程、阀门行程方向、压力上限和执行机构的断电/失驱位置,并准备独立的机械或电气急停手段。 +> 本项目会直接控制真实阀门。首次运行前必须核对 IP、站号、寄存器地址、传感器量程、阀门行程方向、压力上限和执行机构的断电/失驱位置,并准备独立的机械或电气急停手段。 三个入口当前采用不同的阀门初始化与退出策略: -- **闭环 `main.py`**:启动时不再预先下发关闭位置,PID 内部输出由 `INITIAL_OPENING_PCT=100%` 开始;故障或退出时停止控制并断开 MT2-AM8,不主动下发关阀命令。当前系统按“执行机构失驱后回到全开”设计,现场必须确认实际硬件确实如此。 +- **闭环 `main.py`**:启动时不再预先下发关闭位置,PID 内部输出由 `INITIAL_OPENING_PCT=100%` 开始;故障或退出时停止控制并断开 PLC,不主动下发关阀命令。当前系统按“执行机构失驱后回到全开”设计,现场必须确认实际硬件确实如此。 - **开环 `open_loop.py`**:连接成功后先主动设置 `0%` 开度,实验结束、异常或 `Ctrl+C` 时再次尝试设置 `0%` 开度,然后断开设备。 - **手动测试 `test_valve.py`**:连接时仅按已知状态记录 `100%` 开度,不向电机写入初始位置;退出时不主动下发新开度,仅断开设备。该脚本不执行流量/压力越限和连续读取失败保护,只适合在具备独立安全措施的受控测试中使用。 @@ -24,23 +24,22 @@ ## 硬件与默认接线 -- MT2-AM8:Modbus TCP,默认 `192.168.1.12:502`,站号 `1`; -- AI 输入寄存器:地址 `0x00~0x03`; -- AO 保持寄存器:地址 `0x00~0x03`; -- 模拟量原始范围:`0~4095`,传感器物理量换算使用 `819~4095` 对应有效量程; -- 默认流量量程:`300 SLM`;默认压力量程:`1600 kPa`; +- PLC(Easy521):Modbus TCP,默认 `192.168.1.88:502`,站号 `1`; +- D 寄存器为直接 Modbus 地址:读用功能码 `03`(保持寄存器),写用功能码 `06`; +- 模拟量原始范围:`0~32000`,线性对应各量程满量程; +- 默认流量量程:`300 SLM`;阀后压力量程:`400 kPa`;阀前压力量程:`300 kPa`; - 电机行程范围:`0~1000`,且行程与阀门开度反向作用。 -默认通道如下,通道编号均从 `0` 开始: +默认寄存器地址与量程如下: -| 用途 | 通道 | 配置位置 | -| --- | ---: | --- | -| 闭环/阀后压力 | `AI0` | `config.PRESSURE_INPUT_CHANNEL` | -| 闭环/阀后流量 | `AI1` | `config.FLOW_INPUT_CHANNEL` | -| 阀前压力(前馈/增益调度/开环共用) | `AI2` | `config.PRESSURE_BEFORE_CHANNEL` / `open_loop.PRESSURE_BEFORE_CHANNEL` | -| 电机位置指令 | `AO1` | `config.MOTOR_OUTPUT_CHANNEL` | +| 用途 | 地址 | 量程 | 配置位置 | +| --- | ---: | ---: | --- | +| 阀后流量 | `D700` | `300 SLM` | `config.FLOW_AFTER_ADDR` | +| 阀后压力 | `D710` | `400 kPa` | `config.PRESSURE_AFTER_ADDR` | +| 阀前压力(前馈/增益调度/开环共用) | `D720` | `300 kPa` | `config.PRESSURE_BEFORE_ADDR` / `open_loop.PRESSURE_BEFORE_CHANNEL` | +| 电机位置指令(0~10V) | `D730` | 行程 `0~1000` | `config.MOTOR_OUTPUT_ADDR` | -> 控流阀前的流量传感器(原 `AI3`)已删除,`open_loop.FLOW_BEFORE_CHANNEL` 现为 `None`:开环 CSV 的 `flow_before_slm` 列与 PNG 中的 “Flow before valve” 曲线将保持为空。 +> 控流阀前的流量传感器已删除,`open_loop.FLOW_BEFORE_CHANNEL` 现为 `None`:开环 CSV 的 `flow_before_slm` 列与 PNG 中的 “Flow before valve” 曲线将保持为空。 开度与行程的默认映射为: @@ -49,7 +48,7 @@ opening = 0% -> motor_position = 1000(关闭) opening = 100% -> motor_position = 800(当前打开端/机械死区边界) ``` -虽然 `MT2AM8Client` 接受的物理行程仍为 `0~1000`,当前控制映射只使用 `800~1000`。这是近期根据阀门有效调节区间设置的打开端边界;逻辑 `100%` 不再对应物理行程 `0`。 +虽然硬件层行程坐标仍为 `0~1000`,当前控制映射只使用 `800~1000`。这是近期根据阀门有效调节区间设置的打开端边界;逻辑 `100%` 不再对应物理行程 `0`。 所有闭环通道、量程和行程端值集中在 [config.py](config.py);开环新增的阀前通道和实验参数位于 [open_loop.py](open_loop.py) 顶部。 @@ -97,7 +96,7 @@ python main.py --target 50 启动流程为: ```text -校验配置 -> 连接 MT2-AM8 -> 读取一次初始流量 +校验配置 -> 连接 PLC -> 读取一次初始流量 -> PID 输出初始化为 INITIAL_OPENING_PCT -> 进入周期控制(启动阶段不主动写入关闭位置) ``` @@ -118,7 +117,7 @@ error = target - measurement opening_to_motor_position() | v -反向行程 1000~800 -> MT2-AM8 AO1 +反向行程 1000~800 -> PLC D730 ``` 增量 PID 的输出直接定义为阀门开度,因此控制器本身不包含电机行程或死区映射。目标流量不高于 `ZERO_FLOW_THRESHOLD_SLM`(当前 `0.5 SLM`)时,程序跳过常规 PID 更新并直接命令 `0%` 开度。 @@ -187,9 +186,9 @@ Kp_eff = scale · Kp_base,Ki_eff = scale · Ki_base # Kd 不调度 | `FEEDFORWARD_ENABLED` | `False` | 启用前馈反解打底 + PI 修残差 | | `GAIN_SCHEDULE_ENABLED` | `False` | 启用按阀前压力缩放 Kp/Ki | | `VALVE_MODEL_PATH` | `""` | 阀特性表 JSON 路径(空串 = 关闭前馈) | -| `PRESSURE_BEFORE_CHANNEL` | `2` | 阀前压力 P1 的 AI 通道 | +| `PRESSURE_BEFORE_ADDR` | `720` | 阀前压力 P1 的寄存器地址(D720) | -前馈生效需同时满足:`FEEDFORWARD_ENABLED=True`、`VALVE_MODEL_PATH` 指向有效 JSON、`PRESSURE_BEFORE_CHANNEL` 与 `PRESSURE_INPUT_CHANNEL` 均已配置。任一条件缺失时自动退化为纯 PID,并在启动日志中提示。 +前馈生效需同时满足:`FEEDFORWARD_ENABLED=True`、`VALVE_MODEL_PATH` 指向有效 JSON、`PRESSURE_BEFORE_ADDR` 与 `PRESSURE_AFTER_ADDR` 均已配置。任一条件缺失时自动退化为纯 PID,并在启动日志中提示。 ### 运行输出 @@ -290,18 +289,20 @@ python test_valve.py ## 阀特性辨识与离线自测 -前馈需要一张 `A_eff(x)` 阀特性表。先用 [open_loop.py](open_loop.py) 扫点得到开环 CSV,再用 [identify_valve.py](identify_valve.py) 离线拟合: +前馈需要一张 `A_eff(x)` 阀特性表。先用 [open_loop.py](open_loop.py) 扫点得到开环 CSV(时间戳命名、从不覆盖),再用 [identify_valve.py](identify_valve.py) 离线拟合。`--csv` 支持多个路径与 glob 模式,可把多次实验合并重拟合,数据越多表越稳: ```powershell -python identify_valve.py --csv open_loop_data/open_loop_YYYYMMDD_HHMMSS.csv --out valve_model.json [--tail 20] [--plot] +python identify_valve.py --csv open_loop_data/open_loop_YYYYMMDD_HHMMSS.csv --out valve_model.json +python identify_valve.py --csv "open_loop_data/*.csv" --out valve_model.json --plot +python identify_valve.py --plot # 省略 --csv 时默认合并 open_loop_data/ 下全部 CSV ``` -- 按 `step_index` 分组,每组取末尾 `--tail` 个采样点平均,得到稳态 `(行程 x, 流量 Q, 阀前压 P1, 阀后压 P2)`; -- 对每个稳态点反解 `A_eff = Q / (P1_abs · F(r))`; +- 每个 CSV 按 `step_index` 分组,每组取末尾 `--tail` 个采样点平均,得到稳态 `(行程 x, 流量 Q, 阀前压 P1, 阀后压 P2)`,并按开度访问顺序标记逼近方向(上升 / 下降 / 该文件起始步); +- 对每个稳态点反解 `A_eff = Q / (P1_abs · F(r))`;同一行程 x 的多个点(来自不同次实验)先取平均再构表; - 按行程升序构表并做单调性检查;若整体非单调,只保留最长单调连续段,其余点丢弃并给出警告; -- 用 [valve_model.py](valve_model.py) 存成 JSON,并打印阻塞/亚声速点数等摘要;加 `--plot` 额外生成诊断图。 +- 用 [valve_model.py](valve_model.py) 存成 JSON,并打印合并文件数、稳态点数与阻塞/亚声速点统计等摘要;加 `--plot` 额外生成诊断图,实测点按逼近方向着色:同一行程下上升/下降两团点明显分离说明阀门迟滞显著,前馈表应考虑分方向处理;完全重叠则迟滞可忽略。 -将 `config.VALVE_MODEL_PATH` 指到该 JSON、确认 `PRESSURE_BEFORE_CHANNEL` 接线无误、并把 `FEEDFORWARD_ENABLED` 设为 `True` 后,闭环即可启用前馈。 +将 `config.VALVE_MODEL_PATH` 指到该 JSON、确认 `PRESSURE_BEFORE_ADDR` 接线无误、并把 `FEEDFORWARD_ENABLED` 设为 `True` 后,闭环即可启用前馈。 [test_valve_model.py](test_valve_model.py) 是纯计算离线自测,不接触硬件,可在任意环境运行: @@ -342,10 +343,11 @@ python test_valve_model.py | 配置 | 当前默认值 | 说明 | | --- | --- | --- | -| `MT2AM8_HOST` / `MT2AM8_PORT` / `MT2AM8_SLAVE_ID` | `192.168.1.12` / `502` / `1` | 通讯参数 | -| `FLOW_INPUT_CHANNEL` / `PRESSURE_INPUT_CHANNEL` | `1` / `0` | 闭环 AI 通道;压力可设为 `None` | -| `MOTOR_OUTPUT_CHANNEL` | `1` | 电机 AO 通道 | -| `FLOW_METER_RANGE_SLM` / `PRESSURE_RANGE_KPA` | `300` / `1600` | 传感器量程 | +| `PLC_HOST` / `PLC_PORT` / `PLC_SLAVE_ID` | `192.168.1.88` / `502` / `1` | PLC 通讯参数 | +| `FLOW_AFTER_ADDR` / `FLOW_AFTER_RANGE_SLM` | `700` / `300` | 阀后流量 D700 与量程 | +| `PRESSURE_AFTER_ADDR` / `PRESSURE_AFTER_RANGE_KPA` | `710` / `400` | 阀后压力 D710 与量程 | +| `PRESSURE_BEFORE_ADDR` / `PRESSURE_BEFORE_RANGE_KPA` | `720` / `300` | 阀前压力 D720 与量程 | +| `MOTOR_OUTPUT_ADDR` | `730` | 电机输出 D730(0~10V,行程 0~1000) | | `TARGET_FLOW_MIN_SLM` / `TARGET_FLOW_MAX_SLM` | `0` / `300` | 允许的目标范围 | | `TARGET_SWITCH_KEY` | `s` | 运行中按此键进入"输入新目标流量"模式 | | `CONTROL_PERIOD_S` / `MAX_CONTROL_DT_S` | `0.1` / `0.3` | 目标周期 / 超时阈值 | @@ -358,7 +360,7 @@ python test_valve_model.py | `PID_MODE_SWITCH_CONFIRM_CYCLES` | `10` | 模式切换条件需连续满足的采样周期数(当前约 1 秒) | | `MOTOR_CLOSED_POSITION` / `MOTOR_OPEN_POSITION` | `1000` / `800` | 当前反向控制区间;打开端为机械死区边界 | | `INITIAL_OPENING_PCT` | `100` | 闭环 PID 内部初始输出 | -| `PRESSURE_BEFORE_CHANNEL` | `2` | 阀前压力 P1 的 AI 通道(前馈/增益调度/开环共用);可设为 `None` | +| `PRESSURE_BEFORE_ADDR` | `720` | 阀前压力 P1 的寄存器地址(前馈/增益调度/开环共用) | | `VALVE_MODEL_PATH` | `""` | 阀特性表 JSON 路径;空串关闭前馈 | | `FEEDFORWARD_ENABLED` | `False` | 是否启用前馈反解打底 + PI 修残差 | | `FEEDFORWARD_CORRECTION_BAND_PCT` | `20` | 前馈开启时 PI 修正量的对称限幅(±%) | @@ -385,9 +387,9 @@ python test_valve_model.py | [open_loop.py](open_loop.py) | 独立开环扫点、传感器记录和压力比分析 | | [test_valve.py](test_valve.py) | Windows 终端手动开度测试、每秒采样、CSV 与三联图 | | [valve_model.py](valve_model.py) | 静态阀特性模型 `Q_ss = A_eff(x)·P1_abs·F(r)` 与前馈反解(纯计算) | -| [identify_valve.py](identify_valve.py) | 从开环扫点 CSV 拟合 `A_eff(x)` 表,输出 `valve_model.json` | +| [identify_valve.py](identify_valve.py) | 从一份或多份开环扫点 CSV(支持 glob 合并)拟合 `A_eff(x)` 表,输出 `valve_model.json` | | [test_valve_model.py](test_valve_model.py) | 阀特性模型与前馈反解的离线自测 | -| [PcControl.py](PcControl.py) | Modbus 硬件客户端;本项目三个入口实际使用 `MT2AM8Client` | +| [PcControl.py](PcControl.py) | Modbus 硬件客户端;本项目三个入口实际使用 `Easy521ModbusClient` | ## 项目结构 diff --git a/config.py b/config.py index ff53674..0a1d09f 100644 --- a/config.py +++ b/config.py @@ -1,11 +1,11 @@ """气体流量闭环控制的集中配置。 -首次连接真实设备前,请重点核对:MT2-AM8 地址、AI/AO 通道、压力上限、 -阀门打开端行程和关闭端行程。PID 参数是安全起步值,不是最终整定结果。 +首次连接真实设备前,请重点核对:PLC 寄存器地址、量程、阀门打开端行程和 +关闭端行程。PID 参数是安全起步值,不是最终整定结果。 """ # --------------------------------------------------------------------------- -# MT2-AM8 通讯与量程 +# MT2-AM8 通讯与量程(旧方案;代码保留但当前不再使用) # --------------------------------------------------------------------------- MT2AM8_HOST = "192.168.1.12" @@ -13,13 +13,27 @@ MT2AM8_PORT = 502 MT2AM8_SLAVE_ID = 1 # MT2AM8Client 用这些量程把模拟量转换成物理量。 -PRESSURE_RANGE_KPA = 1600.0 +PRESSURE_RANGE_KPA = 400.0 FLOW_METER_RANGE_SLM = 300.0 -# AI/AO 通道均从 0 开始。AI0 和 AO0 属于不同的寄存器区,可以同时使用。 -FLOW_INPUT_CHANNEL = 1 -PRESSURE_INPUT_CHANNEL = 0 # 没有压力传感器时改为 None -MOTOR_OUTPUT_CHANNEL = 1 +# --------------------------------------------------------------------------- +# PLC(Easy521)Modbus TCP 通讯与寄存器地址 +# --------------------------------------------------------------------------- + +PLC_HOST = "192.168.1.88" +PLC_PORT = 502 +PLC_SLAVE_ID = 1 + +# D 寄存器为直接 Modbus 地址:读用功能码 03(保持寄存器),写用功能码 06。 +# 模拟量 0~32000 线性对应各量程满量程(映射常量在 PcControl.py)。 +# 三个测量量 + 一个输出,地址与量程集中放在一起(名称区分阀前 / 阀后)。 +FLOW_AFTER_ADDR = 700 # 阀后流量 D700,量程 0~300 SLM +FLOW_AFTER_RANGE_SLM = 300.0 +PRESSURE_AFTER_ADDR = 710 # 阀后压力 D710,量程 0~400 kPa +PRESSURE_AFTER_RANGE_KPA = 400.0 +PRESSURE_BEFORE_ADDR = 720 # 阀前压力 D720,量程 0~300 kPa +PRESSURE_BEFORE_RANGE_KPA = 300.0 +MOTOR_OUTPUT_ADDR = 730 # 电机输出 D730,0~10V,行程 0~1000 映射 0~32000 # --------------------------------------------------------------------------- # 控制目标和控制周期 @@ -49,8 +63,8 @@ PID_NEAR_KD = 0.0 OPENING_MIN_PCT = 0.0 OPENING_MAX_PCT = 100.0 -MAX_OPENING_RATE_FAR_PCT_S = 20.0 -MAX_OPENING_RATE_NEAR_PCT_S = 2.0 +MAX_OPENING_RATE_FAR_PCT_S = 200.0 +MAX_OPENING_RATE_NEAR_PCT_S = 20.0 # FAR -> NEAR:绝对误差连续满足 near 阈值指定周期数后切换。 # NEAR -> FAR:绝对误差连续达到 far 阈值指定周期数后切换。 @@ -87,8 +101,7 @@ INITIAL_OPENING_PCT = 100.0 # 前馈 + 增益调度(默认关闭,便于与纯 PID 做 A/B 对比;接线确认后再开启) # --------------------------------------------------------------------------- -# 阀前压力 P1(前馈缩放 / 判阻塞 / 增益调度用),按实际接线确认。 -PRESSURE_BEFORE_CHANNEL = 2 +# 阀前压力 P1(前馈缩放 / 判阻塞 / 增益调度用),地址见上 PLC 段 PRESSURE_BEFORE_ADDR。 # 阀特性表 JSON(identify_valve.py 产出)。空字符串表示关闭前馈。 VALVE_MODEL_PATH = "" @@ -138,18 +151,18 @@ def validate_config(): raise ValueError("MAX_CONTROL_DT_S 不得小于 CONTROL_PERIOD_S") if TARGET_FLOW_MIN_SLM > TARGET_FLOW_MAX_SLM: raise ValueError("目标流量上下限配置错误") - if TARGET_FLOW_MAX_SLM > FLOW_METER_RANGE_SLM: + if TARGET_FLOW_MAX_SLM > FLOW_AFTER_RANGE_SLM: raise ValueError("目标流量上限不得超过流量计量程") if FLOW_VALID_MIN_SLM > FLOW_VALID_MAX_SLM: raise ValueError("流量有效范围上下限配置错误") - if FLOW_VALID_MAX_SLM > FLOW_METER_RANGE_SLM: + if FLOW_VALID_MAX_SLM > FLOW_AFTER_RANGE_SLM: raise ValueError("流量有效范围上限不得超过流量计量程") if not 0 <= OPENING_MIN_PCT < OPENING_MAX_PCT <= 100: raise ValueError("阀门开度范围必须位于 0~100%,且下限小于上限") if MOTOR_OPEN_POSITION >= MOTOR_CLOSED_POSITION: raise ValueError("本系统行程越大开度越小,因此打开端行程必须小于关闭端行程") if MOTOR_OPEN_POSITION < 0 or MOTOR_CLOSED_POSITION > 1000: - raise ValueError("电机行程必须位于 MT2AM8Client 当前采用的 0~1000 范围") + raise ValueError("电机行程必须位于当前采用的 0~1000 范围") pid_gains = ( PID_FAR_KP, PID_FAR_KI, @@ -181,13 +194,13 @@ def validate_config(): raise ValueError("PID_MODE_SWITCH_CONFIRM_CYCLES 必须至少为 1") if 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_AFTER_ADDR is not None and MAX_PRESSURE_KPA is None: raise ValueError("启用压力通道时必须配置 MAX_PRESSURE_KPA") - if PRESSURE_BEFORE_CHANNEL is not None: - if not isinstance(PRESSURE_BEFORE_CHANNEL, int): - raise ValueError("PRESSURE_BEFORE_CHANNEL 必须为整数通道号或 None") - if PRESSURE_BEFORE_CHANNEL < 0: - raise ValueError("PRESSURE_BEFORE_CHANNEL 不得为负数") + if PRESSURE_BEFORE_ADDR is not None: + if not isinstance(PRESSURE_BEFORE_ADDR, int): + raise ValueError("PRESSURE_BEFORE_ADDR 必须为整数地址或 None") + if PRESSURE_BEFORE_ADDR < 0: + raise ValueError("PRESSURE_BEFORE_ADDR 不得为负数") if MAX_PRESSURE_KPA is None: raise ValueError("启用阀前压力通道时必须配置 MAX_PRESSURE_KPA") if GAIN_SCHEDULE_REF_ABS_KPA <= 0: diff --git a/flow_control.py b/flow_control.py index e445140..0d36ee4 100644 --- a/flow_control.py +++ b/flow_control.py @@ -1,7 +1,7 @@ """流量闭环控制层。 本模块只负责“读取传感器 -> PID -> 开度/行程映射 -> 下发电机位置”以及 -安全处理;底层 Modbus 通讯仍由 PcControl.MT2AM8Client 完成。 +安全处理;底层 Modbus 通讯仍由 PcControl.Easy521ModbusClient 完成。 """ from dataclasses import asdict, dataclass @@ -75,7 +75,7 @@ def opening_to_motor_position( class FlowControlLoop: - """基于 MT2AM8Client 和 IncrementalPID 的单回路流量控制器。""" + """基于 Easy521ModbusClient 和 IncrementalPID 的单回路流量控制器。""" def __init__( self, @@ -352,7 +352,7 @@ class FlowControlLoop: self._trip( "DEVICE_DISCONNECTED", "PRECHECK", - "MT2-AM8 未连接", + "PLC 未连接", ) current_time = time.perf_counter() if now is None else float(now) @@ -763,7 +763,7 @@ class FlowControlLoop: self._trip( "MOTOR_COMMAND_FAILED", "WRITE_MOTOR", - "MT2-AM8 返回电机位置写入失败", + "PLC 返回电机位置写入失败", { "requested_opening_pct": opening, "requested_motor_position": position, diff --git a/identify_valve.py b/identify_valve.py index 21926ee..056017c 100644 --- a/identify_valve.py +++ b/identify_valve.py @@ -1,19 +1,26 @@ """从开环扫点 CSV 拟合 A_eff(x) 表,输出 valve_model.json。 用法: - python identify_valve.py --csv open_loop_data/open_loop_xxx.csv \\ - --out valve_model.json [--tail 20] [--plot] + python identify_valve.py --csv open_loop_data/open_loop_xxx.csv --out valve_model.json + python identify_valve.py --csv "open_loop_data/*.csv" --out valve_model.json --plot + python identify_valve.py --plot # 省略 --csv 时默认合并 open_loop_data/ 下全部 CSV 流程: -1. 按 step_index 分组,每组取末尾 --tail 个采样点平均,得到稳态 (x, Q, P1, P2); -2. 对每个稳态点反解 A_eff = Q / (P1_abs * F(r)); -3. 按行程升序构表,做单调性检查; -4. 用 ValveModel 存成 JSON,并打印摘要(可选画诊断图)。 +1. --csv 支持多个路径与 glob 模式,全部 CSV 合并使用(文件名含时间戳,按名称 + 排序即按实验先后合并);省略时默认 open_loop_data/*.csv; +2. 每个 CSV 按 step_index 分组,每组取末尾 --tail 个采样点平均,得到稳态 + (x, Q, P1, P2),并按开度访问顺序标记逼近方向(上升/下降/该文件起始步); +3. 对每个稳态点反解 A_eff = Q / (P1_abs * F(r)),同一行程 x 的多个点 + (来自不同次实验)先平均再构表; +4. 按行程升序构表,做单调性检查; +5. 用 ValveModel 存成 JSON,并打印摘要(可选画诊断图,实测点按逼近方向着色)。 """ import argparse import csv +import glob import math +import os from pathlib import Path import config @@ -21,6 +28,21 @@ from valve_model import CRITICAL_RATIO, ValveModel, abs_pressure, f_ratio DEFAULT_TAIL = 20 +# 逼近方向:按开度访问顺序,当前开度大于/小于上一开度。 +DIRECTION_UP = "up" # 从更小开度升到当前开度 +DIRECTION_DOWN = "down" # 从更大开度降到当前开度 +DIRECTION_ORDER = [DIRECTION_UP, DIRECTION_DOWN, None] +DIRECTION_LABELS = { + DIRECTION_UP: "ascending", + DIRECTION_DOWN: "descending", + None: "start (unknown)", +} +DIRECTION_COLORS = { + DIRECTION_UP: "#1565C0", + DIRECTION_DOWN: "#D84315", + None: "#757575", +} + def _to_float(text): """把 CSV 单元格解析成有限浮点数;空/非法/非有限返回 None。""" @@ -43,8 +65,14 @@ def _average(values): def load_steady_points(csv_path, tail=DEFAULT_TAIL): - """提取每个 step_index 的稳态均值,返回按 step 升序的 (x, q, p1, p2) 列表。""" + """提取单个 CSV 每个 step_index 的稳态均值与逼近方向。 + + 返回按 step(访问顺序)排列的 ``(x, q, p1, p2, direction)`` 列表。 + direction 为 ``"up"``(开度上升逼近)/ ``"down"``(下降逼近)/ + ``None``(该文件第一步,无上一开度可比较)。 + """ rows_by_step = {} + opening_by_step = {} with open(csv_path, "r", newline="", encoding="utf-8-sig") as file: for row in csv.DictReader(file): step_text = (row.get("step_index") or "").strip() @@ -55,9 +83,25 @@ def load_steady_points(csv_path, tail=DEFAULT_TAIL): except (TypeError, ValueError): continue rows_by_step.setdefault(step, []).append(row) + if step not in opening_by_step: + opening_by_step[step] = _to_float(row.get("opening_pct")) points = [] + prev_opening = None for step in sorted(rows_by_step): + opening = opening_by_step.get(step) + if prev_opening is not None and opening is not None: + if opening > prev_opening: + direction = DIRECTION_UP + elif opening < prev_opening: + direction = DIRECTION_DOWN + else: + direction = None + else: + direction = None + if opening is not None: + prev_opening = opening + rows = rows_by_step[step] rows.sort(key=lambda r: _to_float(r.get("time_s")) or 0.0) tail_rows = rows[-tail:] if tail > 0 else rows @@ -69,19 +113,23 @@ def load_steady_points(csv_path, tail=DEFAULT_TAIL): if None in (x, q, p1, p2): continue - points.append((x, q, p1, p2)) + points.append((x, q, p1, p2, direction)) return points def compute_area_table(steady_points): - """把稳态点换算成 (x, A_eff) 单调表,并返回丢弃点与阻塞/亚声速统计。 + """把稳态点换算成 (x, A_eff) 单调表,并返回逐点测量值供诊断图着色。 - 返回 ``(table, dropped, stats)``。``table`` 为按 x 升序的 (x, A_eff) 列表, - 只保留最长的单调连续段;``dropped`` 为因非单调被丢弃的点。 + 返回 ``(table, dropped, stats, area_points)``: + + - ``area_points``:每个实测点的 ``(x, A_eff, direction)``,按 x 升序,未平均; + - ``table``:同一行程 x 的多个 A_eff 先平均,再按 x 升序取最长单调连续段; + - ``dropped``:因非单调被丢弃的 ``(x, A_eff)``; + - ``stats``:阻塞/亚声速点统计(按原始测量点计数)。 """ area_points = [] choked = subsonic = 0 - for x, q, p1, p2 in steady_points: + for x, q, p1, p2, direction in steady_points: p1_abs = abs_pressure(p1) p2_abs = abs_pressure(p2) if p1_abs <= 0.0: @@ -95,21 +143,28 @@ def compute_area_table(steady_points): choked += 1 else: subsonic += 1 - area_points.append((x, q / denom)) + area_points.append((x, q / denom, direction)) area_points.sort(key=lambda item: item[0]) - ys = [a for _, a in area_points] + # 合并多份 CSV 后同一行程会有多个点:先按 x 分组平均(CSV 以 6 位小数 + # 记录行程,round 用于吸收极小浮点差异)。 + by_x = {} + for x, a, _ in area_points: + by_x.setdefault(round(x, 6), []).append(a) + merged = [(float(x), sum(vals) / len(vals)) for x, vals in sorted(by_x.items())] + + ys = [a for _, a in merged] if _is_monotonic(ys): - table = area_points + table = merged dropped = [] else: start, end = _longest_monotonic_run(ys) - table = area_points[start:end + 1] - dropped = area_points[:start] + area_points[end + 1:] + table = merged[start:end + 1] + dropped = merged[:start] + merged[end + 1:] stats = {"choked": choked, "subsonic": subsonic, "total": choked + subsonic} - return table, dropped, stats + return table, dropped, stats, area_points def _is_monotonic(ys): @@ -151,14 +206,22 @@ def _longest_monotonic_run(ys): return best_start, best_end -def identify(csv_path, tail=DEFAULT_TAIL, out_path=None): - """端到端辨识;返回 ``(model, steady_points, table, dropped, stats)``。""" - steady = load_steady_points(csv_path, tail) - table, dropped, stats = compute_area_table(steady) +def identify(csv_paths, tail=DEFAULT_TAIL, out_path=None): + """从一份或多份开环扫点 CSV 端到端辨识。 + + 返回 ``(model, steady_points, table, dropped, stats, area_points)``。 + ``csv_paths`` 为路径列表,其稳态点合并后统一拟合。 + """ + steady = [] + for path in csv_paths: + steady.extend(load_steady_points(path, tail)) + table, dropped, stats, area_points = compute_area_table(steady) if len(table) < 2: raise ValueError( - f"有效稳态点不足({len(table)} 个),无法构表。请确认 CSV 的 " - "pressure_before_kpa / pressure_after_kpa / flow_after_slm 列读数合理。" + f"有效稳态点不足({len(table)} 个),无法构表。请确认 CSV 为 " + "open_loop.py 产出(需含 step_index 列),且 " + "pressure_before_kpa / pressure_after_kpa / flow_after_slm " + "列读数合理。" ) model = ValveModel( table, @@ -167,11 +230,69 @@ def identify(csv_path, tail=DEFAULT_TAIL, out_path=None): ) if out_path: model.save(out_path) - return model, steady, table, dropped, stats + return model, steady, table, dropped, stats, area_points -def create_diagnostic_plot(steady_points, table, dropped, image_path): - """画 x vs A_eff(散点+插值线)与 x vs Q_ss(散点)两张子图。""" +def resolve_csv_paths(items=None): + """把 CLI 传入的路径/glob 展开为去重、按名称排序的 CSV 路径列表。 + + ``items`` 为空时默认使用工程目录下 ``open_loop_data/*.csv``(文件名含 + 时间戳,排序后即按实验先后合并)。 + """ + if not items: + default_dir = Path(__file__).resolve().parent / "open_loop_data" + items = [str(default_dir / "*.csv")] + paths = [] + for item in items: + pattern = str(item).replace("\\", "/") + if glob.has_magic(pattern): + paths.extend(sorted(glob.glob(pattern))) + else: + paths.append(pattern) + # 去重:glob 结果与显式路径的斜杠方向可能不同,用 normcase 归一化比较 + # (Windows 下同时统一大小写)。 + unique = [] + seen = set() + for path in paths: + key = os.path.normcase(path) + if key not in seen: + seen.add(key) + unique.append(path) + return unique + + +def _count_directions(steady_points): + counts = {DIRECTION_UP: 0, DIRECTION_DOWN: 0, None: 0} + for point in steady_points: + counts[point[4]] += 1 + return counts + + +def _scatter_by_direction(axis, points): + """按逼近方向分色画散点;``points`` 为 ``(x, y, direction)`` 列表。""" + by_direction = {d: [] for d in DIRECTION_ORDER} + for x, y, direction in points: + by_direction.setdefault(direction, []).append((x, y)) + for direction in DIRECTION_ORDER: + pts = by_direction.get(direction, []) + if not pts: + continue + axis.scatter( + [p[0] for p in pts], + [p[1] for p in pts], + color=DIRECTION_COLORS[direction], + label=DIRECTION_LABELS[direction], + s=18, + alpha=0.85, + ) + + +def create_diagnostic_plot(steady_points, table, dropped, area_points, image_path): + """画 x vs A_eff 与 x vs Q_ss 两张子图,实测点按逼近方向着色。 + + 同一行程下上升/下降两团点明显分离,说明阀门迟滞显著,前馈表应考虑 + 分方向处理;完全重叠则迟滞可忽略。 + """ import matplotlib matplotlib.use("Agg") @@ -183,13 +304,13 @@ def create_diagnostic_plot(steady_points, table, dropped, image_path): figure, axes = plt.subplots(2, 1, figsize=(10, 8), constrained_layout=True) figure.suptitle("Identified valve characteristic") - axes[0].scatter(xs, areas, color="#1565C0", label="measured A_eff") - axes[0].plot(xs, areas, color="#1565C0", linewidth=1.2) + axes[0].plot(xs, areas, color="#263238", linewidth=1.2, label="fitted A_eff (per-x mean)") + _scatter_by_direction(axes[0], area_points) if dropped: axes[0].scatter( [x for x, _ in dropped], [a for _, a in dropped], - color="#D84315", + color="#B71C1C", marker="x", label="dropped (non-monotonic)", ) @@ -198,9 +319,9 @@ def create_diagnostic_plot(steady_points, table, dropped, image_path): axes[0].legend(loc="best") axes[0].grid(True, alpha=0.3, linestyle="--") - qxs = [x for x, _, _, _ in steady_points] - qs = [q for _, q, _, _ in steady_points] - axes[1].scatter(qxs, qs, color="#2E7D32", label="measured Q_ss") + _scatter_by_direction( + axes[1], [(p[0], p[1], p[4]) for p in steady_points] + ) axes[1].set_ylabel("Flow (SLM)") axes[1].set_xlabel("Motor stroke x") axes[1].set_title("Steady flow vs stroke") @@ -213,9 +334,12 @@ def create_diagnostic_plot(steady_points, table, dropped, image_path): def parse_args(argv=None): parser = argparse.ArgumentParser( - description="从开环扫点 CSV 拟合 A_eff(x) 阀特性表" + description="从开环扫点 CSV 拟合 A_eff(x) 阀特性表(支持多 CSV/glob 合并)" + ) + parser.add_argument( + "--csv", nargs="+", + help="一个或多个 CSV 路径或 glob 模式;省略时默认合并 open_loop_data/*.csv", ) - parser.add_argument("--csv", required=True, help="开环扫点 CSV 路径") parser.add_argument("--out", default="valve_model.json", help="输出 JSON 路径") parser.add_argument( "--tail", type=int, default=DEFAULT_TAIL, @@ -230,26 +354,40 @@ def parse_args(argv=None): def main(argv=None): args = parse_args(argv) - csv_path = Path(args.csv) - if not csv_path.exists(): - print(f"CSV 不存在:{csv_path}") + csv_paths = resolve_csv_paths(args.csv) + if not csv_paths: + print("未找到任何 CSV:请确认 open_loop_data/ 下有开环扫点数据,") + print('或用 --csv 指定路径或 glob 模式(如 --csv "open_loop_data/*.csv")。') return 2 + for path in csv_paths: + if not Path(path).exists(): + print(f"CSV 不存在:{path}") + return 2 try: - model, steady, table, dropped, stats = identify( - csv_path, tail=args.tail, out_path=args.out + model, steady, table, dropped, stats, area_points = identify( + csv_paths, tail=args.tail, out_path=args.out ) except (ValueError, OSError) as exc: print(f"辨识失败:{exc}") return 2 + if len(csv_paths) <= 8: + print("合并的 CSV:") + for path in csv_paths: + print(f" {path}") + else: + print(f"合并的 CSV:{csv_paths[0]} 等共 {len(csv_paths)} 个文件") + directions = _count_directions(steady) print( - f"读取稳态点 {len(steady)} 个;阻塞 {stats['choked']} 个," - f"亚声速 {stats['subsonic']} 个,合计 {stats['total']} 个。" + f"读取稳态点 {len(steady)} 个:上升逼近 {directions[DIRECTION_UP]}、" + f"下降逼近 {directions[DIRECTION_DOWN]}、起始点 {directions[None]};" + f"阻塞 {stats['choked']} 个,亚声速 {stats['subsonic']} 个," + f"合计 {stats['total']} 个。" ) print( f"有效行程范围 {table[0][0]:.1f} ~ {table[-1][0]:.1f}" - f"({len(table)} 点)。" + f"({len(table)} 点,同一行程已平均)。" ) if dropped: @@ -270,7 +408,7 @@ def main(argv=None): if args.plot: image_path = Path(args.out).with_suffix(".png") try: - create_diagnostic_plot(steady, table, dropped, image_path) + create_diagnostic_plot(steady, table, dropped, area_points, image_path) print(f"诊断图已保存:{image_path}") except Exception as exc: print(f"生成诊断图失败(不影响 JSON):{exc}") diff --git a/main.py b/main.py index 7e32c6a..f3d76e6 100644 --- a/main.py +++ b/main.py @@ -4,7 +4,7 @@ python main.py --target 50 程序启动后直接从当前阀门开度(默认全开)开始闭环。Ctrl+C、控制故障或 -其他异常会断开 MT2-AM8;阀门不被驱动时默认回到全开。 +其他异常会断开 PLC;阀门不被驱动时默认回到全开。 """ import argparse @@ -96,10 +96,10 @@ def build_control_loop(hardware, logger): hardware=hardware, pid=pid, period_s=config.CONTROL_PERIOD_S, - flow_channel=config.FLOW_INPUT_CHANNEL, - pressure_channel=config.PRESSURE_INPUT_CHANNEL, - pressure_before_channel=config.PRESSURE_BEFORE_CHANNEL, - motor_channel=config.MOTOR_OUTPUT_CHANNEL, + flow_channel=config.FLOW_AFTER_ADDR, + pressure_channel=config.PRESSURE_AFTER_ADDR, + pressure_before_channel=config.PRESSURE_BEFORE_ADDR, + motor_channel=config.MOTOR_OUTPUT_ADDR, motor_open_position=config.MOTOR_OPEN_POSITION, motor_closed_position=config.MOTOR_CLOSED_POSITION, target_min_slm=config.TARGET_FLOW_MIN_SLM, @@ -148,7 +148,7 @@ def build_control_loop(hardware, logger): def parse_args(argv=None): - parser = argparse.ArgumentParser(description="MT2-AM8 气体流量 PID 控制") + parser = argparse.ArgumentParser(description="PLC 气体流量 PID 控制") parser.add_argument( "--target", type=float, @@ -197,7 +197,7 @@ def run(target_flow_slm): config.validate_config() logger, console_gate = build_logger() try: - from PcControl import MT2AM8Client + from PcControl import Easy521ModbusClient except ModuleNotFoundError as exc: if exc.name and exc.name.startswith("pymodbus"): logger.critical( @@ -207,12 +207,17 @@ def run(target_flow_slm): return 6 raise - hardware = MT2AM8Client( - host=config.MT2AM8_HOST, - port=config.MT2AM8_PORT, - slave_id=config.MT2AM8_SLAVE_ID, - pressure_range=config.PRESSURE_RANGE_KPA, - flow_range=config.FLOW_METER_RANGE_SLM, + 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, ) controller = build_control_loop(hardware, logger) controller.set_target_flow(target_flow_slm) @@ -223,9 +228,9 @@ def run(target_flow_slm): exit_code = 0 try: logger.info( - "正在连接 MT2-AM8 %s:%s,目标流量=%.3f SLM", - config.MT2AM8_HOST, - config.MT2AM8_PORT, + "正在连接 PLC %s:%s,目标流量=%.3f SLM", + config.PLC_HOST, + config.PLC_PORT, target_flow_slm, ) connected = bool(hardware.connect()) @@ -233,17 +238,17 @@ def run(target_flow_slm): raise FlowControlFault( "DEVICE_CONNECT_FAILED", "STARTUP", - "无法连接 MT2-AM8", + "无法连接 PLC", { - "host": config.MT2AM8_HOST, - "port": config.MT2AM8_PORT, - "slave_id": config.MT2AM8_SLAVE_ID, + "host": config.PLC_HOST, + "port": config.PLC_PORT, + "slave_id": config.PLC_SLAVE_ID, }, ) # 控制前读取一次当前流量,作为超调量方向的基准。 try: - initial_flow_slm = hardware.get_flow(config.FLOW_INPUT_CHANNEL) + initial_flow_slm = hardware.get_flow(config.FLOW_AFTER_ADDR) except Exception: initial_flow_slm = None @@ -376,7 +381,7 @@ def run(target_flow_slm): hardware.disconnect() except Exception: exit_code = max(exit_code, 5) - logger.exception("断开 MT2-AM8 时发生异常") + logger.exception("断开 PLC 时发生异常") if recorder is not None: try: diff --git a/open_loop.py b/open_loop.py index ef70719..53596f9 100644 --- a/open_loop.py +++ b/open_loop.py @@ -36,15 +36,15 @@ from flow_control import opening_to_motor_position # 实验参数(可在本文件修改,不影响 config.py) # --------------------------------------------------------------------------- -# 四个传感器的 AI 通道(0 起始,MT2-AM8 AI0~AI3)。 -# “后”= 控流阀出口侧(靠近流量计/负载一侧),沿用 config 的原始通道; -# “前”= 控流阀入口侧(靠近减压阀一侧),为本次新增传感器,请按实际接线确认。 -PRESSURE_AFTER_CHANNEL = config.PRESSURE_INPUT_CHANNEL # 后压力,默认 0 -FLOW_AFTER_CHANNEL = config.FLOW_INPUT_CHANNEL # 后流量,默认 1 -PRESSURE_BEFORE_CHANNEL = 2 # 前压力(新增,需确认) -FLOW_BEFORE_CHANNEL = None # 前流量(新增,需确认) +# 控流阀前后传感器的 PLC 寄存器地址(直接 Modbus 地址)。 +# “后”= 控流阀出口侧(靠近流量计/负载一侧),沿用 config 的原始地址; +# “前”= 控流阀入口侧(靠近减压阀一侧),按实际接线确认。 +PRESSURE_AFTER_CHANNEL = config.PRESSURE_AFTER_ADDR # 后压力 D710 +FLOW_AFTER_CHANNEL = config.FLOW_AFTER_ADDR # 后流量 D700 +PRESSURE_BEFORE_CHANNEL = config.PRESSURE_BEFORE_ADDR # 前压力 D720 +FLOW_BEFORE_CHANNEL = None # 前流量(暂无传感器) -MOTOR_CHANNEL = config.MOTOR_OUTPUT_CHANNEL # AO 通道 +MOTOR_CHANNEL = config.MOTOR_OUTPUT_ADDR # 电机输出 D730 # 开度扫描:0%~100%,默认 5% 间隔,共 21 个开度值,随机打乱。 OPENING_MIN_PCT = 0.0 @@ -430,7 +430,7 @@ def check_safety(pressure_before, flow_before, pressure_after, flow_after): def set_opening(hardware, opening_pct): - """把 0~100% 开度换算成反向电机行程并下发给 MT2-AM8。""" + """把 0~100% 开度换算成反向电机行程并下发给 PLC。""" position = opening_to_motor_position( opening_pct, config.MOTOR_OPEN_POSITION, @@ -485,7 +485,7 @@ def build_logger(): # --------------------------------------------------------------------------- def parse_args(argv=None): - parser = argparse.ArgumentParser(description="MT2-AM8 气体流量开环扫点实验") + parser = argparse.ArgumentParser(description="PLC 气体流量开环扫点实验") parser.add_argument("--min", type=float, default=OPENING_MIN_PCT, help=f"扫描起始开度(%%),默认 {OPENING_MIN_PCT:.0f}") parser.add_argument("--max", type=float, default=OPENING_MAX_PCT, @@ -507,7 +507,7 @@ def run(args): logger = build_logger() try: - from PcControl import MT2AM8Client + from PcControl import Easy521ModbusClient except ModuleNotFoundError as exc: if exc.name and exc.name.startswith("pymodbus"): logger.critical( @@ -517,12 +517,17 @@ def run(args): return 6 raise - hardware = MT2AM8Client( - host=config.MT2AM8_HOST, - port=config.MT2AM8_PORT, - slave_id=config.MT2AM8_SLAVE_ID, - pressure_range=config.PRESSURE_RANGE_KPA, - flow_range=config.FLOW_METER_RANGE_SLM, + 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, ) openings = generate_openings( @@ -538,10 +543,10 @@ def run(args): exit_code = 0 try: - logger.info("正在连接 MT2-AM8 %s:%s", config.MT2AM8_HOST, config.MT2AM8_PORT) + logger.info("正在连接 PLC %s:%s", config.PLC_HOST, config.PLC_PORT) connected = bool(hardware.connect()) if not connected: - logger.critical("无法连接 MT2-AM8,实验中止") + logger.critical("无法连接 PLC,实验中止") exit_code = 2 return exit_code @@ -671,7 +676,7 @@ def run(args): hardware.disconnect() except Exception: exit_code = max(exit_code, 5) - logger.exception("断开 MT2-AM8 时发生异常") + logger.exception("断开 PLC 时发生异常") if recorder is not None: try: diff --git a/test_valve.py b/test_valve.py index f31b949..4b6c33d 100644 --- a/test_valve.py +++ b/test_valve.py @@ -173,7 +173,7 @@ def set_opening(hardware, opening_pct): position = motor_position_for(opening) if not hardware.set_motor_position( position, - channel=config.MOTOR_OUTPUT_CHANNEL, + channel=config.MOTOR_OUTPUT_ADDR, ): raise RuntimeError("电机位置写入失败") return opening, position @@ -184,11 +184,11 @@ def read_sensors(hardware, quiet=False): output = io.StringIO() stream = output if quiet else sys.stdout with contextlib.redirect_stdout(stream): - flow = hardware.get_flow(config.FLOW_INPUT_CHANNEL) + flow = hardware.get_flow(config.FLOW_AFTER_ADDR) pressure = ( None - if config.PRESSURE_INPUT_CHANNEL is None - else hardware.get_pressure(config.PRESSURE_INPUT_CHANNEL) + if config.PRESSURE_AFTER_ADDR is None + else hardware.get_pressure(config.PRESSURE_AFTER_ADDR) ) return flow, pressure @@ -199,17 +199,22 @@ def main(): return 1 try: - from PcControl import MT2AM8Client + from PcControl import Easy521ModbusClient except ModuleNotFoundError as exc: print(f"无法导入硬件客户端: {exc}") return 2 - hardware = MT2AM8Client( - host=config.MT2AM8_HOST, - port=config.MT2AM8_PORT, - slave_id=config.MT2AM8_SLAVE_ID, - pressure_range=config.PRESSURE_RANGE_KPA, - flow_range=config.FLOW_METER_RANGE_SLM, + 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 @@ -222,7 +227,7 @@ def main(): try: print( - f"正在连接 MT2-AM8 {config.MT2AM8_HOST}:{config.MT2AM8_PORT} ..." + f"正在连接 PLC {config.PLC_HOST}:{config.PLC_PORT} ..." ) connected = bool(hardware.connect()) if not connected: diff --git a/test_valve_model.py b/test_valve_model.py index bcc9e9d..4379ca0 100644 --- a/test_valve_model.py +++ b/test_valve_model.py @@ -22,6 +22,37 @@ def _close(a, b, rel=1e-6): return abs(a - b) <= rel * max(1.0, abs(a), abs(b)) +def _write_sweep_csv(path, *, scale=1.0, visit_openings=(100.0, 50.0, 0.0)): + """构造合成扫点 CSV:每个 step 30 个采样点,全程稳态。 + + ``visit_openings`` 为开度的访问顺序(模拟 open_loop.py 的打乱顺序); + A_eff 真值 = scale * (1000 - x) / 400,Q_ss 按阻塞流(F=1)反推。 + """ + rows = [] + for step, opening in enumerate(visit_openings): + x = 1000.0 - opening / 100.0 * 200.0 # 与 flow_control 的映射一致 + a_eff = scale * (1000.0 - x) / 400.0 # 单调递减的真值 + q_ss = a_eff * (400.0 + P_ATM) # 阻塞流,F=1 + for i in range(30): + rows.append({ + "time_s": f"{step * 10 + i * 0.1:.6f}", + "step_index": step, + "opening_pct": opening, + "motor_position": x, + "flow_before_slm": "", + "flow_after_slm": q_ss, + "pressure_before_kpa": 400.0, + "pressure_after_kpa": 0.0, + "P_abs_ratio": "", + "is_ratio_smaller_than_0.528": "Y", + }) + + with path.open("w", newline="", encoding="utf-8-sig") as f: + writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + + def test_f_ratio(): assert f_ratio(0.4) == 1.0 assert f_ratio(CRITICAL_RATIO) == 1.0 @@ -70,46 +101,75 @@ def test_non_monotonic_raises(): def test_identify_end_to_end(): import identify_valve - # 构造合成扫点 CSV:每个 step 30 个采样点,全程稳态。 - rows = [] - for step, x in enumerate((800.0, 900.0, 1000.0)): - a_eff = (1000.0 - x) / 400.0 # 单调递减的真值 - q_ss = a_eff * (400.0 + P_ATM) # 阻塞流,F=1 - for i in range(30): - rows.append({ - "time_s": f"{step * 10 + i * 0.1:.6f}", - "step_index": step, - "opening_pct": 100.0 - step * 50.0, - "motor_position": x, - "flow_before_slm": "", - "flow_after_slm": q_ss, - "pressure_before_kpa": 400.0, - "pressure_after_kpa": 0.0, - "P_abs_ratio": "", - "is_ratio_smaller_than_0.528": "Y", - }) - with tempfile.TemporaryDirectory() as tmp: csv_path = Path(tmp) / "sweep.csv" out_path = Path(tmp) / "model.json" - with csv_path.open("w", newline="", encoding="utf-8-sig") as f: - writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) - writer.writeheader() - writer.writerows(rows) + _write_sweep_csv(csv_path) - model, steady, table, dropped, stats = identify_valve.identify( - csv_path, tail=20, out_path=str(out_path) + model, steady, table, dropped, stats, area_points = identify_valve.identify( + [csv_path], tail=20, out_path=str(out_path) ) assert len(table) == 3, table assert stats["choked"] == 3 assert not dropped for x, a in table: assert _close(a, (1000.0 - x) / 400.0, rel=0.01), (x, a) + assert len(area_points) == 3 loaded = ValveModel.load(str(out_path), 800.0, 1000.0) assert _close(loaded.area_from_stroke(900.0), 0.25, rel=0.01) +def test_merge_multiple_csvs(): + import identify_valve + + with tempfile.TemporaryDirectory() as tmp: + csv_a = Path(tmp) / "run_a.csv" + csv_b = Path(tmp) / "run_b.csv" + _write_sweep_csv(csv_a, scale=1.0) + _write_sweep_csv(csv_b, scale=1.1) + + model, steady, table, dropped, stats, _ = identify_valve.identify( + [csv_a, csv_b], tail=20 + ) + assert len(steady) == 6, steady + assert stats["choked"] == 6 + assert not dropped + # 同一行程的两个点先平均:A_eff = 1.05 * 真值 + for x, a in table: + assert _close(a, 1.05 * (1000.0 - x) / 400.0, rel=0.01), (x, a) + + +def test_direction_tagging(): + import identify_valve + + with tempfile.TemporaryDirectory() as tmp: + csv_path = Path(tmp) / "shuffled.csv" + _write_sweep_csv(csv_path, visit_openings=(0.0, 100.0, 50.0)) + points = identify_valve.load_steady_points(csv_path, tail=20) + directions = [p[4] for p in points] + assert directions == [None, "up", "down"], directions + + +def test_resolve_csv_paths_glob(): + import identify_valve + + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) + (base / "b.csv").write_text("", encoding="utf-8") + (base / "a.csv").write_text("", encoding="utf-8") + (base / "c.txt").write_text("", encoding="utf-8") + + # glob 展开并按名称排序,只匹配 CSV + paths = identify_valve.resolve_csv_paths([str(base / "*.csv")]) + assert [Path(p).name for p in paths] == ["a.csv", "b.csv"], paths + # 显式路径 + glob 混用时去重 + paths = identify_valve.resolve_csv_paths( + [str(base / "*.csv"), str(base / "a.csv")] + ) + assert [Path(p).name for p in paths] == ["a.csv", "b.csv"], paths + + if __name__ == "__main__": tests = [ test_f_ratio, @@ -117,6 +177,9 @@ if __name__ == "__main__": test_valve_model_roundtrip, test_non_monotonic_raises, test_identify_end_to_end, + test_merge_multiple_csvs, + test_direction_tagging, + test_resolve_csv_paths_glob, ] for test in tests: test()