Files
flow_control/identify_valve.py
T
louis 5888b4ccce 从 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 中的硬件接线、配置默认值与模块职责描述
2026-09-01 17:03:26 +08:00

423 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""从开环扫点 CSV 拟合 A_eff(x) 表,输出 valve_model.json。
用法:
python identify_valve.py --csv open_loop_data/open_loop_xxx.csv --out valve_model.json
python identify_valve.py --csv "open_loop_data/*.csv" --out valve_model.json --plot
python identify_valve.py --plot # 省略 --csv 时默认合并 open_loop_data/ 下全部 CSV
流程:
1. --csv 支持多个路径与 glob 模式,全部 CSV 合并使用(文件名含时间戳,按名称
排序即按实验先后合并);省略时默认 open_loop_data/*.csv
2. 每个 CSV 按 step_index 分组,每组取末尾 --tail 个采样点平均,得到稳态
(x, Q, P1, P2),并按开度访问顺序标记逼近方向(上升/下降/该文件起始步);
3. 对每个稳态点反解 A_eff = Q / (P1_abs * F(r)),同一行程 x 的多个点
(来自不同次实验)先平均再构表;
4. 按行程升序构表,做单调性检查;
5. 用 ValveModel 存成 JSON,并打印摘要(可选画诊断图,实测点按逼近方向着色)。
"""
import argparse
import csv
import glob
import math
import os
from pathlib import Path
import config
from valve_model import CRITICAL_RATIO, ValveModel, abs_pressure, f_ratio
DEFAULT_TAIL = 20
# 逼近方向:按开度访问顺序,当前开度大于/小于上一开度。
DIRECTION_UP = "up" # 从更小开度升到当前开度
DIRECTION_DOWN = "down" # 从更大开度降到当前开度
DIRECTION_ORDER = [DIRECTION_UP, DIRECTION_DOWN, None]
DIRECTION_LABELS = {
DIRECTION_UP: "ascending",
DIRECTION_DOWN: "descending",
None: "start (unknown)",
}
DIRECTION_COLORS = {
DIRECTION_UP: "#1565C0",
DIRECTION_DOWN: "#D84315",
None: "#757575",
}
def _to_float(text):
"""把 CSV 单元格解析成有限浮点数;空/非法/非有限返回 None。"""
if text is None:
return None
text = str(text).strip()
if text == "":
return None
try:
value = float(text)
except (TypeError, ValueError):
return None
return value if math.isfinite(value) else None
def _average(values):
"""返回非 None 数值的平均;没有有效值时返回 None。"""
valid = [value for value in values if value is not None]
return None if not valid else sum(valid) / len(valid)
def load_steady_points(csv_path, tail=DEFAULT_TAIL):
"""提取单个 CSV 每个 step_index 的稳态均值与逼近方向。
返回按 step(访问顺序)排列的 ``(x, q, p1, p2, direction)`` 列表。
direction 为 ``"up"``(开度上升逼近)/ ``"down"``(下降逼近)/
``None``(该文件第一步,无上一开度可比较)。
"""
rows_by_step = {}
opening_by_step = {}
with open(csv_path, "r", newline="", encoding="utf-8-sig") as file:
for row in csv.DictReader(file):
step_text = (row.get("step_index") or "").strip()
if step_text == "":
continue
try:
step = int(float(step_text))
except (TypeError, ValueError):
continue
rows_by_step.setdefault(step, []).append(row)
if step not in opening_by_step:
opening_by_step[step] = _to_float(row.get("opening_pct"))
points = []
prev_opening = None
for step in sorted(rows_by_step):
opening = opening_by_step.get(step)
if prev_opening is not None and opening is not None:
if opening > prev_opening:
direction = DIRECTION_UP
elif opening < prev_opening:
direction = DIRECTION_DOWN
else:
direction = None
else:
direction = None
if opening is not None:
prev_opening = opening
rows = rows_by_step[step]
rows.sort(key=lambda r: _to_float(r.get("time_s")) or 0.0)
tail_rows = rows[-tail:] if tail > 0 else rows
x = _average(_to_float(r.get("motor_position")) for r in tail_rows)
q = _average(_to_float(r.get("flow_after_slm")) for r in tail_rows)
p1 = _average(_to_float(r.get("pressure_before_kpa")) for r in tail_rows)
p2 = _average(_to_float(r.get("pressure_after_kpa")) for r in tail_rows)
if None in (x, q, p1, p2):
continue
points.append((x, q, p1, p2, direction))
return points
def compute_area_table(steady_points):
"""把稳态点换算成 (x, A_eff) 单调表,并返回逐点测量值供诊断图着色。
返回 ``(table, dropped, stats, area_points)``
- ``area_points``:每个实测点的 ``(x, A_eff, direction)``,按 x 升序,未平均;
- ``table``:同一行程 x 的多个 A_eff 先平均,再按 x 升序取最长单调连续段;
- ``dropped``:因非单调被丢弃的 ``(x, A_eff)``
- ``stats``:阻塞/亚声速点统计(按原始测量点计数)。
"""
area_points = []
choked = subsonic = 0
for x, q, p1, p2, direction in steady_points:
p1_abs = abs_pressure(p1)
p2_abs = abs_pressure(p2)
if p1_abs <= 0.0:
continue
r = p2_abs / p1_abs
f = f_ratio(r)
denom = p1_abs * f
if denom <= 0.0 or q < 0.0:
continue
if r <= CRITICAL_RATIO:
choked += 1
else:
subsonic += 1
area_points.append((x, q / denom, direction))
area_points.sort(key=lambda item: item[0])
# 合并多份 CSV 后同一行程会有多个点:先按 x 分组平均(CSV 以 6 位小数
# 记录行程,round 用于吸收极小浮点差异)。
by_x = {}
for x, a, _ in area_points:
by_x.setdefault(round(x, 6), []).append(a)
merged = [(float(x), sum(vals) / len(vals)) for x, vals in sorted(by_x.items())]
ys = [a for _, a in merged]
if _is_monotonic(ys):
table = merged
dropped = []
else:
start, end = _longest_monotonic_run(ys)
table = merged[start:end + 1]
dropped = merged[:start] + merged[end + 1:]
stats = {"choked": choked, "subsonic": subsonic, "total": choked + subsonic}
return table, dropped, stats, area_points
def _is_monotonic(ys):
"""序列是否单调(允许相等,但不允许中途反向)。"""
direction = 0
for i in range(1, len(ys)):
delta = ys[i] - ys[i - 1]
if delta == 0:
continue
sign = 1 if delta > 0 else -1
if direction == 0:
direction = sign
elif direction != sign:
return False
return True
def _longest_monotonic_run(ys):
"""返回最长单调连续段的闭区间下标 ``(start, end)``。"""
best_start = best_end = 0
for start in range(len(ys)):
direction = 0
end = start
for j in range(start + 1, len(ys)):
delta = ys[j] - ys[j - 1]
if delta == 0:
end = j
continue
sign = 1 if delta > 0 else -1
if direction == 0:
direction = sign
end = j
elif direction == sign:
end = j
else:
break
if end - start > best_end - best_start:
best_start, best_end = start, end
return best_start, best_end
def identify(csv_paths, tail=DEFAULT_TAIL, out_path=None):
"""从一份或多份开环扫点 CSV 端到端辨识。
返回 ``(model, steady_points, table, dropped, stats, area_points)``。
``csv_paths`` 为路径列表,其稳态点合并后统一拟合。
"""
steady = []
for path in csv_paths:
steady.extend(load_steady_points(path, tail))
table, dropped, stats, area_points = compute_area_table(steady)
if len(table) < 2:
raise ValueError(
f"有效稳态点不足({len(table)} 个),无法构表。请确认 CSV 为 "
"open_loop.py 产出(需含 step_index 列),且 "
"pressure_before_kpa / pressure_after_kpa / flow_after_slm "
"列读数合理。"
)
model = ValveModel(
table,
config.MOTOR_OPEN_POSITION,
config.MOTOR_CLOSED_POSITION,
)
if out_path:
model.save(out_path)
return model, steady, table, dropped, stats, area_points
def resolve_csv_paths(items=None):
"""把 CLI 传入的路径/glob 展开为去重、按名称排序的 CSV 路径列表。
``items`` 为空时默认使用工程目录下 ``open_loop_data/*.csv``(文件名含
时间戳,排序后即按实验先后合并)。
"""
if not items:
default_dir = Path(__file__).resolve().parent / "open_loop_data"
items = [str(default_dir / "*.csv")]
paths = []
for item in items:
pattern = str(item).replace("\\", "/")
if glob.has_magic(pattern):
paths.extend(sorted(glob.glob(pattern)))
else:
paths.append(pattern)
# 去重:glob 结果与显式路径的斜杠方向可能不同,用 normcase 归一化比较
# (Windows 下同时统一大小写)。
unique = []
seen = set()
for path in paths:
key = os.path.normcase(path)
if key not in seen:
seen.add(key)
unique.append(path)
return unique
def _count_directions(steady_points):
counts = {DIRECTION_UP: 0, DIRECTION_DOWN: 0, None: 0}
for point in steady_points:
counts[point[4]] += 1
return counts
def _scatter_by_direction(axis, points):
"""按逼近方向分色画散点;``points`` 为 ``(x, y, direction)`` 列表。"""
by_direction = {d: [] for d in DIRECTION_ORDER}
for x, y, direction in points:
by_direction.setdefault(direction, []).append((x, y))
for direction in DIRECTION_ORDER:
pts = by_direction.get(direction, [])
if not pts:
continue
axis.scatter(
[p[0] for p in pts],
[p[1] for p in pts],
color=DIRECTION_COLORS[direction],
label=DIRECTION_LABELS[direction],
s=18,
alpha=0.85,
)
def create_diagnostic_plot(steady_points, table, dropped, area_points, image_path):
"""画 x vs A_eff 与 x vs Q_ss 两张子图,实测点按逼近方向着色。
同一行程下上升/下降两团点明显分离,说明阀门迟滞显著,前馈表应考虑
分方向处理;完全重叠则迟滞可忽略。
"""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
xs = [x for x, _ in table]
areas = [a for _, a in table]
figure, axes = plt.subplots(2, 1, figsize=(10, 8), constrained_layout=True)
figure.suptitle("Identified valve characteristic")
axes[0].plot(xs, areas, color="#263238", linewidth=1.2, label="fitted A_eff (per-x mean)")
_scatter_by_direction(axes[0], area_points)
if dropped:
axes[0].scatter(
[x for x, _ in dropped],
[a for _, a in dropped],
color="#B71C1C",
marker="x",
label="dropped (non-monotonic)",
)
axes[0].set_ylabel("A_eff (slm/kPa)")
axes[0].set_title("Effective area vs stroke")
axes[0].legend(loc="best")
axes[0].grid(True, alpha=0.3, linestyle="--")
_scatter_by_direction(
axes[1], [(p[0], p[1], p[4]) for p in steady_points]
)
axes[1].set_ylabel("Flow (SLM)")
axes[1].set_xlabel("Motor stroke x")
axes[1].set_title("Steady flow vs stroke")
axes[1].legend(loc="best")
axes[1].grid(True, alpha=0.3, linestyle="--")
figure.savefig(image_path, dpi=config.PLOT_DPI, bbox_inches="tight")
plt.close(figure)
def parse_args(argv=None):
parser = argparse.ArgumentParser(
description="从开环扫点 CSV 拟合 A_eff(x) 阀特性表(支持多 CSV/glob 合并)"
)
parser.add_argument(
"--csv", nargs="+",
help="一个或多个 CSV 路径或 glob 模式;省略时默认合并 open_loop_data/*.csv",
)
parser.add_argument("--out", default="valve_model.json", help="输出 JSON 路径")
parser.add_argument(
"--tail", type=int, default=DEFAULT_TAIL,
help=f"每组末尾用于平均的采样点数(默认 {DEFAULT_TAIL}",
)
parser.add_argument("--plot", action="store_true", help="生成诊断图 PNG")
args = parser.parse_args(argv)
if args.tail < 1:
parser.error("--tail 必须 >= 1")
return args
def main(argv=None):
args = parse_args(argv)
csv_paths = resolve_csv_paths(args.csv)
if not csv_paths:
print("未找到任何 CSV:请确认 open_loop_data/ 下有开环扫点数据,")
print('或用 --csv 指定路径或 glob 模式(如 --csv "open_loop_data/*.csv")。')
return 2
for path in csv_paths:
if not Path(path).exists():
print(f"CSV 不存在:{path}")
return 2
try:
model, steady, table, dropped, stats, area_points = identify(
csv_paths, tail=args.tail, out_path=args.out
)
except (ValueError, OSError) as exc:
print(f"辨识失败:{exc}")
return 2
if len(csv_paths) <= 8:
print("合并的 CSV")
for path in csv_paths:
print(f" {path}")
else:
print(f"合并的 CSV{csv_paths[0]} 等共 {len(csv_paths)} 个文件")
directions = _count_directions(steady)
print(
f"读取稳态点 {len(steady)} 个:上升逼近 {directions[DIRECTION_UP]}、"
f"下降逼近 {directions[DIRECTION_DOWN]}、起始点 {directions[None]}"
f"阻塞 {stats['choked']} 个,亚声速 {stats['subsonic']} 个,"
f"合计 {stats['total']} 个。"
)
print(
f"有效行程范围 {table[0][0]:.1f} ~ {table[-1][0]:.1f}"
f"{len(table)} 点,同一行程已平均)。"
)
if dropped:
print(
"警告:A_eff(x) 非单调,以下点被丢弃"
"(建议缩小扫点范围到单调段重扫)。"
)
print("行程 x -> A_eff")
for x, a in table:
print(f" x={x:8.3f} A_eff={a:.6f}")
if dropped:
print("被丢弃的点:")
for x, a in dropped:
print(f" x={x:8.3f} A_eff={a:.6f} [DROPPED]")
print(f"已保存阀特性模型:{args.out}")
if args.plot:
image_path = Path(args.out).with_suffix(".png")
try:
create_diagnostic_plot(steady, table, dropped, area_points, image_path)
print(f"诊断图已保存:{image_path}")
except Exception as exc:
print(f"生成诊断图失败(不影响 JSON):{exc}")
return 0
if __name__ == "__main__":
import sys
sys.exit(main())