336 lines
17 KiB
Python
336 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""PDO 周期扫描:一次跑完多档,同时记「速度达成 / 故障率 / CPU 占用」三条曲线。
|
||
|
||
与 tools/ecat/rate_probe.py 的区别:rate_probe 只量速度达成率,本脚本把故障率与
|
||
CPU 一并采齐并落成机读 CSV,供 plot_ecat_sweep.py 画曲线。速率达成部分的
|
||
"cycles 与时刻同取同舍"纪律直接沿用 rate_probe(它注释里记过一次把 99.2% 误报
|
||
成 96% 的实踩教训:分子取末次 cycles、分母却在循环退出后才算,dt 多出一次 sleep)。
|
||
|
||
必须绕开的四件事(均由 Phase 0 现场标定确认,不是照抄文档):
|
||
1 u16 回绕:bridge.drop 在 4000/s 下 16s 绕一圈,首尾相减必错 → 高频逐次累加,
|
||
且单次增量 >32768 视为绕圈、该点作废并计数暴露采样率不足。
|
||
2 rx_torn / pdo_reentry 是**饱和**计数(到 65535 停),不能按回绕算 → 走
|
||
delta_saturate,封顶即标注"下界"而不是报精确值。
|
||
3 周期能被 localStorage 劫持:任何开着的电机页标签在 ensureStarted 时会把总线
|
||
周期拽回存量值(transport-ecat.js:227-228)→ 每档下发后回读 period_ms 确认
|
||
真的生效,不一致就把该档标记为被劫持。
|
||
4 λ(CAN 到帧率)取 u32 的 head.global_seq,不取 u16 的 bridge.rx_count。
|
||
本次实测到关键分界:1ms/0.5ms 档 λ=4000/s、GD32 负载 44%/77%;0.25ms/0.125ms
|
||
档 GD32 顶到 99.5%/99.3%,λ 塌到 149/s —— 从站主循环被 PDO 填充吃光、CAN 接收
|
||
停摆。而主站达成率反而仍很高(0.125ms 实测 7780Hz),所以瓶颈在从站不在主站:
|
||
只看达成率会把这条线整个看漏。
|
||
|
||
用法:
|
||
python scripts/sweep_ecat_rate.py # 默认六档 × 60s
|
||
python scripts/sweep_ecat_rate.py --periods 0.25,0.5,1 --dwell 30
|
||
python scripts/sweep_ecat_rate.py --restore 1 --out lab/ecat_sweep.csv
|
||
python scripts/sweep_ecat_rate.py --dry-run # 只走一遍不下发周期
|
||
结束后一定把周期恢复到脚本启动前的原值(含 Ctrl-C / 异常)。
|
||
"""
|
||
import argparse
|
||
import csv
|
||
import os
|
||
import signal
|
||
import sys
|
||
import time
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
from calibrate_ecat_metrics import (cadence_rings, find_native_pid,
|
||
firmware_identity, ensure_loop)
|
||
from ecat_sweep_common import delta_saturate, delta_wrap, percentile, post
|
||
|
||
DEFAULT_PERIODS = "0.125,0.25,0.5,1,2,4"
|
||
COLS = ["period_ms", "set_period_ms", "hijacked", "native_pid", "pid_stable",
|
||
"measured_hz", "achieve_pct", "can_lambda_per_s",
|
||
"drop_per_s", "drop_pct_of_lambda", "drop_untrusted", "lambda_u16_crosscheck",
|
||
"rxb_count_mode_per_cycle", "rxb_lost_max", "rxb_pending_max",
|
||
"skip_per_s", "skip_delta", "motors_online", "online_frac", "motor_refresh_per_s",
|
||
"torn_per_s", "torn_cum", "torn_capped",
|
||
"reentry_per_s", "reentry_cum", "reentry_capped", "wkc", "expected_wkc", "wr",
|
||
"cyc_p50_us", "cyc_p90_us", "cyc_max_us", "cyc_duty_pct",
|
||
"gd32_load_pct", "gd32_load_samples", "gd32_load_stale",
|
||
"host_cpu_1core_pct", "host_cpu_total_pct",
|
||
"logical_cpus", "samples", "errors", "dwell_s", "note"]
|
||
|
||
|
||
def sample_period(url, ms, dwell, hz, substr, autostart, dry):
|
||
"""在给定周期档上采 dwell 秒,返回一行 CSV 记录。"""
|
||
note = []
|
||
pid0, perr = find_native_pid(url)
|
||
st0 = post(url, "/api/motor/state")
|
||
if not (st0.get("ok") and st0.get("cycles")):
|
||
st0, how = ensure_loop(url, autostart, substr)
|
||
note.append("起循环:" + how)
|
||
orig = st0.get("period_ms")
|
||
|
||
if dry:
|
||
return {"period_ms": ms, "note": "dry-run 未下发"}
|
||
|
||
r = post(url, "/api/motor/period", {"period_ms": ms})
|
||
if not r.get("ok"):
|
||
return {"period_ms": ms, "note": "档位被拒:" + str(r.get("log"))[:80]}
|
||
|
||
# 等周期真正生效(热调是异步落到循环线程的)
|
||
eff, stable_iters = None, 0
|
||
t_deadline = time.perf_counter() + 3.0
|
||
while time.perf_counter() < t_deadline:
|
||
time.sleep(0.2)
|
||
eff = (post(url, "/api/motor/state")).get("period_ms")
|
||
if eff is not None and abs(float(eff) - float(ms)) < 1e-6:
|
||
stable_iters += 1
|
||
if stable_iters >= 3:
|
||
break
|
||
else:
|
||
stable_iters = 0
|
||
hijacked = "no"
|
||
if eff is None or abs(float(eff) - float(ms)) >= 1e-6:
|
||
hijacked = "YES"
|
||
note.append(f"下发 {ms}ms 但生效 {eff}ms(疑 localStorage 劫持或有他端在设)")
|
||
|
||
interval = 1.0 / hz
|
||
cyc_hz, drop_rate, rx_rate, gseq_rate, loads, hc1, hct = [], [], [], [], [], [], []
|
||
cyc_p50, cyc_p90, cyc_max = [], [], []
|
||
untrusted = torn_sum = reentry_sum = err = iters = load_stale = 0
|
||
torn_cap = reentry_cap = False
|
||
torn_prev = re_prev = None
|
||
torn_cum = re_cum = None
|
||
node_seq = {} # node -> (t, seq):上一拍的槽 seq,用于算电机数据刷新率
|
||
online_hits = online_tot = 0
|
||
refresh_rates = []
|
||
prev = prev_t = None
|
||
wr0 = (post(url, "/api/motor/state")).get("wr")
|
||
skip0 = (post(url, "/api/motor/dbg")).get("skip_total")
|
||
proc = None
|
||
try:
|
||
import psutil
|
||
if pid0:
|
||
proc = psutil.Process(pid0)
|
||
proc.cpu_percent(None) # 建立差分基线
|
||
except Exception as e:
|
||
note.append(f"psutil 不可用({e.__class__.__name__}),主机 CPU 缺")
|
||
|
||
t_end = time.perf_counter() + dwell
|
||
while time.perf_counter() < t_end:
|
||
tc = time.perf_counter()
|
||
try:
|
||
s = post(url, "/api/motor/state")
|
||
L = post(url, "/api/app/load")
|
||
except Exception:
|
||
err += 1
|
||
time.sleep(interval)
|
||
continue
|
||
iters += 1
|
||
b = s.get("bridge") or {}
|
||
cur = {"cycles": s.get("cycles"), "drop": b.get("drop"), "rx": b.get("rx_count"),
|
||
"gseq": (s.get("head") or {}).get("global_seq")}
|
||
if prev is not None and tc > prev_t:
|
||
dt = tc - prev_t
|
||
if cur["cycles"] is not None and prev["cycles"] is not None:
|
||
h = (cur["cycles"] - prev["cycles"]) / dt
|
||
if 0 < h < 1e6:
|
||
cyc_hz.append(h)
|
||
if cur["drop"] is not None and prev["drop"] is not None:
|
||
d = delta_wrap(prev["drop"], cur["drop"], 16)
|
||
if d < 32768:
|
||
drop_rate.append(d / dt)
|
||
else:
|
||
untrusted += 1 # 已绕圈:该点作废,只计数不臆造速率
|
||
# λ 以 u32 的 head.global_seq 为准。u16 的 bridge.rx_count/drop 在快档
|
||
# 会失真(实测 0.25ms 档真值 149/s,两路 u16 与 u32 虽一致但增量已贴近
|
||
# 截断区),留作交叉核对:两者显著背离即说明该档 u16 不可信。
|
||
if cur["gseq"] is not None and prev["gseq"] is not None:
|
||
d = delta_wrap(prev["gseq"], cur["gseq"], 32)
|
||
if 0 <= d < (1 << 31):
|
||
gseq_rate.append(d / dt)
|
||
if cur["rx"] is not None and prev["rx"] is not None:
|
||
d = delta_wrap(prev["rx"], cur["rx"], 32)
|
||
if d < (1 << 31):
|
||
rx_rate.append(d / dt)
|
||
tn, rn = L.get("rx_torn"), L.get("pdo_reentry")
|
||
if tn is not None:
|
||
if torn_prev is not None and not torn_cap:
|
||
dd, capped = delta_saturate(torn_prev, tn)
|
||
torn_sum += dd
|
||
torn_cap |= capped
|
||
torn_prev = tn
|
||
if rn is not None:
|
||
if re_prev is not None and not reentry_cap:
|
||
dd, capped = delta_saturate(re_prev, rn)
|
||
reentry_sum += dd
|
||
reentry_cap |= capped
|
||
re_prev = rn
|
||
# 从站停报时 load_perm 会保留最后一次的值(实测:从站楔死后 age_ms=214000
|
||
# 而 load_perm 仍报 992)。不加 age 闸就会把残值当实测写进结论。
|
||
if (L.get("valid") and L.get("load_perm") is not None
|
||
and (L.get("age_ms") if L.get("age_ms") is not None else 1e9) < 1000):
|
||
loads.append(L["load_perm"] / 10.0)
|
||
else:
|
||
load_stale += 1
|
||
cu = (s.get("cyc_us") or {}).get("total") or {}
|
||
if cu.get("p50") is not None:
|
||
cyc_p50.append(cu["p50"])
|
||
if cu.get("p90") is not None:
|
||
cyc_p90.append(cu["p90"])
|
||
if cu.get("max") is not None:
|
||
cyc_max.append(cu["max"])
|
||
if proc is not None:
|
||
try:
|
||
c = proc.cpu_percent(None)
|
||
hc1.append(c)
|
||
hct.append(c / max(1, os.cpu_count() or 1))
|
||
except Exception:
|
||
pass
|
||
# 电机在线标志与数据刷新率:快档塌陷必须能区分"从站算不过来"与"电机掉线"
|
||
# —— 二者在 λ/seq 上长得一模一样,只有 online 标志能把它们切开。
|
||
for m in (s.get("motors") or []):
|
||
nd = m.get("node")
|
||
if nd is None:
|
||
continue
|
||
online_tot += 1
|
||
online_hits += 1 if m.get("online") else 0
|
||
sq = m.get("seq")
|
||
if sq is None:
|
||
continue
|
||
pr = node_seq.get(nd)
|
||
if pr and tc > pr[0]:
|
||
d = delta_wrap(pr[1], sq, 32)
|
||
if d < (1 << 31):
|
||
refresh_rates.append(d / (tc - pr[0]))
|
||
node_seq[nd] = (tc, sq)
|
||
prev, prev_t = cur, tc
|
||
time.sleep(max(0.0, interval - (time.perf_counter() - tc)))
|
||
|
||
dur = max(1e-6, time.perf_counter() - (t_end - dwell))
|
||
stf = post(url, "/api/motor/state")
|
||
tend = post(url, "/api/app/load")
|
||
pid1, _ = find_native_pid(url)
|
||
skip1 = (post(url, "/api/motor/dbg")).get("skip_total")
|
||
cad = cadence_rings(url)
|
||
torn_cum, re_cum = tend.get("rx_torn"), tend.get("pdo_reentry")
|
||
lam = percentile(gseq_rate, 0.5) or 0 # u32 gseq 口径,主用
|
||
lam_u16 = percentile(rx_rate, 0.5) or 0 # u16 rx_count 口径,仅作交叉核对
|
||
dropr = percentile(drop_rate, 0.5) or 0
|
||
hz_meas = percentile(cyc_hz, 0.5) or 0
|
||
eff_ms = float(stf.get("period_ms") or ms)
|
||
cad_hist = cad.get("count_hist") or {}
|
||
mode_cnt = max(cad_hist, key=lambda k: cad_hist[k]) if cad_hist else None
|
||
rec = {
|
||
"period_ms": ms, "set_period_ms": eff_ms, "hijacked": hijacked,
|
||
"native_pid": pid0, "pid_stable": "yes" if pid0 == pid1 else "NO",
|
||
"measured_hz": round(hz_meas, 1),
|
||
"achieve_pct": round(hz_meas / (1000.0 / eff_ms) * 100, 1) if eff_ms else None,
|
||
"can_lambda_per_s": round(lam, 1), "lambda_u16_crosscheck": round(lam_u16, 1),
|
||
"drop_per_s": round(dropr, 1),
|
||
"drop_pct_of_lambda": round(dropr / lam * 100, 2) if lam else None,
|
||
"drop_untrusted": untrusted,
|
||
"rxb_count_mode_per_cycle": mode_cnt,
|
||
"rxb_lost_max": cad.get("lost_max"), "rxb_pending_max": cad.get("pending_max"),
|
||
"motors_online": sum(1 for m in (stf.get("motors") or []) if m.get("online")),
|
||
"online_frac": round(online_hits / online_tot, 3) if online_tot else None,
|
||
"motor_refresh_per_s": round(percentile(refresh_rates, 0.5) or 0, 1),
|
||
"skip_per_s": round(((skip1 or 0) - (skip0 or 0)) / dur, 2),
|
||
"skip_delta": (skip1 or 0) - (skip0 or 0),
|
||
"torn_per_s": round(torn_sum / dur, 3),
|
||
"torn_cum": torn_cum, "torn_capped": "yes" if torn_cap else "no",
|
||
"reentry_per_s": round(reentry_sum / dur, 3),
|
||
"reentry_cum": re_cum, "reentry_capped": "yes" if reentry_cap else "no",
|
||
"wkc": stf.get("wkc"), "expected_wkc": stf.get("expected_wkc"),
|
||
"wr": f"{wr0}->{stf.get('wr')}",
|
||
"cyc_p50_us": round(percentile(cyc_p50, 0.5) or 0, 1),
|
||
"cyc_p90_us": round(percentile(cyc_p90, 0.9) or 0, 1),
|
||
"cyc_max_us": round(max(cyc_max), 1) if cyc_max else None,
|
||
"cyc_duty_pct": round((percentile(cyc_p50, 0.5) or 0) / (eff_ms * 1000) * 100, 1),
|
||
"gd32_load_pct": round(percentile(loads, 0.5) or 0, 1) if loads else None,
|
||
"gd32_load_samples": len(loads), "gd32_load_stale": load_stale,
|
||
"host_cpu_1core_pct": round(percentile(hc1, 0.5) or 0, 1),
|
||
"host_cpu_total_pct": round(percentile(hct, 0.5) or 0, 2),
|
||
"logical_cpus": os.cpu_count(), "samples": iters, "errors": err,
|
||
"dwell_s": round(dur, 1), "note": "; ".join(note)}
|
||
return rec
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="ECAT 周期-故障率-CPU 扫描")
|
||
ap.add_argument("--url", default="http://127.0.0.1:8090")
|
||
ap.add_argument("--periods", default=DEFAULT_PERIODS, help="逗号分隔周期(ms)")
|
||
ap.add_argument("--dwell", type=float, default=60.0, help="每档采样秒数")
|
||
ap.add_argument("--hz", type=float, default=10.0,
|
||
help="HTTP 采样频率;u16 drop 在 4000/s 下 16s 绕一圈,须够高")
|
||
ap.add_argument("--restore", type=float, default=None, help="测完恢复到的周期(ms)")
|
||
ap.add_argument("--substr", default="I210")
|
||
ap.add_argument("--no-autostart", action="store_true")
|
||
ap.add_argument("--out", default=os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lab", "ecat_rate_sweep.csv"))
|
||
ap.add_argument("--dry-run", action="store_true")
|
||
a = ap.parse_args()
|
||
|
||
st = post(a.url, "/api/motor/state")
|
||
if not (st.get("ok") and st.get("cycles")) and a.no_autostart:
|
||
print("循环没跑且 --no-autostart:", st.get("log"))
|
||
return 2
|
||
orig = st.get("period_ms")
|
||
pid0, _ = find_native_pid(a.url)
|
||
periods = [float(x) for x in a.periods.split(",")]
|
||
print(f"服务 {a.url} · native PID {pid0} · 原周期 {orig}ms · 档位 {periods} · "
|
||
f"每档 {a.dwell}s @ {a.hz:g}Hz")
|
||
fw = firmware_identity()
|
||
print(f"固件台账 {str(fw.get('ledger'))[:16]}… 匹配={fw.get('match') or fw.get('error') or '无'}")
|
||
print("提示:确认没有任何电机页标签开着,否则周期会被 localStorage 拽回去\n")
|
||
|
||
rows, interrupted = [], False
|
||
signal.signal(signal.SIGINT, lambda *_: None)
|
||
try:
|
||
for ms in periods:
|
||
try:
|
||
rec = sample_period(a.url, ms, a.dwell, a.hz, a.substr,
|
||
not a.no_autostart, a.dry_run)
|
||
except Exception as e:
|
||
print(f"{ms}ms 档异常: {type(e).__name__}: {e}")
|
||
break
|
||
rows.append(rec)
|
||
if rec.get("note") == "dry-run 未下发":
|
||
print(f"[dry-run] {ms}ms 将下发,未执行")
|
||
continue
|
||
print(f"{ms:>7}ms 生效={rec['set_period_ms']} 劫持={rec['hijacked']} "
|
||
f"{rec['measured_hz']}Hz/{rec['achieve_pct']}% λ={rec['can_lambda_per_s']} "
|
||
f"drop={rec['drop_per_s']}/s({rec['drop_pct_of_lambda']}%) "
|
||
f"skip={rec['skip_per_s']}/s GD32={rec['gd32_load_pct']}% "
|
||
f"host={rec['host_cpu_1core_pct']}% wkc={rec['wkc']}/{rec['expected_wkc']}")
|
||
if rec["note"]:
|
||
print(f" note: {rec['note']}")
|
||
if rec["pid_stable"] == "NO":
|
||
print(" !! native PID 变了,该档及之后数据作废,停止扫描")
|
||
break
|
||
if rec["wkc"] != rec["expected_wkc"]:
|
||
print(" !! wkc 不达预期(从站丢帧/掉 OP 前兆),停止继续加压")
|
||
break
|
||
except KeyboardInterrupt:
|
||
interrupted = True
|
||
print("\n收到 Ctrl-C,正在恢复原周期…")
|
||
finally:
|
||
restore = a.restore if a.restore is not None else orig
|
||
if restore is not None and not a.dry_run:
|
||
try:
|
||
post(a.url, "/api/motor/period", {"period_ms": float(restore)})
|
||
back = (post(a.url, "/api/motor/state")).get("period_ms")
|
||
print(f"\n已恢复周期 -> 设定 {restore}ms,实际回读 {back}ms")
|
||
except Exception as e:
|
||
print(f"\n!! 恢复周期失败({e}),请手动确认总线周期")
|
||
|
||
if not rows or a.dry_run:
|
||
return 0
|
||
os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
|
||
with open(a.out, "w", newline="", encoding="utf-8") as f:
|
||
w = csv.DictWriter(f, fieldnames=COLS, extrasaction="ignore")
|
||
w.writeheader()
|
||
w.writerows(rows)
|
||
print(f"扫描数据 {len(rows)} 档 -> {a.out}")
|
||
if interrupted:
|
||
print("(本次为中断后的部分数据)")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|