从 MT2-AM8 模拟量模块迁移到 Easy521 PLC(Modbus TCP)

- 读取 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 中的硬件接线、配置默认值与模块职责描述
This commit is contained in:
2026-09-01 17:03:26 +08:00
parent abab25326a
commit 5888b4ccce
10 changed files with 507 additions and 374 deletions
+2 -1
View File
@@ -6,4 +6,5 @@ __pycache__
阻塞流压力判断.md
压差压比对流量影响的突发奇想.md
MPC与PID控制系统对比分析.md
plan.md
plan.md
valve_pipeline.html
+92 -191
View File
@@ -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 客户端 ----------
# ---------- PLCEasy521Modbus 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_max1000)坐标(死区由 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 客户端类(含报文打印) ----------
+36 -34
View File
@@ -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-AM8Modbus TCP,默认 `192.168.1.12:502`,站号 `1`
- AI 输入寄存器:地址 `0x00~0x03`
- AO 保持寄存器:地址 `0x00~0x03`
- 模拟量原始范围:`0~4095`,传感器物理量换算使用 `819~4095` 对应有效量程
- 默认流量量程:`300 SLM`;默认压力量程:`1600 kPa`
- PLCEasy521Modbus 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_baseKi_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` | 电机输出 D7300~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` |
## 项目结构
+34 -21
View File
@@ -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
# ---------------------------------------------------------------------------
# PLCEasy521Modbus 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 # 电机输出 D7300~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
# 阀特性表 JSONidentify_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:
+4 -4
View File
@@ -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,
+182 -44
View File
@@ -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}")
+27 -22
View File
@@ -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:
+25 -20
View File
@@ -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:
+17 -12
View File
@@ -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:
+88 -25
View File
@@ -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) / 400Q_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()