从 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
+1
View File
@@ -7,3 +7,4 @@ __pycache__
压差压比对流量影响的突发奇想.md 压差压比对流量影响的突发奇想.md
MPC与PID控制系统对比分析.md MPC与PID控制系统对比分析.md
plan.md plan.md
valve_pipeline.html
+91 -190
View File
@@ -20,9 +20,10 @@ def _motor_log(msg: str):
except Exception: except Exception:
pass pass
volthege_min = 819 # 模拟量映射最小值(对应 0V/4mA) volthege_min = 819 # 模拟量映射最小值(对应 0V/4mA)—— MT2 旧用
volthege_max = 4095 # 模拟量映射最大值(对应 10V/20mA) volthege_max = 4095 # 模拟量映射最大值(对应 10V/20mA)—— MT2 旧用
x_max = 1000 # 最大行程 x_max = 1000 # 电机最大行程PLC 沿用 0~1000 坐标,死区由 config 给定)
raw_max = 32000 # PLC 模拟量映射最大值(0~32000,对应量程满量程)
impulse_max = 163840 # 电机脉冲最大值(对应 x_max 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: if x_max_val is not None:
x_max = x_max_val x_max = x_max_val
# ---------- 原有的 PLC Modbus TCP 客户端 ---------- # ---------- PLCEasy521Modbus TCP 客户端 ----------
class Easy521ModbusClient: class Easy521ModbusClient:
# 参数来源(GUI 页面1 Modbus TCP 区): """
# host <- PLC地址 (默认 192.168.1.88) 信捷 Easy521 PLC 的 Modbus TCP 通讯类。
# port <- 端口 (默认 502)
# current_p_addr <- 读取压力寄存器地址 (默认 504) - D 寄存器为直接 Modbus 地址:读用功能码 03(保持寄存器),写用功能码 06。
def __init__(self, host="192.168.1.88", port=502, slave_id=1, current_p_addr=504): - 模拟量映射: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.host = host
self.port = port self.port = port
self.slave_id = slave_id 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( self.client = ModbusTcpClient(
host=host, host=host,
port=port, port=port,
timeout=3, timeout=3,
retries=3 retries=3,
) )
self.connected = False 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): def connect(self):
try: try:
connection = self.client.connect() conn = self.client.connect()
if connection: if conn:
print(f"成功连接到 {self.host}:{self.port}") print(f"成功连接到 PLC {self.host}:{self.port}")
self.connected = True self.connected = True
else: else:
print(f"无法连接到 {self.host}:{self.port}") print(f"无法连接到 PLC {self.host}:{self.port}")
self.connected = False self.connected = False
return connection return conn
except Exception as e: except Exception as e:
print(f"连接错误: {e}") print(f"连接错误: {e}")
self.connected = False self.connected = False
@@ -95,181 +107,70 @@ class Easy521ModbusClient:
self.connected = False self.connected = False
print("连接已关闭") print("连接已关闭")
def read_float(self, address): def _read_register(self, address):
"""读单个 16 位保持寄存器(功能码 03),返回原始整数,失败返回 None。"""
try: try:
address = int(address) result = self.client.read_holding_registers(
result = self.client.read_input_registers( address=int(address),
address=address, count=1,
count=2, slave=self.slave_id,
slave=self.slave_id
) )
if not result.isError(): if not result.isError():
decoder = BinaryPayloadDecoder.fromRegisters( return result.registers[0]
result.registers, print(f"读取寄存器 D{int(address)} 错误: {result}")
byteorder=Endian.BIG,
wordorder=Endian.LITTLE
)
return decoder.decode_32bit_float()
else:
print(f"读取寄存器错误: {result}")
return None return None
except Exception as e: except Exception as e:
print(f"读取浮点数时发生错误: {e}") print(f"读取寄存器 D{int(address)} 异常: {e}")
return None return None
def write_float(self, address, float_value): def _write_register(self, address, value):
"""写单个 16 位保持寄存器(功能码 06)。"""
try: try:
address = int(address) result = self.client.write_register(
builder = BinaryPayloadBuilder(byteorder=Endian.LITTLE, wordorder=Endian.BIG) address=int(address),
builder.add_32bit_float(float_value) value=int(value),
payload = builder.to_registers() slave=self.slave_id,
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 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(): if result.isError():
print(f"RTU 读取寄存器错误: {result}") print(f"写入寄存器 D{int(address)} 错误: {result}")
return None return False
decoder = BinaryPayloadDecoder.fromRegisters( return True
result.registers,
byteorder=Endian.BIG,
wordorder=Endian.BIG
)
value = decoder.decode_32bit_uint() / 100
return value
except Exception as e: 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 return None
finally: return raw / raw_max * self.flow_range
client.close()
def start_up(self): def get_pressure(self, address):
if self.write_coil(self.M901_ADDR, True): """读压力,按寄存器地址匹配量程(阀前 300 / 阀后 400-> kPa。"""
print("M901 置位 TRUE") address = int(address)
time.sleep(1) if address == self.pressure_before_addr:
if self.write_coil(self.M901_ADDR, False): rng = self.pressure_before_range
print("M901 复位 FALSE") elif address == self.pressure_after_addr:
rng = self.pressure_after_range
else: else:
print("警告:M901 复位失败") print(f"未知压力寄存器 D{address},按阀后量程兜底")
else: rng = self.pressure_after_range
print("警告:M901 置位失败") raw = self._read_register(address)
time.sleep(1) if raw is None:
return None
return raw / raw_max * rng
if self.write_coil(self.M902_ADDR, True): def set_motor_position(self, position, channel=None):
print("M902 置位 TRUE") """把 0~x_max 的行程映射到 0~raw_max 后写入电机输出寄存器。"""
time.sleep(1) if channel is None:
if self.write_coil(self.M902_ADDR, False): channel = self.motor_output_addr
print("M902 复位 FALSE") position = float(position)
else: if not 0 <= position <= x_max:
print("警告:M902 复位失败") print(f"行程需在 0~{x_max:.0f} 之间")
else: return False
print("警告:M902 置位失败") raw_value = int(position / x_max * raw_max)
time.sleep(1) return self._write_register(channel, raw_value)
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。")
# ---------- 新增:独立的电机 Modbus RTU 客户端类(含报文打印) ---------- # ---------- 新增:独立的电机 Modbus RTU 客户端类(含报文打印) ----------
+36 -34
View File
@@ -1,6 +1,6 @@
# 气体流量控制与阀门开环标定 # 气体流量控制与阀门开环标定
本项目基于艾莫迅 **MT2-AM8 模拟量采集/输出模块**提供三个可独立运行的在线入口和两个离线工具: 本项目基于信捷 **Easy521 PLC**Modbus TCP提供三个可独立运行的在线入口和两个离线工具:
- [main.py](main.py):以 10 Hz 周期运行单回路气体流量闭环控制,支持增量式 PID,并可选“前馈反解打底 + PI 修残差”与按阀前压力缩放增益的增益调度; - [main.py](main.py):以 10 Hz 周期运行单回路气体流量闭环控制,支持增量式 PID,并可选“前馈反解打底 + PI 修残差”与按阀前压力缩放增益的增益调度;
- [open_loop.py](open_loop.py):随机扫描阀门开度,采集阀前/阀后的压力与阀后流量,用于开环标定、动态特性观察和临界压力比分析。 - [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%` 开度,然后断开设备。 - **开环 `open_loop.py`**:连接成功后先主动设置 `0%` 开度,实验结束、异常或 `Ctrl+C` 时再次尝试设置 `0%` 开度,然后断开设备。
- **手动测试 `test_valve.py`**:连接时仅按已知状态记录 `100%` 开度,不向电机写入初始位置;退出时不主动下发新开度,仅断开设备。该脚本不执行流量/压力越限和连续读取失败保护,只适合在具备独立安全措施的受控测试中使用。 - **手动测试 `test_valve.py`**:连接时仅按已知状态记录 `100%` 开度,不向电机写入初始位置;退出时不主动下发新开度,仅断开设备。该脚本不执行流量/压力越限和连续读取失败保护,只适合在具备独立安全措施的受控测试中使用。
@@ -24,23 +24,22 @@
## 硬件与默认接线 ## 硬件与默认接线
- MT2-AM8Modbus TCP,默认 `192.168.1.12:502`,站号 `1` - PLCEasy521Modbus TCP,默认 `192.168.1.88:502`,站号 `1`
- AI 输入寄存器:地址 `0x00~0x03` - D 寄存器为直接 Modbus 地址:读用功能码 `03`(保持寄存器),写用功能码 `06`
- AO 保持寄存器:地址 `0x00~0x03` - 模拟量原始范围:`0~32000`,线性对应各量程满量程
- 模拟量原始范围:`0~4095`,传感器物理量换算使用 `819~4095` 对应有效量程 - 默认流量量程:`300 SLM`;阀后压力量程:`400 kPa`;阀前压力量程:`300 kPa`
- 默认流量量程:`300 SLM`;默认压力量程:`1600 kPa`
- 电机行程范围:`0~1000`,且行程与阀门开度反向作用。 - 电机行程范围:`0~1000`,且行程与阀门开度反向作用。
默认通道如下,通道编号均从 `0` 开始 默认寄存器地址与量程如下
| 用途 | 通道 | 配置位置 | | 用途 | 地址 | 量程 | 配置位置 |
| --- | ---: | --- | | --- | ---: | ---: | --- |
| 闭环/阀后压力 | `AI0` | `config.PRESSURE_INPUT_CHANNEL` | | 阀后流量 | `D700` | `300 SLM` | `config.FLOW_AFTER_ADDR` |
| 闭环/阀后流量 | `AI1` | `config.FLOW_INPUT_CHANNEL` | | 阀后压力 | `D710` | `400 kPa` | `config.PRESSURE_AFTER_ADDR` |
| 阀前压力(前馈/增益调度/开环共用) | `AI2` | `config.PRESSURE_BEFORE_CHANNEL` / `open_loop.PRESSURE_BEFORE_CHANNEL` | | 阀前压力(前馈/增益调度/开环共用) | `D720` | `300 kPa` | `config.PRESSURE_BEFORE_ADDR` / `open_loop.PRESSURE_BEFORE_CHANNEL` |
| 电机位置指令 | `AO1` | `config.MOTOR_OUTPUT_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(当前打开端/机械死区边界) 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) 顶部。 所有闭环通道、量程和行程端值集中在 [config.py](config.py);开环新增的阀前通道和实验参数位于 [open_loop.py](open_loop.py) 顶部。
@@ -97,7 +96,7 @@ python main.py --target 50
启动流程为: 启动流程为:
```text ```text
校验配置 -> 连接 MT2-AM8 -> 读取一次初始流量 校验配置 -> 连接 PLC -> 读取一次初始流量
-> PID 输出初始化为 INITIAL_OPENING_PCT -> PID 输出初始化为 INITIAL_OPENING_PCT
-> 进入周期控制(启动阶段不主动写入关闭位置) -> 进入周期控制(启动阶段不主动写入关闭位置)
``` ```
@@ -118,7 +117,7 @@ error = target - measurement
opening_to_motor_position() opening_to_motor_position()
| |
v v
反向行程 1000~800 -> MT2-AM8 AO1 反向行程 1000~800 -> PLC D730
``` ```
增量 PID 的输出直接定义为阀门开度,因此控制器本身不包含电机行程或死区映射。目标流量不高于 `ZERO_FLOW_THRESHOLD_SLM`(当前 `0.5 SLM`)时,程序跳过常规 PID 更新并直接命令 `0%` 开度。 增量 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 修残差 | | `FEEDFORWARD_ENABLED` | `False` | 启用前馈反解打底 + PI 修残差 |
| `GAIN_SCHEDULE_ENABLED` | `False` | 启用按阀前压力缩放 Kp/Ki | | `GAIN_SCHEDULE_ENABLED` | `False` | 启用按阀前压力缩放 Kp/Ki |
| `VALVE_MODEL_PATH` | `""` | 阀特性表 JSON 路径(空串 = 关闭前馈) | | `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 ```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)` - 每个 CSV`step_index` 分组,每组取末尾 `--tail` 个采样点平均,得到稳态 `(行程 x, 流量 Q, 阀前压 P1, 阀后压 P2)`,并按开度访问顺序标记逼近方向(上升 / 下降 / 该文件起始步)
- 对每个稳态点反解 `A_eff = Q / (P1_abs · F(r))` - 对每个稳态点反解 `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) 是纯计算离线自测,不接触硬件,可在任意环境运行: [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` | 通讯参数 | | `PLC_HOST` / `PLC_PORT` / `PLC_SLAVE_ID` | `192.168.1.88` / `502` / `1` | PLC 通讯参数 |
| `FLOW_INPUT_CHANNEL` / `PRESSURE_INPUT_CHANNEL` | `1` / `0` | 闭环 AI 通道;压力可设为 `None` | | `FLOW_AFTER_ADDR` / `FLOW_AFTER_RANGE_SLM` | `700` / `300` | 阀后流量 D700 与量程 |
| `MOTOR_OUTPUT_CHANNEL` | `1` | 电机 AO 通道 | | `PRESSURE_AFTER_ADDR` / `PRESSURE_AFTER_RANGE_KPA` | `710` / `400` | 阀后压力 D710 与量程 |
| `FLOW_METER_RANGE_SLM` / `PRESSURE_RANGE_KPA` | `300` / `1600` | 传感器量程 | | `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_FLOW_MIN_SLM` / `TARGET_FLOW_MAX_SLM` | `0` / `300` | 允许的目标范围 |
| `TARGET_SWITCH_KEY` | `s` | 运行中按此键进入"输入新目标流量"模式 | | `TARGET_SWITCH_KEY` | `s` | 运行中按此键进入"输入新目标流量"模式 |
| `CONTROL_PERIOD_S` / `MAX_CONTROL_DT_S` | `0.1` / `0.3` | 目标周期 / 超时阈值 | | `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 秒) | | `PID_MODE_SWITCH_CONFIRM_CYCLES` | `10` | 模式切换条件需连续满足的采样周期数(当前约 1 秒) |
| `MOTOR_CLOSED_POSITION` / `MOTOR_OPEN_POSITION` | `1000` / `800` | 当前反向控制区间;打开端为机械死区边界 | | `MOTOR_CLOSED_POSITION` / `MOTOR_OPEN_POSITION` | `1000` / `800` | 当前反向控制区间;打开端为机械死区边界 |
| `INITIAL_OPENING_PCT` | `100` | 闭环 PID 内部初始输出 | | `INITIAL_OPENING_PCT` | `100` | 闭环 PID 内部初始输出 |
| `PRESSURE_BEFORE_CHANNEL` | `2` | 阀前压力 P1 的 AI 通道(前馈/增益调度/开环共用);可设为 `None` | | `PRESSURE_BEFORE_ADDR` | `720` | 阀前压力 P1 的寄存器地址(前馈/增益调度/开环共用) |
| `VALVE_MODEL_PATH` | `""` | 阀特性表 JSON 路径;空串关闭前馈 | | `VALVE_MODEL_PATH` | `""` | 阀特性表 JSON 路径;空串关闭前馈 |
| `FEEDFORWARD_ENABLED` | `False` | 是否启用前馈反解打底 + PI 修残差 | | `FEEDFORWARD_ENABLED` | `False` | 是否启用前馈反解打底 + PI 修残差 |
| `FEEDFORWARD_CORRECTION_BAND_PCT` | `20` | 前馈开启时 PI 修正量的对称限幅(±%) | | `FEEDFORWARD_CORRECTION_BAND_PCT` | `20` | 前馈开启时 PI 修正量的对称限幅(±%) |
@@ -385,9 +387,9 @@ python test_valve_model.py
| [open_loop.py](open_loop.py) | 独立开环扫点、传感器记录和压力比分析 | | [open_loop.py](open_loop.py) | 独立开环扫点、传感器记录和压力比分析 |
| [test_valve.py](test_valve.py) | Windows 终端手动开度测试、每秒采样、CSV 与三联图 | | [test_valve.py](test_valve.py) | Windows 终端手动开度测试、每秒采样、CSV 与三联图 |
| [valve_model.py](valve_model.py) | 静态阀特性模型 `Q_ss = A_eff(x)·P1_abs·F(r)` 与前馈反解(纯计算) | | [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) | 阀特性模型与前馈反解的离线自测 | | [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 通道、压力上限、 首次连接真实设备前,请重点核对:PLC 寄存器地址、量程、阀门打开端行程和
阀门打开端行程和关闭端行程。PID 参数是安全起步值,不是最终整定结果。 关闭端行程。PID 参数是安全起步值,不是最终整定结果。
""" """
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MT2-AM8 通讯与量程 # MT2-AM8 通讯与量程(旧方案;代码保留但当前不再使用)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
MT2AM8_HOST = "192.168.1.12" MT2AM8_HOST = "192.168.1.12"
@@ -13,13 +13,27 @@ MT2AM8_PORT = 502
MT2AM8_SLAVE_ID = 1 MT2AM8_SLAVE_ID = 1
# MT2AM8Client 用这些量程把模拟量转换成物理量。 # MT2AM8Client 用这些量程把模拟量转换成物理量。
PRESSURE_RANGE_KPA = 1600.0 PRESSURE_RANGE_KPA = 400.0
FLOW_METER_RANGE_SLM = 300.0 FLOW_METER_RANGE_SLM = 300.0
# AI/AO 通道均从 0 开始。AI0 和 AO0 属于不同的寄存器区,可以同时使用。 # ---------------------------------------------------------------------------
FLOW_INPUT_CHANNEL = 1 # PLCEasy521Modbus TCP 通讯与寄存器地址
PRESSURE_INPUT_CHANNEL = 0 # 没有压力传感器时改为 None # ---------------------------------------------------------------------------
MOTOR_OUTPUT_CHANNEL = 1
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_MIN_PCT = 0.0
OPENING_MAX_PCT = 100.0 OPENING_MAX_PCT = 100.0
MAX_OPENING_RATE_FAR_PCT_S = 20.0 MAX_OPENING_RATE_FAR_PCT_S = 200.0
MAX_OPENING_RATE_NEAR_PCT_S = 2.0 MAX_OPENING_RATE_NEAR_PCT_S = 20.0
# FAR -> NEAR:绝对误差连续满足 near 阈值指定周期数后切换。 # FAR -> NEAR:绝对误差连续满足 near 阈值指定周期数后切换。
# NEAR -> FAR:绝对误差连续达到 far 阈值指定周期数后切换。 # NEAR -> FAR:绝对误差连续达到 far 阈值指定周期数后切换。
@@ -87,8 +101,7 @@ INITIAL_OPENING_PCT = 100.0
# 前馈 + 增益调度(默认关闭,便于与纯 PID 做 A/B 对比;接线确认后再开启) # 前馈 + 增益调度(默认关闭,便于与纯 PID 做 A/B 对比;接线确认后再开启)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 阀前压力 P1(前馈缩放 / 判阻塞 / 增益调度用),按实际接线确认 # 阀前压力 P1(前馈缩放 / 判阻塞 / 增益调度用),地址见上 PLC 段 PRESSURE_BEFORE_ADDR
PRESSURE_BEFORE_CHANNEL = 2
# 阀特性表 JSONidentify_valve.py 产出)。空字符串表示关闭前馈。 # 阀特性表 JSONidentify_valve.py 产出)。空字符串表示关闭前馈。
VALVE_MODEL_PATH = "" VALVE_MODEL_PATH = ""
@@ -138,18 +151,18 @@ def validate_config():
raise ValueError("MAX_CONTROL_DT_S 不得小于 CONTROL_PERIOD_S") raise ValueError("MAX_CONTROL_DT_S 不得小于 CONTROL_PERIOD_S")
if TARGET_FLOW_MIN_SLM > TARGET_FLOW_MAX_SLM: if TARGET_FLOW_MIN_SLM > TARGET_FLOW_MAX_SLM:
raise ValueError("目标流量上下限配置错误") raise ValueError("目标流量上下限配置错误")
if TARGET_FLOW_MAX_SLM > FLOW_METER_RANGE_SLM: if TARGET_FLOW_MAX_SLM > FLOW_AFTER_RANGE_SLM:
raise ValueError("目标流量上限不得超过流量计量程") raise ValueError("目标流量上限不得超过流量计量程")
if FLOW_VALID_MIN_SLM > FLOW_VALID_MAX_SLM: if FLOW_VALID_MIN_SLM > FLOW_VALID_MAX_SLM:
raise ValueError("流量有效范围上下限配置错误") raise ValueError("流量有效范围上下限配置错误")
if FLOW_VALID_MAX_SLM > FLOW_METER_RANGE_SLM: if FLOW_VALID_MAX_SLM > FLOW_AFTER_RANGE_SLM:
raise ValueError("流量有效范围上限不得超过流量计量程") raise ValueError("流量有效范围上限不得超过流量计量程")
if not 0 <= OPENING_MIN_PCT < OPENING_MAX_PCT <= 100: if not 0 <= OPENING_MIN_PCT < OPENING_MAX_PCT <= 100:
raise ValueError("阀门开度范围必须位于 0~100%,且下限小于上限") raise ValueError("阀门开度范围必须位于 0~100%,且下限小于上限")
if MOTOR_OPEN_POSITION >= MOTOR_CLOSED_POSITION: if MOTOR_OPEN_POSITION >= MOTOR_CLOSED_POSITION:
raise ValueError("本系统行程越大开度越小,因此打开端行程必须小于关闭端行程") raise ValueError("本系统行程越大开度越小,因此打开端行程必须小于关闭端行程")
if MOTOR_OPEN_POSITION < 0 or MOTOR_CLOSED_POSITION > 1000: if MOTOR_OPEN_POSITION < 0 or MOTOR_CLOSED_POSITION > 1000:
raise ValueError("电机行程必须位于 MT2AM8Client 当前采用的 0~1000 范围") raise ValueError("电机行程必须位于当前采用的 0~1000 范围")
pid_gains = ( pid_gains = (
PID_FAR_KP, PID_FAR_KP,
PID_FAR_KI, PID_FAR_KI,
@@ -181,13 +194,13 @@ def validate_config():
raise ValueError("PID_MODE_SWITCH_CONFIRM_CYCLES 必须至少为 1") raise ValueError("PID_MODE_SWITCH_CONFIRM_CYCLES 必须至少为 1")
if MAX_CONSECUTIVE_FLOW_FAILURES < 1: if MAX_CONSECUTIVE_FLOW_FAILURES < 1:
raise ValueError("MAX_CONSECUTIVE_FLOW_FAILURES 必须至少为 1") raise ValueError("MAX_CONSECUTIVE_FLOW_FAILURES 必须至少为 1")
if PRESSURE_INPUT_CHANNEL is not None and MAX_PRESSURE_KPA is None: if PRESSURE_AFTER_ADDR is not None and MAX_PRESSURE_KPA is None:
raise ValueError("启用压力通道时必须配置 MAX_PRESSURE_KPA") raise ValueError("启用压力通道时必须配置 MAX_PRESSURE_KPA")
if PRESSURE_BEFORE_CHANNEL is not None: if PRESSURE_BEFORE_ADDR is not None:
if not isinstance(PRESSURE_BEFORE_CHANNEL, int): if not isinstance(PRESSURE_BEFORE_ADDR, int):
raise ValueError("PRESSURE_BEFORE_CHANNEL 必须为整数通道号或 None") raise ValueError("PRESSURE_BEFORE_ADDR 必须为整数地址或 None")
if PRESSURE_BEFORE_CHANNEL < 0: if PRESSURE_BEFORE_ADDR < 0:
raise ValueError("PRESSURE_BEFORE_CHANNEL 不得为负数") raise ValueError("PRESSURE_BEFORE_ADDR 不得为负数")
if MAX_PRESSURE_KPA is None: if MAX_PRESSURE_KPA is None:
raise ValueError("启用阀前压力通道时必须配置 MAX_PRESSURE_KPA") raise ValueError("启用阀前压力通道时必须配置 MAX_PRESSURE_KPA")
if GAIN_SCHEDULE_REF_ABS_KPA <= 0: if GAIN_SCHEDULE_REF_ABS_KPA <= 0:
+4 -4
View File
@@ -1,7 +1,7 @@
"""流量闭环控制层。 """流量闭环控制层。
本模块只负责“读取传感器 -> PID -> 开度/行程映射 -> 下发电机位置”以及 本模块只负责“读取传感器 -> PID -> 开度/行程映射 -> 下发电机位置”以及
安全处理;底层 Modbus 通讯仍由 PcControl.MT2AM8Client 完成。 安全处理;底层 Modbus 通讯仍由 PcControl.Easy521ModbusClient 完成。
""" """
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
@@ -75,7 +75,7 @@ def opening_to_motor_position(
class FlowControlLoop: class FlowControlLoop:
"""基于 MT2AM8Client 和 IncrementalPID 的单回路流量控制器。""" """基于 Easy521ModbusClient 和 IncrementalPID 的单回路流量控制器。"""
def __init__( def __init__(
self, self,
@@ -352,7 +352,7 @@ class FlowControlLoop:
self._trip( self._trip(
"DEVICE_DISCONNECTED", "DEVICE_DISCONNECTED",
"PRECHECK", "PRECHECK",
"MT2-AM8 未连接", "PLC 未连接",
) )
current_time = time.perf_counter() if now is None else float(now) current_time = time.perf_counter() if now is None else float(now)
@@ -763,7 +763,7 @@ class FlowControlLoop:
self._trip( self._trip(
"MOTOR_COMMAND_FAILED", "MOTOR_COMMAND_FAILED",
"WRITE_MOTOR", "WRITE_MOTOR",
"MT2-AM8 返回电机位置写入失败", "PLC 返回电机位置写入失败",
{ {
"requested_opening_pct": opening, "requested_opening_pct": opening,
"requested_motor_position": position, "requested_motor_position": position,
+182 -44
View File
@@ -1,19 +1,26 @@
"""从开环扫点 CSV 拟合 A_eff(x) 表,输出 valve_model.json。 """从开环扫点 CSV 拟合 A_eff(x) 表,输出 valve_model.json。
用法: 用法:
python identify_valve.py --csv open_loop_data/open_loop_xxx.csv \\ python identify_valve.py --csv open_loop_data/open_loop_xxx.csv --out valve_model.json
--out valve_model.json [--tail 20] [--plot] 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) 1. --csv 支持多个路径与 glob 模式,全部 CSV 合并使用(文件名含时间戳,按名称
2. 对每个稳态点反解 A_eff = Q / (P1_abs * F(r)) 排序即按实验先后合并);省略时默认 open_loop_data/*.csv
3. 按行程升序构表,做单调性检查; 2. 每个 CSV 按 step_index 分组,每组取末尾 --tail 个采样点平均,得到稳态
4. 用 ValveModel 存成 JSON,并打印摘要(可选画诊断图)。 (x, Q, P1, P2),并按开度访问顺序标记逼近方向(上升/下降/该文件起始步);
3. 对每个稳态点反解 A_eff = Q / (P1_abs * F(r)),同一行程 x 的多个点
(来自不同次实验)先平均再构表;
4. 按行程升序构表,做单调性检查;
5. 用 ValveModel 存成 JSON,并打印摘要(可选画诊断图,实测点按逼近方向着色)。
""" """
import argparse import argparse
import csv import csv
import glob
import math import math
import os
from pathlib import Path from pathlib import Path
import config import config
@@ -21,6 +28,21 @@ from valve_model import CRITICAL_RATIO, ValveModel, abs_pressure, f_ratio
DEFAULT_TAIL = 20 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): def _to_float(text):
"""把 CSV 单元格解析成有限浮点数;空/非法/非有限返回 None。""" """把 CSV 单元格解析成有限浮点数;空/非法/非有限返回 None。"""
@@ -43,8 +65,14 @@ def _average(values):
def load_steady_points(csv_path, tail=DEFAULT_TAIL): 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 = {} rows_by_step = {}
opening_by_step = {}
with open(csv_path, "r", newline="", encoding="utf-8-sig") as file: with open(csv_path, "r", newline="", encoding="utf-8-sig") as file:
for row in csv.DictReader(file): for row in csv.DictReader(file):
step_text = (row.get("step_index") or "").strip() step_text = (row.get("step_index") or "").strip()
@@ -55,9 +83,25 @@ def load_steady_points(csv_path, tail=DEFAULT_TAIL):
except (TypeError, ValueError): except (TypeError, ValueError):
continue continue
rows_by_step.setdefault(step, []).append(row) 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 = [] points = []
prev_opening = None
for step in sorted(rows_by_step): 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 = rows_by_step[step]
rows.sort(key=lambda r: _to_float(r.get("time_s")) or 0.0) rows.sort(key=lambda r: _to_float(r.get("time_s")) or 0.0)
tail_rows = rows[-tail:] if tail > 0 else rows 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): if None in (x, q, p1, p2):
continue continue
points.append((x, q, p1, p2)) points.append((x, q, p1, p2, direction))
return points return points
def compute_area_table(steady_points): def compute_area_table(steady_points):
"""把稳态点换算成 (x, A_eff) 单调表,并返回丢弃点与阻塞/亚声速统计 """把稳态点换算成 (x, A_eff) 单调表,并返回逐点测量值供诊断图着色
返回 ``(table, dropped, stats)``。``table`` 为按 x 升序的 (x, A_eff) 列表, 返回 ``(table, dropped, stats, area_points)``
只保留最长的单调连续段;``dropped`` 为因非单调被丢弃的点。
- ``area_points``:每个实测点的 ``(x, A_eff, direction)``,按 x 升序,未平均;
- ``table``:同一行程 x 的多个 A_eff 先平均,再按 x 升序取最长单调连续段;
- ``dropped``:因非单调被丢弃的 ``(x, A_eff)``
- ``stats``:阻塞/亚声速点统计(按原始测量点计数)。
""" """
area_points = [] area_points = []
choked = subsonic = 0 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) p1_abs = abs_pressure(p1)
p2_abs = abs_pressure(p2) p2_abs = abs_pressure(p2)
if p1_abs <= 0.0: if p1_abs <= 0.0:
@@ -95,21 +143,28 @@ def compute_area_table(steady_points):
choked += 1 choked += 1
else: else:
subsonic += 1 subsonic += 1
area_points.append((x, q / denom)) area_points.append((x, q / denom, direction))
area_points.sort(key=lambda item: item[0]) 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): if _is_monotonic(ys):
table = area_points table = merged
dropped = [] dropped = []
else: else:
start, end = _longest_monotonic_run(ys) start, end = _longest_monotonic_run(ys)
table = area_points[start:end + 1] table = merged[start:end + 1]
dropped = area_points[:start] + area_points[end + 1:] dropped = merged[:start] + merged[end + 1:]
stats = {"choked": choked, "subsonic": subsonic, "total": choked + subsonic} stats = {"choked": choked, "subsonic": subsonic, "total": choked + subsonic}
return table, dropped, stats return table, dropped, stats, area_points
def _is_monotonic(ys): def _is_monotonic(ys):
@@ -151,14 +206,22 @@ def _longest_monotonic_run(ys):
return best_start, best_end return best_start, best_end
def identify(csv_path, tail=DEFAULT_TAIL, out_path=None): def identify(csv_paths, tail=DEFAULT_TAIL, out_path=None):
"""端到端辨识;返回 ``(model, steady_points, table, dropped, stats)``。""" """从一份或多份开环扫点 CSV 端到端辨识。
steady = load_steady_points(csv_path, tail)
table, dropped, stats = compute_area_table(steady) 返回 ``(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: if len(table) < 2:
raise ValueError( raise ValueError(
f"有效稳态点不足({len(table)} 个),无法构表。请确认 CSV " f"有效稳态点不足({len(table)} 个),无法构表。请确认 CSV "
"pressure_before_kpa / pressure_after_kpa / flow_after_slm 列读数合理。" "open_loop.py 产出(需含 step_index 列),且 "
"pressure_before_kpa / pressure_after_kpa / flow_after_slm "
"列读数合理。"
) )
model = ValveModel( model = ValveModel(
table, table,
@@ -167,11 +230,69 @@ def identify(csv_path, tail=DEFAULT_TAIL, out_path=None):
) )
if out_path: if out_path:
model.save(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): def resolve_csv_paths(items=None):
"""画 x vs A_eff(散点+插值线)与 x vs Q_ss(散点)两张子图。""" """把 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 import matplotlib
matplotlib.use("Agg") 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, axes = plt.subplots(2, 1, figsize=(10, 8), constrained_layout=True)
figure.suptitle("Identified valve characteristic") figure.suptitle("Identified valve characteristic")
axes[0].scatter(xs, areas, color="#1565C0", label="measured A_eff") axes[0].plot(xs, areas, color="#263238", linewidth=1.2, label="fitted A_eff (per-x mean)")
axes[0].plot(xs, areas, color="#1565C0", linewidth=1.2) _scatter_by_direction(axes[0], area_points)
if dropped: if dropped:
axes[0].scatter( axes[0].scatter(
[x for x, _ in dropped], [x for x, _ in dropped],
[a for _, a in dropped], [a for _, a in dropped],
color="#D84315", color="#B71C1C",
marker="x", marker="x",
label="dropped (non-monotonic)", 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].legend(loc="best")
axes[0].grid(True, alpha=0.3, linestyle="--") axes[0].grid(True, alpha=0.3, linestyle="--")
qxs = [x for x, _, _, _ in steady_points] _scatter_by_direction(
qs = [q for _, q, _, _ in steady_points] axes[1], [(p[0], p[1], p[4]) for p in steady_points]
axes[1].scatter(qxs, qs, color="#2E7D32", label="measured Q_ss") )
axes[1].set_ylabel("Flow (SLM)") axes[1].set_ylabel("Flow (SLM)")
axes[1].set_xlabel("Motor stroke x") axes[1].set_xlabel("Motor stroke x")
axes[1].set_title("Steady flow vs stroke") 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): def parse_args(argv=None):
parser = argparse.ArgumentParser( 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("--out", default="valve_model.json", help="输出 JSON 路径")
parser.add_argument( parser.add_argument(
"--tail", type=int, default=DEFAULT_TAIL, "--tail", type=int, default=DEFAULT_TAIL,
@@ -230,26 +354,40 @@ def parse_args(argv=None):
def main(argv=None): def main(argv=None):
args = parse_args(argv) args = parse_args(argv)
csv_path = Path(args.csv) csv_paths = resolve_csv_paths(args.csv)
if not csv_path.exists(): if not csv_paths:
print(f"CSV 不存在:{csv_path}") 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 return 2
try: try:
model, steady, table, dropped, stats = identify( model, steady, table, dropped, stats, area_points = identify(
csv_path, tail=args.tail, out_path=args.out csv_paths, tail=args.tail, out_path=args.out
) )
except (ValueError, OSError) as exc: except (ValueError, OSError) as exc:
print(f"辨识失败:{exc}") print(f"辨识失败:{exc}")
return 2 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( print(
f"读取稳态点 {len(steady)};阻塞 {stats['choked']} 个," f"读取稳态点 {len(steady)}:上升逼近 {directions[DIRECTION_UP]}"
f"亚声速 {stats['subsonic']} 个,合计 {stats['total']} 个。" f"下降逼近 {directions[DIRECTION_DOWN]}、起始点 {directions[None]}"
f"阻塞 {stats['choked']} 个,亚声速 {stats['subsonic']} 个,"
f"合计 {stats['total']} 个。"
) )
print( print(
f"有效行程范围 {table[0][0]:.1f} ~ {table[-1][0]:.1f}" f"有效行程范围 {table[0][0]:.1f} ~ {table[-1][0]:.1f}"
f"{len(table)} 点)。" f"{len(table)},同一行程已平均)。"
) )
if dropped: if dropped:
@@ -270,7 +408,7 @@ def main(argv=None):
if args.plot: if args.plot:
image_path = Path(args.out).with_suffix(".png") image_path = Path(args.out).with_suffix(".png")
try: try:
create_diagnostic_plot(steady, table, dropped, image_path) create_diagnostic_plot(steady, table, dropped, area_points, image_path)
print(f"诊断图已保存:{image_path}") print(f"诊断图已保存:{image_path}")
except Exception as exc: except Exception as exc:
print(f"生成诊断图失败(不影响 JSON):{exc}") print(f"生成诊断图失败(不影响 JSON):{exc}")
+27 -22
View File
@@ -4,7 +4,7 @@
python main.py --target 50 python main.py --target 50
程序启动后直接从当前阀门开度(默认全开)开始闭环。Ctrl+C、控制故障或 程序启动后直接从当前阀门开度(默认全开)开始闭环。Ctrl+C、控制故障或
其他异常会断开 MT2-AM8;阀门不被驱动时默认回到全开。 其他异常会断开 PLC;阀门不被驱动时默认回到全开。
""" """
import argparse import argparse
@@ -96,10 +96,10 @@ def build_control_loop(hardware, logger):
hardware=hardware, hardware=hardware,
pid=pid, pid=pid,
period_s=config.CONTROL_PERIOD_S, period_s=config.CONTROL_PERIOD_S,
flow_channel=config.FLOW_INPUT_CHANNEL, flow_channel=config.FLOW_AFTER_ADDR,
pressure_channel=config.PRESSURE_INPUT_CHANNEL, pressure_channel=config.PRESSURE_AFTER_ADDR,
pressure_before_channel=config.PRESSURE_BEFORE_CHANNEL, pressure_before_channel=config.PRESSURE_BEFORE_ADDR,
motor_channel=config.MOTOR_OUTPUT_CHANNEL, motor_channel=config.MOTOR_OUTPUT_ADDR,
motor_open_position=config.MOTOR_OPEN_POSITION, motor_open_position=config.MOTOR_OPEN_POSITION,
motor_closed_position=config.MOTOR_CLOSED_POSITION, motor_closed_position=config.MOTOR_CLOSED_POSITION,
target_min_slm=config.TARGET_FLOW_MIN_SLM, target_min_slm=config.TARGET_FLOW_MIN_SLM,
@@ -148,7 +148,7 @@ def build_control_loop(hardware, logger):
def parse_args(argv=None): def parse_args(argv=None):
parser = argparse.ArgumentParser(description="MT2-AM8 气体流量 PID 控制") parser = argparse.ArgumentParser(description="PLC 气体流量 PID 控制")
parser.add_argument( parser.add_argument(
"--target", "--target",
type=float, type=float,
@@ -197,7 +197,7 @@ def run(target_flow_slm):
config.validate_config() config.validate_config()
logger, console_gate = build_logger() logger, console_gate = build_logger()
try: try:
from PcControl import MT2AM8Client from PcControl import Easy521ModbusClient
except ModuleNotFoundError as exc: except ModuleNotFoundError as exc:
if exc.name and exc.name.startswith("pymodbus"): if exc.name and exc.name.startswith("pymodbus"):
logger.critical( logger.critical(
@@ -207,12 +207,17 @@ def run(target_flow_slm):
return 6 return 6
raise raise
hardware = MT2AM8Client( hardware = Easy521ModbusClient(
host=config.MT2AM8_HOST, host=config.PLC_HOST,
port=config.MT2AM8_PORT, port=config.PLC_PORT,
slave_id=config.MT2AM8_SLAVE_ID, slave_id=config.PLC_SLAVE_ID,
pressure_range=config.PRESSURE_RANGE_KPA, flow_addr=config.FLOW_AFTER_ADDR,
flow_range=config.FLOW_METER_RANGE_SLM, 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 = build_control_loop(hardware, logger)
controller.set_target_flow(target_flow_slm) controller.set_target_flow(target_flow_slm)
@@ -223,9 +228,9 @@ def run(target_flow_slm):
exit_code = 0 exit_code = 0
try: try:
logger.info( logger.info(
"正在连接 MT2-AM8 %s:%s,目标流量=%.3f SLM", "正在连接 PLC %s:%s,目标流量=%.3f SLM",
config.MT2AM8_HOST, config.PLC_HOST,
config.MT2AM8_PORT, config.PLC_PORT,
target_flow_slm, target_flow_slm,
) )
connected = bool(hardware.connect()) connected = bool(hardware.connect())
@@ -233,17 +238,17 @@ def run(target_flow_slm):
raise FlowControlFault( raise FlowControlFault(
"DEVICE_CONNECT_FAILED", "DEVICE_CONNECT_FAILED",
"STARTUP", "STARTUP",
"无法连接 MT2-AM8", "无法连接 PLC",
{ {
"host": config.MT2AM8_HOST, "host": config.PLC_HOST,
"port": config.MT2AM8_PORT, "port": config.PLC_PORT,
"slave_id": config.MT2AM8_SLAVE_ID, "slave_id": config.PLC_SLAVE_ID,
}, },
) )
# 控制前读取一次当前流量,作为超调量方向的基准。 # 控制前读取一次当前流量,作为超调量方向的基准。
try: try:
initial_flow_slm = hardware.get_flow(config.FLOW_INPUT_CHANNEL) initial_flow_slm = hardware.get_flow(config.FLOW_AFTER_ADDR)
except Exception: except Exception:
initial_flow_slm = None initial_flow_slm = None
@@ -376,7 +381,7 @@ def run(target_flow_slm):
hardware.disconnect() hardware.disconnect()
except Exception: except Exception:
exit_code = max(exit_code, 5) exit_code = max(exit_code, 5)
logger.exception("断开 MT2-AM8 时发生异常") logger.exception("断开 PLC 时发生异常")
if recorder is not None: if recorder is not None:
try: try:
+25 -20
View File
@@ -36,15 +36,15 @@ from flow_control import opening_to_motor_position
# 实验参数(可在本文件修改,不影响 config.py) # 实验参数(可在本文件修改,不影响 config.py)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 四个传感器的 AI 通道(0 起始,MT2-AM8 AI0~AI3)。 # 控流阀前后传感器的 PLC 寄存器地址(直接 Modbus 地址)。
# “后”= 控流阀出口侧(靠近流量计/负载一侧),沿用 config 的原始通道 # “后”= 控流阀出口侧(靠近流量计/负载一侧),沿用 config 的原始地址
# “前”= 控流阀入口侧(靠近减压阀一侧),为本次新增传感器,请按实际接线确认。 # “前”= 控流阀入口侧(靠近减压阀一侧),按实际接线确认。
PRESSURE_AFTER_CHANNEL = config.PRESSURE_INPUT_CHANNEL # 后压力,默认 0 PRESSURE_AFTER_CHANNEL = config.PRESSURE_AFTER_ADDR # 后压力 D710
FLOW_AFTER_CHANNEL = config.FLOW_INPUT_CHANNEL # 后流量,默认 1 FLOW_AFTER_CHANNEL = config.FLOW_AFTER_ADDR # 后流量 D700
PRESSURE_BEFORE_CHANNEL = 2 # 前压力(新增,需确认) PRESSURE_BEFORE_CHANNEL = config.PRESSURE_BEFORE_ADDR # 前压力 D720
FLOW_BEFORE_CHANNEL = None # 前流量(新增,需确认 FLOW_BEFORE_CHANNEL = None # 前流量(暂无传感器
MOTOR_CHANNEL = config.MOTOR_OUTPUT_CHANNEL # AO 通道 MOTOR_CHANNEL = config.MOTOR_OUTPUT_ADDR # 电机输出 D730
# 开度扫描:0%~100%,默认 5% 间隔,共 21 个开度值,随机打乱。 # 开度扫描:0%~100%,默认 5% 间隔,共 21 个开度值,随机打乱。
OPENING_MIN_PCT = 0.0 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): def set_opening(hardware, opening_pct):
"""把 0~100% 开度换算成反向电机行程并下发给 MT2-AM8""" """把 0~100% 开度换算成反向电机行程并下发给 PLC"""
position = opening_to_motor_position( position = opening_to_motor_position(
opening_pct, opening_pct,
config.MOTOR_OPEN_POSITION, config.MOTOR_OPEN_POSITION,
@@ -485,7 +485,7 @@ def build_logger():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def parse_args(argv=None): 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, parser.add_argument("--min", type=float, default=OPENING_MIN_PCT,
help=f"扫描起始开度(%%),默认 {OPENING_MIN_PCT:.0f}") help=f"扫描起始开度(%%),默认 {OPENING_MIN_PCT:.0f}")
parser.add_argument("--max", type=float, default=OPENING_MAX_PCT, parser.add_argument("--max", type=float, default=OPENING_MAX_PCT,
@@ -507,7 +507,7 @@ def run(args):
logger = build_logger() logger = build_logger()
try: try:
from PcControl import MT2AM8Client from PcControl import Easy521ModbusClient
except ModuleNotFoundError as exc: except ModuleNotFoundError as exc:
if exc.name and exc.name.startswith("pymodbus"): if exc.name and exc.name.startswith("pymodbus"):
logger.critical( logger.critical(
@@ -517,12 +517,17 @@ def run(args):
return 6 return 6
raise raise
hardware = MT2AM8Client( hardware = Easy521ModbusClient(
host=config.MT2AM8_HOST, host=config.PLC_HOST,
port=config.MT2AM8_PORT, port=config.PLC_PORT,
slave_id=config.MT2AM8_SLAVE_ID, slave_id=config.PLC_SLAVE_ID,
pressure_range=config.PRESSURE_RANGE_KPA, flow_addr=config.FLOW_AFTER_ADDR,
flow_range=config.FLOW_METER_RANGE_SLM, 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( openings = generate_openings(
@@ -538,10 +543,10 @@ def run(args):
exit_code = 0 exit_code = 0
try: 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()) connected = bool(hardware.connect())
if not connected: if not connected:
logger.critical("无法连接 MT2-AM8,实验中止") logger.critical("无法连接 PLC,实验中止")
exit_code = 2 exit_code = 2
return exit_code return exit_code
@@ -671,7 +676,7 @@ def run(args):
hardware.disconnect() hardware.disconnect()
except Exception: except Exception:
exit_code = max(exit_code, 5) exit_code = max(exit_code, 5)
logger.exception("断开 MT2-AM8 时发生异常") logger.exception("断开 PLC 时发生异常")
if recorder is not None: if recorder is not None:
try: try:
+17 -12
View File
@@ -173,7 +173,7 @@ def set_opening(hardware, opening_pct):
position = motor_position_for(opening) position = motor_position_for(opening)
if not hardware.set_motor_position( if not hardware.set_motor_position(
position, position,
channel=config.MOTOR_OUTPUT_CHANNEL, channel=config.MOTOR_OUTPUT_ADDR,
): ):
raise RuntimeError("电机位置写入失败") raise RuntimeError("电机位置写入失败")
return opening, position return opening, position
@@ -184,11 +184,11 @@ def read_sensors(hardware, quiet=False):
output = io.StringIO() output = io.StringIO()
stream = output if quiet else sys.stdout stream = output if quiet else sys.stdout
with contextlib.redirect_stdout(stream): with contextlib.redirect_stdout(stream):
flow = hardware.get_flow(config.FLOW_INPUT_CHANNEL) flow = hardware.get_flow(config.FLOW_AFTER_ADDR)
pressure = ( pressure = (
None None
if config.PRESSURE_INPUT_CHANNEL is None if config.PRESSURE_AFTER_ADDR is None
else hardware.get_pressure(config.PRESSURE_INPUT_CHANNEL) else hardware.get_pressure(config.PRESSURE_AFTER_ADDR)
) )
return flow, pressure return flow, pressure
@@ -199,17 +199,22 @@ def main():
return 1 return 1
try: try:
from PcControl import MT2AM8Client from PcControl import Easy521ModbusClient
except ModuleNotFoundError as exc: except ModuleNotFoundError as exc:
print(f"无法导入硬件客户端: {exc}") print(f"无法导入硬件客户端: {exc}")
return 2 return 2
hardware = MT2AM8Client( hardware = Easy521ModbusClient(
host=config.MT2AM8_HOST, host=config.PLC_HOST,
port=config.MT2AM8_PORT, port=config.PLC_PORT,
slave_id=config.MT2AM8_SLAVE_ID, slave_id=config.PLC_SLAVE_ID,
pressure_range=config.PRESSURE_RANGE_KPA, flow_addr=config.FLOW_AFTER_ADDR,
flow_range=config.FLOW_METER_RANGE_SLM, 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 connected = False
@@ -222,7 +227,7 @@ def main():
try: try:
print( print(
f"正在连接 MT2-AM8 {config.MT2AM8_HOST}:{config.MT2AM8_PORT} ..." f"正在连接 PLC {config.PLC_HOST}:{config.PLC_PORT} ..."
) )
connected = bool(hardware.connect()) connected = bool(hardware.connect())
if not connected: 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)) 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(): def test_f_ratio():
assert f_ratio(0.4) == 1.0 assert f_ratio(0.4) == 1.0
assert f_ratio(CRITICAL_RATIO) == 1.0 assert f_ratio(CRITICAL_RATIO) == 1.0
@@ -70,46 +101,75 @@ def test_non_monotonic_raises():
def test_identify_end_to_end(): def test_identify_end_to_end():
import identify_valve 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: with tempfile.TemporaryDirectory() as tmp:
csv_path = Path(tmp) / "sweep.csv" csv_path = Path(tmp) / "sweep.csv"
out_path = Path(tmp) / "model.json" out_path = Path(tmp) / "model.json"
with csv_path.open("w", newline="", encoding="utf-8-sig") as f: _write_sweep_csv(csv_path)
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
writer.writeheader()
writer.writerows(rows)
model, steady, table, dropped, stats = identify_valve.identify( model, steady, table, dropped, stats, area_points = identify_valve.identify(
csv_path, tail=20, out_path=str(out_path) [csv_path], tail=20, out_path=str(out_path)
) )
assert len(table) == 3, table assert len(table) == 3, table
assert stats["choked"] == 3 assert stats["choked"] == 3
assert not dropped assert not dropped
for x, a in table: for x, a in table:
assert _close(a, (1000.0 - x) / 400.0, rel=0.01), (x, a) 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) loaded = ValveModel.load(str(out_path), 800.0, 1000.0)
assert _close(loaded.area_from_stroke(900.0), 0.25, rel=0.01) 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__": if __name__ == "__main__":
tests = [ tests = [
test_f_ratio, test_f_ratio,
@@ -117,6 +177,9 @@ if __name__ == "__main__":
test_valve_model_roundtrip, test_valve_model_roundtrip,
test_non_monotonic_raises, test_non_monotonic_raises,
test_identify_end_to_end, test_identify_end_to_end,
test_merge_multiple_csvs,
test_direction_tagging,
test_resolve_csv_paths_glob,
] ]
for test in tests: for test in tests:
test() test()