从 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:
+182
-44
@@ -1,19 +1,26 @@
|
||||
"""从开环扫点 CSV 拟合 A_eff(x) 表,输出 valve_model.json。
|
||||
|
||||
用法:
|
||||
python identify_valve.py --csv open_loop_data/open_loop_xxx.csv \\
|
||||
--out valve_model.json [--tail 20] [--plot]
|
||||
python identify_valve.py --csv open_loop_data/open_loop_xxx.csv --out valve_model.json
|
||||
python identify_valve.py --csv "open_loop_data/*.csv" --out valve_model.json --plot
|
||||
python identify_valve.py --plot # 省略 --csv 时默认合并 open_loop_data/ 下全部 CSV
|
||||
|
||||
流程:
|
||||
1. 按 step_index 分组,每组取末尾 --tail 个采样点平均,得到稳态 (x, Q, P1, P2);
|
||||
2. 对每个稳态点反解 A_eff = Q / (P1_abs * F(r));
|
||||
3. 按行程升序构表,做单调性检查;
|
||||
4. 用 ValveModel 存成 JSON,并打印摘要(可选画诊断图)。
|
||||
1. --csv 支持多个路径与 glob 模式,全部 CSV 合并使用(文件名含时间戳,按名称
|
||||
排序即按实验先后合并);省略时默认 open_loop_data/*.csv;
|
||||
2. 每个 CSV 按 step_index 分组,每组取末尾 --tail 个采样点平均,得到稳态
|
||||
(x, Q, P1, P2),并按开度访问顺序标记逼近方向(上升/下降/该文件起始步);
|
||||
3. 对每个稳态点反解 A_eff = Q / (P1_abs * F(r)),同一行程 x 的多个点
|
||||
(来自不同次实验)先平均再构表;
|
||||
4. 按行程升序构表,做单调性检查;
|
||||
5. 用 ValveModel 存成 JSON,并打印摘要(可选画诊断图,实测点按逼近方向着色)。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import glob
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import config
|
||||
@@ -21,6 +28,21 @@ from valve_model import CRITICAL_RATIO, ValveModel, abs_pressure, f_ratio
|
||||
|
||||
DEFAULT_TAIL = 20
|
||||
|
||||
# 逼近方向:按开度访问顺序,当前开度大于/小于上一开度。
|
||||
DIRECTION_UP = "up" # 从更小开度升到当前开度
|
||||
DIRECTION_DOWN = "down" # 从更大开度降到当前开度
|
||||
DIRECTION_ORDER = [DIRECTION_UP, DIRECTION_DOWN, None]
|
||||
DIRECTION_LABELS = {
|
||||
DIRECTION_UP: "ascending",
|
||||
DIRECTION_DOWN: "descending",
|
||||
None: "start (unknown)",
|
||||
}
|
||||
DIRECTION_COLORS = {
|
||||
DIRECTION_UP: "#1565C0",
|
||||
DIRECTION_DOWN: "#D84315",
|
||||
None: "#757575",
|
||||
}
|
||||
|
||||
|
||||
def _to_float(text):
|
||||
"""把 CSV 单元格解析成有限浮点数;空/非法/非有限返回 None。"""
|
||||
@@ -43,8 +65,14 @@ def _average(values):
|
||||
|
||||
|
||||
def load_steady_points(csv_path, tail=DEFAULT_TAIL):
|
||||
"""提取每个 step_index 的稳态均值,返回按 step 升序的 (x, q, p1, p2) 列表。"""
|
||||
"""提取单个 CSV 每个 step_index 的稳态均值与逼近方向。
|
||||
|
||||
返回按 step(访问顺序)排列的 ``(x, q, p1, p2, direction)`` 列表。
|
||||
direction 为 ``"up"``(开度上升逼近)/ ``"down"``(下降逼近)/
|
||||
``None``(该文件第一步,无上一开度可比较)。
|
||||
"""
|
||||
rows_by_step = {}
|
||||
opening_by_step = {}
|
||||
with open(csv_path, "r", newline="", encoding="utf-8-sig") as file:
|
||||
for row in csv.DictReader(file):
|
||||
step_text = (row.get("step_index") or "").strip()
|
||||
@@ -55,9 +83,25 @@ def load_steady_points(csv_path, tail=DEFAULT_TAIL):
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
rows_by_step.setdefault(step, []).append(row)
|
||||
if step not in opening_by_step:
|
||||
opening_by_step[step] = _to_float(row.get("opening_pct"))
|
||||
|
||||
points = []
|
||||
prev_opening = None
|
||||
for step in sorted(rows_by_step):
|
||||
opening = opening_by_step.get(step)
|
||||
if prev_opening is not None and opening is not None:
|
||||
if opening > prev_opening:
|
||||
direction = DIRECTION_UP
|
||||
elif opening < prev_opening:
|
||||
direction = DIRECTION_DOWN
|
||||
else:
|
||||
direction = None
|
||||
else:
|
||||
direction = None
|
||||
if opening is not None:
|
||||
prev_opening = opening
|
||||
|
||||
rows = rows_by_step[step]
|
||||
rows.sort(key=lambda r: _to_float(r.get("time_s")) or 0.0)
|
||||
tail_rows = rows[-tail:] if tail > 0 else rows
|
||||
@@ -69,19 +113,23 @@ def load_steady_points(csv_path, tail=DEFAULT_TAIL):
|
||||
|
||||
if None in (x, q, p1, p2):
|
||||
continue
|
||||
points.append((x, q, p1, p2))
|
||||
points.append((x, q, p1, p2, direction))
|
||||
return points
|
||||
|
||||
|
||||
def compute_area_table(steady_points):
|
||||
"""把稳态点换算成 (x, A_eff) 单调表,并返回丢弃点与阻塞/亚声速统计。
|
||||
"""把稳态点换算成 (x, A_eff) 单调表,并返回逐点测量值供诊断图着色。
|
||||
|
||||
返回 ``(table, dropped, stats)``。``table`` 为按 x 升序的 (x, A_eff) 列表,
|
||||
只保留最长的单调连续段;``dropped`` 为因非单调被丢弃的点。
|
||||
返回 ``(table, dropped, stats, area_points)``:
|
||||
|
||||
- ``area_points``:每个实测点的 ``(x, A_eff, direction)``,按 x 升序,未平均;
|
||||
- ``table``:同一行程 x 的多个 A_eff 先平均,再按 x 升序取最长单调连续段;
|
||||
- ``dropped``:因非单调被丢弃的 ``(x, A_eff)``;
|
||||
- ``stats``:阻塞/亚声速点统计(按原始测量点计数)。
|
||||
"""
|
||||
area_points = []
|
||||
choked = subsonic = 0
|
||||
for x, q, p1, p2 in steady_points:
|
||||
for x, q, p1, p2, direction in steady_points:
|
||||
p1_abs = abs_pressure(p1)
|
||||
p2_abs = abs_pressure(p2)
|
||||
if p1_abs <= 0.0:
|
||||
@@ -95,21 +143,28 @@ def compute_area_table(steady_points):
|
||||
choked += 1
|
||||
else:
|
||||
subsonic += 1
|
||||
area_points.append((x, q / denom))
|
||||
area_points.append((x, q / denom, direction))
|
||||
|
||||
area_points.sort(key=lambda item: item[0])
|
||||
ys = [a for _, a in area_points]
|
||||
|
||||
# 合并多份 CSV 后同一行程会有多个点:先按 x 分组平均(CSV 以 6 位小数
|
||||
# 记录行程,round 用于吸收极小浮点差异)。
|
||||
by_x = {}
|
||||
for x, a, _ in area_points:
|
||||
by_x.setdefault(round(x, 6), []).append(a)
|
||||
merged = [(float(x), sum(vals) / len(vals)) for x, vals in sorted(by_x.items())]
|
||||
|
||||
ys = [a for _, a in merged]
|
||||
if _is_monotonic(ys):
|
||||
table = area_points
|
||||
table = merged
|
||||
dropped = []
|
||||
else:
|
||||
start, end = _longest_monotonic_run(ys)
|
||||
table = area_points[start:end + 1]
|
||||
dropped = area_points[:start] + area_points[end + 1:]
|
||||
table = merged[start:end + 1]
|
||||
dropped = merged[:start] + merged[end + 1:]
|
||||
|
||||
stats = {"choked": choked, "subsonic": subsonic, "total": choked + subsonic}
|
||||
return table, dropped, stats
|
||||
return table, dropped, stats, area_points
|
||||
|
||||
|
||||
def _is_monotonic(ys):
|
||||
@@ -151,14 +206,22 @@ def _longest_monotonic_run(ys):
|
||||
return best_start, best_end
|
||||
|
||||
|
||||
def identify(csv_path, tail=DEFAULT_TAIL, out_path=None):
|
||||
"""端到端辨识;返回 ``(model, steady_points, table, dropped, stats)``。"""
|
||||
steady = load_steady_points(csv_path, tail)
|
||||
table, dropped, stats = compute_area_table(steady)
|
||||
def identify(csv_paths, tail=DEFAULT_TAIL, out_path=None):
|
||||
"""从一份或多份开环扫点 CSV 端到端辨识。
|
||||
|
||||
返回 ``(model, steady_points, table, dropped, stats, area_points)``。
|
||||
``csv_paths`` 为路径列表,其稳态点合并后统一拟合。
|
||||
"""
|
||||
steady = []
|
||||
for path in csv_paths:
|
||||
steady.extend(load_steady_points(path, tail))
|
||||
table, dropped, stats, area_points = compute_area_table(steady)
|
||||
if len(table) < 2:
|
||||
raise ValueError(
|
||||
f"有效稳态点不足({len(table)} 个),无法构表。请确认 CSV 的 "
|
||||
"pressure_before_kpa / pressure_after_kpa / flow_after_slm 列读数合理。"
|
||||
f"有效稳态点不足({len(table)} 个),无法构表。请确认 CSV 为 "
|
||||
"open_loop.py 产出(需含 step_index 列),且 "
|
||||
"pressure_before_kpa / pressure_after_kpa / flow_after_slm "
|
||||
"列读数合理。"
|
||||
)
|
||||
model = ValveModel(
|
||||
table,
|
||||
@@ -167,11 +230,69 @@ def identify(csv_path, tail=DEFAULT_TAIL, out_path=None):
|
||||
)
|
||||
if out_path:
|
||||
model.save(out_path)
|
||||
return model, steady, table, dropped, stats
|
||||
return model, steady, table, dropped, stats, area_points
|
||||
|
||||
|
||||
def create_diagnostic_plot(steady_points, table, dropped, image_path):
|
||||
"""画 x vs A_eff(散点+插值线)与 x vs Q_ss(散点)两张子图。"""
|
||||
def resolve_csv_paths(items=None):
|
||||
"""把 CLI 传入的路径/glob 展开为去重、按名称排序的 CSV 路径列表。
|
||||
|
||||
``items`` 为空时默认使用工程目录下 ``open_loop_data/*.csv``(文件名含
|
||||
时间戳,排序后即按实验先后合并)。
|
||||
"""
|
||||
if not items:
|
||||
default_dir = Path(__file__).resolve().parent / "open_loop_data"
|
||||
items = [str(default_dir / "*.csv")]
|
||||
paths = []
|
||||
for item in items:
|
||||
pattern = str(item).replace("\\", "/")
|
||||
if glob.has_magic(pattern):
|
||||
paths.extend(sorted(glob.glob(pattern)))
|
||||
else:
|
||||
paths.append(pattern)
|
||||
# 去重:glob 结果与显式路径的斜杠方向可能不同,用 normcase 归一化比较
|
||||
# (Windows 下同时统一大小写)。
|
||||
unique = []
|
||||
seen = set()
|
||||
for path in paths:
|
||||
key = os.path.normcase(path)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(path)
|
||||
return unique
|
||||
|
||||
|
||||
def _count_directions(steady_points):
|
||||
counts = {DIRECTION_UP: 0, DIRECTION_DOWN: 0, None: 0}
|
||||
for point in steady_points:
|
||||
counts[point[4]] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def _scatter_by_direction(axis, points):
|
||||
"""按逼近方向分色画散点;``points`` 为 ``(x, y, direction)`` 列表。"""
|
||||
by_direction = {d: [] for d in DIRECTION_ORDER}
|
||||
for x, y, direction in points:
|
||||
by_direction.setdefault(direction, []).append((x, y))
|
||||
for direction in DIRECTION_ORDER:
|
||||
pts = by_direction.get(direction, [])
|
||||
if not pts:
|
||||
continue
|
||||
axis.scatter(
|
||||
[p[0] for p in pts],
|
||||
[p[1] for p in pts],
|
||||
color=DIRECTION_COLORS[direction],
|
||||
label=DIRECTION_LABELS[direction],
|
||||
s=18,
|
||||
alpha=0.85,
|
||||
)
|
||||
|
||||
|
||||
def create_diagnostic_plot(steady_points, table, dropped, area_points, image_path):
|
||||
"""画 x vs A_eff 与 x vs Q_ss 两张子图,实测点按逼近方向着色。
|
||||
|
||||
同一行程下上升/下降两团点明显分离,说明阀门迟滞显著,前馈表应考虑
|
||||
分方向处理;完全重叠则迟滞可忽略。
|
||||
"""
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
@@ -183,13 +304,13 @@ def create_diagnostic_plot(steady_points, table, dropped, image_path):
|
||||
figure, axes = plt.subplots(2, 1, figsize=(10, 8), constrained_layout=True)
|
||||
figure.suptitle("Identified valve characteristic")
|
||||
|
||||
axes[0].scatter(xs, areas, color="#1565C0", label="measured A_eff")
|
||||
axes[0].plot(xs, areas, color="#1565C0", linewidth=1.2)
|
||||
axes[0].plot(xs, areas, color="#263238", linewidth=1.2, label="fitted A_eff (per-x mean)")
|
||||
_scatter_by_direction(axes[0], area_points)
|
||||
if dropped:
|
||||
axes[0].scatter(
|
||||
[x for x, _ in dropped],
|
||||
[a for _, a in dropped],
|
||||
color="#D84315",
|
||||
color="#B71C1C",
|
||||
marker="x",
|
||||
label="dropped (non-monotonic)",
|
||||
)
|
||||
@@ -198,9 +319,9 @@ def create_diagnostic_plot(steady_points, table, dropped, image_path):
|
||||
axes[0].legend(loc="best")
|
||||
axes[0].grid(True, alpha=0.3, linestyle="--")
|
||||
|
||||
qxs = [x for x, _, _, _ in steady_points]
|
||||
qs = [q for _, q, _, _ in steady_points]
|
||||
axes[1].scatter(qxs, qs, color="#2E7D32", label="measured Q_ss")
|
||||
_scatter_by_direction(
|
||||
axes[1], [(p[0], p[1], p[4]) for p in steady_points]
|
||||
)
|
||||
axes[1].set_ylabel("Flow (SLM)")
|
||||
axes[1].set_xlabel("Motor stroke x")
|
||||
axes[1].set_title("Steady flow vs stroke")
|
||||
@@ -213,9 +334,12 @@ def create_diagnostic_plot(steady_points, table, dropped, image_path):
|
||||
|
||||
def parse_args(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="从开环扫点 CSV 拟合 A_eff(x) 阀特性表"
|
||||
description="从开环扫点 CSV 拟合 A_eff(x) 阀特性表(支持多 CSV/glob 合并)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--csv", nargs="+",
|
||||
help="一个或多个 CSV 路径或 glob 模式;省略时默认合并 open_loop_data/*.csv",
|
||||
)
|
||||
parser.add_argument("--csv", required=True, help="开环扫点 CSV 路径")
|
||||
parser.add_argument("--out", default="valve_model.json", help="输出 JSON 路径")
|
||||
parser.add_argument(
|
||||
"--tail", type=int, default=DEFAULT_TAIL,
|
||||
@@ -230,26 +354,40 @@ def parse_args(argv=None):
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_args(argv)
|
||||
csv_path = Path(args.csv)
|
||||
if not csv_path.exists():
|
||||
print(f"CSV 不存在:{csv_path}")
|
||||
csv_paths = resolve_csv_paths(args.csv)
|
||||
if not csv_paths:
|
||||
print("未找到任何 CSV:请确认 open_loop_data/ 下有开环扫点数据,")
|
||||
print('或用 --csv 指定路径或 glob 模式(如 --csv "open_loop_data/*.csv")。')
|
||||
return 2
|
||||
for path in csv_paths:
|
||||
if not Path(path).exists():
|
||||
print(f"CSV 不存在:{path}")
|
||||
return 2
|
||||
|
||||
try:
|
||||
model, steady, table, dropped, stats = identify(
|
||||
csv_path, tail=args.tail, out_path=args.out
|
||||
model, steady, table, dropped, stats, area_points = identify(
|
||||
csv_paths, tail=args.tail, out_path=args.out
|
||||
)
|
||||
except (ValueError, OSError) as exc:
|
||||
print(f"辨识失败:{exc}")
|
||||
return 2
|
||||
|
||||
if len(csv_paths) <= 8:
|
||||
print("合并的 CSV:")
|
||||
for path in csv_paths:
|
||||
print(f" {path}")
|
||||
else:
|
||||
print(f"合并的 CSV:{csv_paths[0]} 等共 {len(csv_paths)} 个文件")
|
||||
directions = _count_directions(steady)
|
||||
print(
|
||||
f"读取稳态点 {len(steady)} 个;阻塞 {stats['choked']} 个,"
|
||||
f"亚声速 {stats['subsonic']} 个,合计 {stats['total']} 个。"
|
||||
f"读取稳态点 {len(steady)} 个:上升逼近 {directions[DIRECTION_UP]}、"
|
||||
f"下降逼近 {directions[DIRECTION_DOWN]}、起始点 {directions[None]};"
|
||||
f"阻塞 {stats['choked']} 个,亚声速 {stats['subsonic']} 个,"
|
||||
f"合计 {stats['total']} 个。"
|
||||
)
|
||||
print(
|
||||
f"有效行程范围 {table[0][0]:.1f} ~ {table[-1][0]:.1f}"
|
||||
f"({len(table)} 点)。"
|
||||
f"({len(table)} 点,同一行程已平均)。"
|
||||
)
|
||||
|
||||
if dropped:
|
||||
@@ -270,7 +408,7 @@ def main(argv=None):
|
||||
if args.plot:
|
||||
image_path = Path(args.out).with_suffix(".png")
|
||||
try:
|
||||
create_diagnostic_plot(steady, table, dropped, image_path)
|
||||
create_diagnostic_plot(steady, table, dropped, area_points, image_path)
|
||||
print(f"诊断图已保存:{image_path}")
|
||||
except Exception as exc:
|
||||
print(f"生成诊断图失败(不影响 JSON):{exc}")
|
||||
|
||||
Reference in New Issue
Block a user