从 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:
+2
-1
@@ -6,4 +6,5 @@ __pycache__
|
|||||||
阻塞流压力判断.md
|
阻塞流压力判断.md
|
||||||
压差压比对流量影响的突发奇想.md
|
压差压比对流量影响的突发奇想.md
|
||||||
MPC与PID控制系统对比分析.md
|
MPC与PID控制系统对比分析.md
|
||||||
plan.md
|
plan.md
|
||||||
|
valve_pipeline.html
|
||||||
+92
-191
@@ -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 客户端类 ----------
|
# ---------- PLC(Easy521)Modbus 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_max(1000)坐标(死区由 config 给定),映射到 0~raw_max 输出。
|
||||||
|
|
||||||
|
对外接口沿用 MT2AM8Client 的 get_flow / get_pressure / set_motor_position,
|
||||||
|
只是这里的 ``channel`` 参数是寄存器地址。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
host="192.168.1.88",
|
||||||
|
port=502,
|
||||||
|
slave_id=1,
|
||||||
|
flow_addr=700,
|
||||||
|
flow_range=300.0,
|
||||||
|
pressure_before_addr=720,
|
||||||
|
pressure_before_range=300.0,
|
||||||
|
pressure_after_addr=710,
|
||||||
|
pressure_after_range=400.0,
|
||||||
|
motor_output_addr=730,
|
||||||
|
):
|
||||||
self.host = host
|
self.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,
|
return None
|
||||||
wordorder=Endian.LITTLE
|
|
||||||
)
|
|
||||||
return decoder.decode_32bit_float()
|
|
||||||
else:
|
|
||||||
print(f"读取寄存器错误: {result}")
|
|
||||||
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:
|
||||||
else:
|
rng = self.pressure_after_range
|
||||||
print("警告:M901 复位失败")
|
|
||||||
else:
|
else:
|
||||||
print("警告:M901 置位失败")
|
print(f"未知压力寄存器 D{address},按阀后量程兜底")
|
||||||
time.sleep(1)
|
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):
|
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 客户端类(含报文打印) ----------
|
||||||
|
|||||||
@@ -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-AM8:Modbus TCP,默认 `192.168.1.12:502`,站号 `1`;
|
- PLC(Easy521):Modbus 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_base,Ki_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` | 电机输出 D730(0~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` |
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# PLC(Easy521)Modbus 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 # 电机输出 D730,0~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
|
|
||||||
|
|
||||||
# 阀特性表 JSON(identify_valve.py 产出)。空字符串表示关闭前馈。
|
# 阀特性表 JSON(identify_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
@@ -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
@@ -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
|
return 2
|
||||||
|
for path in csv_paths:
|
||||||
|
if not Path(path).exists():
|
||||||
|
print(f"CSV 不存在:{path}")
|
||||||
|
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}")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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) / 400,Q_ss 按阻塞流(F=1)反推。
|
||||||
|
"""
|
||||||
|
rows = []
|
||||||
|
for step, opening in enumerate(visit_openings):
|
||||||
|
x = 1000.0 - opening / 100.0 * 200.0 # 与 flow_control 的映射一致
|
||||||
|
a_eff = scale * (1000.0 - x) / 400.0 # 单调递减的真值
|
||||||
|
q_ss = a_eff * (400.0 + P_ATM) # 阻塞流,F=1
|
||||||
|
for i in range(30):
|
||||||
|
rows.append({
|
||||||
|
"time_s": f"{step * 10 + i * 0.1:.6f}",
|
||||||
|
"step_index": step,
|
||||||
|
"opening_pct": opening,
|
||||||
|
"motor_position": x,
|
||||||
|
"flow_before_slm": "",
|
||||||
|
"flow_after_slm": q_ss,
|
||||||
|
"pressure_before_kpa": 400.0,
|
||||||
|
"pressure_after_kpa": 0.0,
|
||||||
|
"P_abs_ratio": "",
|
||||||
|
"is_ratio_smaller_than_0.528": "Y",
|
||||||
|
})
|
||||||
|
|
||||||
|
with path.open("w", newline="", encoding="utf-8-sig") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
def test_f_ratio():
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user