chore(hygiene): P3 报告归档 docs/reports + scripts 收敛 + lab 清理 + CSS/README 约定
正式 scripts 仅保留 README 自测清单;根目录报告迁入 docs/reports; lab 采样 dump 出库;CSS 注明 btn/toast 所有权并 bump ?v=。
This commit is contained in:
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""模拟页面 pollStatus:5s 超时算失败,连续失败即离线。"""
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
URL = "http://192.168.31.127/system/status"
|
||||
TIMEOUT = 5.0
|
||||
POLL = 1.0
|
||||
WINDOW = 75
|
||||
|
||||
|
||||
def probe():
|
||||
t0 = time.time()
|
||||
try:
|
||||
with urllib.request.urlopen(URL, timeout=TIMEOUT) as r:
|
||||
r.read(64)
|
||||
return True, "HTTP %s" % r.status, time.time() - t0
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, "HTTP %s" % e.code, time.time() - t0
|
||||
except Exception as e:
|
||||
return False, "%s: %s" % (type(e).__name__, str(e)[:80]), time.time() - t0
|
||||
|
||||
|
||||
def main():
|
||||
print("=== monitor start (mimic pollStatus) ===", flush=True)
|
||||
prev = None
|
||||
fails = 0
|
||||
t_end = time.time() + WINDOW
|
||||
while time.time() < t_end:
|
||||
ok, msg, dt = probe()
|
||||
now = time.strftime("%H:%M:%S")
|
||||
if ok:
|
||||
fails = 0
|
||||
state = "ONLINE"
|
||||
else:
|
||||
fails += 1
|
||||
state = "FAIL#%d" % fails + (" -> OFFLINE" if fails >= 1 else "")
|
||||
mark = " *" if state != prev else ""
|
||||
print("[%s] %s (%s, %.0fms)%s" % (now, state, msg, dt * 1000, mark), flush=True)
|
||||
if state != prev:
|
||||
prev = state
|
||||
time.sleep(POLL)
|
||||
print("=== monitor end ===", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""事后分析 rec_ecat_slots.py 落盘的 ECAT 原始流:相邻数据大跳变检测。
|
||||
|
||||
思路(按用户指定):不看协议判据,只看数值形态。同一台电机的 STATUS 是连续量,
|
||||
相邻两帧(按 seq 排序)之间出现"跳出去又立刻跳回来"的孤立尖峰,就是这一帧的字节
|
||||
被拼坏了 —— 真突变会保持住(下一帧仍在新值附近),不会一帧就回头。
|
||||
|
||||
指标:每个 (node,字段) 先取全体相邻差的稳健尺度 scale = median(|Δ|),
|
||||
大跳变 : |v[i]-v[i-1]| > K*scale
|
||||
孤立尖峰 : 大跳变 且 |v[i+1]-v[i-1]| <= 2*K*scale ← 撕裂签名
|
||||
跳变保持 : 大跳变 但下一帧不回头的(真实突变/新参考值),只计数不算异常
|
||||
|
||||
--selftest 会把"前 j 字节取新帧、其余取上一帧"的混合帧注入实测数据,
|
||||
逐 j 报告检出率:证明这套判据对撕裂本身有灵敏度(否则 0 报警没有意义)。
|
||||
|
||||
用法:
|
||||
python scripts/anal_ecat_jumps.py ecat_slots_raw.csv [--k 8]
|
||||
python scripts/anal_ecat_jumps.py ecat_slots_raw.csv --selftest
|
||||
"""
|
||||
import csv
|
||||
import statistics
|
||||
import struct
|
||||
import sys
|
||||
|
||||
# (名称, 偏移, 解码器)
|
||||
FIELDS = [
|
||||
("mcuTemp", 0, lambda b: struct.unpack_from("<h", b, 0)[0] * 0.01),
|
||||
("motTemp", 2, lambda b: struct.unpack_from("<h", b, 2)[0] * 0.01),
|
||||
("uCur", 4, lambda b: struct.unpack_from("<h", b, 4)[0]),
|
||||
("vCur", 6, lambda b: struct.unpack_from("<h", b, 6)[0]),
|
||||
("wCur", 8, lambda b: struct.unpack_from("<h", b, 8)[0]),
|
||||
("flags", 10, lambda b: b[10]),
|
||||
("pwr_V", 11, lambda b: struct.unpack_from("<H", b, 11)[0] * 0.001),
|
||||
("rtAngle", 13, lambda b: struct.unpack_from("<f", b, 13)[0]),
|
||||
("rtVel", 17, lambda b: struct.unpack_from("<f", b, 17)[0]),
|
||||
("rtCur", 21, lambda b: struct.unpack_from("<f", b, 21)[0]),
|
||||
("testF", 25, lambda b: struct.unpack_from("<f", b, 25)[0]),
|
||||
("testU16", 29, lambda b: struct.unpack_from("<H", b, 29)[0]),
|
||||
("cpu%", 31, lambda b: b[31]),
|
||||
("initSt", 32, lambda b: struct.unpack_from("<I", b, 32)[0]),
|
||||
]
|
||||
|
||||
|
||||
def load(path):
|
||||
"""返回 {node: [(seq, 36B bytes), ...]} 按 seq 升序。"""
|
||||
per = {}
|
||||
with open(path, "r", encoding="ascii", errors="ignore") as f:
|
||||
for row in csv.reader(f):
|
||||
if not row or row[0] != "M":
|
||||
continue
|
||||
try:
|
||||
node, ch, seq = int(row[2]), int(row[3]), int(row[4])
|
||||
payload = bytes.fromhex(row[7])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if node <= 0 or len(payload) < 36:
|
||||
continue
|
||||
per.setdefault(node, []).append((seq, payload))
|
||||
for v in per.values():
|
||||
v.sort(key=lambda x: x[0])
|
||||
return per
|
||||
|
||||
|
||||
def decode(payload):
|
||||
out = {}
|
||||
for name, off, fn in FIELDS:
|
||||
try:
|
||||
out[name] = float(fn(payload))
|
||||
except Exception:
|
||||
out[name] = None
|
||||
return out
|
||||
|
||||
|
||||
def analyze(per, k=8.0, verbose=True):
|
||||
"""逐 (node,字段) 找孤立尖峰。返回总尖峰数与明细列表。"""
|
||||
spikes, holds, details = 0, 0, []
|
||||
tails = []
|
||||
for node, rows in sorted(per.items()):
|
||||
if len(rows) < 40:
|
||||
continue
|
||||
cols = [decode(p) for _, p in rows]
|
||||
for name, _off, _fn in FIELDS:
|
||||
v = [c[name] for c in cols]
|
||||
finite = [x for x in v if x is not None and x == x and abs(x) != float("inf")]
|
||||
if len(finite) < 40:
|
||||
continue
|
||||
deltas = [abs(v[i] - v[i - 1]) for i in range(1, len(v))
|
||||
if v[i] is not None and v[i - 1] is not None
|
||||
and v[i] == v[i] and v[i - 1] == v[i - 1]]
|
||||
if not deltas:
|
||||
continue
|
||||
# 尺度取 |Δ| 的 p99 分位数(不是 median):量化字段(mcuTemp 这类)
|
||||
# 的 median(|Δ|)=0,用 median 会让阈值塌到 eps,正常的 1LSB 抖动
|
||||
# 全被判成尖峰。p99 代表"全体相邻差的尾部",阈值 = K×p99 才是
|
||||
# "远超正常抖动尾巴"的意思。
|
||||
ordered = sorted(deltas)
|
||||
scale = max(ordered[min(len(ordered) - 1, int(len(ordered) * 0.99))], 1e-9)
|
||||
mx, mxctx = 0.0, None
|
||||
for i in range(1, len(v) - 1):
|
||||
a, b, c = v[i - 1], v[i], v[i + 1]
|
||||
if None in (a, b, c) or any(x != x for x in (a, b, c)):
|
||||
continue
|
||||
d = abs(b - a)
|
||||
if d > mx:
|
||||
mx, mxctx = d, (a, b, c)
|
||||
if d <= k * scale:
|
||||
continue
|
||||
back = abs(c - a) <= 2 * k * scale
|
||||
if back:
|
||||
spikes += 1
|
||||
if len(details) < 25:
|
||||
details.append((node, name, rows[i][0], scale, a, b, c,
|
||||
rows[i][1].hex()))
|
||||
else:
|
||||
holds += 1
|
||||
if mxctx and mx > 3 * scale and len(tails) < 60:
|
||||
tails.append((node, name, scale, mx, mxctx))
|
||||
if verbose:
|
||||
print(f"孤立尖峰(撕裂签名) = {spikes} 跳变保持(真实突变) = {holds}")
|
||||
for node, name, seq, scale, a, b, c, hexs in details[:12]:
|
||||
print(f" node{node} {name}: seq={seq} 尺度={scale:.4g} "
|
||||
f"{a:.6g} -> {b:.6g} -> {c:.6g}")
|
||||
print(f" payload={hexs}")
|
||||
if tails:
|
||||
tails.sort(key=lambda x: -(x[3] / max(x[2], 1e-12)))
|
||||
print("各字段最大相邻差(倍数 = 最大|Δ| / p99 尺度):")
|
||||
for node, name, scale, mx, (a, b, c) in tails[:14]:
|
||||
print(f" node{node} {name:9s} p99尺度={scale:.4g} 最大|Δ|={mx:.4g} "
|
||||
f"({mx/max(scale,1e-12):.0f}×) {a:.6g} -> {b:.6g} -> {c:.6g}")
|
||||
return spikes, holds, details
|
||||
|
||||
|
||||
def selftest(per, k=8.0, n_inject=40):
|
||||
"""注入混合帧,测各边界 j 的检出率(证明判据有灵敏度)。"""
|
||||
import random
|
||||
random.seed(7)
|
||||
base_sp, _bh, _bd = analyze(per, k=k, verbose=False)
|
||||
nodes = [n for n, r in per.items() if len(r) >= 60]
|
||||
if not nodes:
|
||||
print("自检跳过:数据太短(每节点需 >=60 帧)")
|
||||
return
|
||||
print(f"自检:基线孤立尖峰 = {base_sp};向实测流注入『新帧前缀 j 字节 + 上一帧后缀』"
|
||||
f"混合帧,每节点各 {n_inject} 次")
|
||||
print(" j(字节) 净增尖峰/注入总数 检出率")
|
||||
for j in (1, 4, 8, 12, 13, 16, 17, 20, 21, 24, 25, 28, 31, 32, 35):
|
||||
rnd = random.Random(j)
|
||||
merged = {}
|
||||
for node in nodes:
|
||||
rows = per[node]
|
||||
idxs = rnd.sample(range(2, len(rows) - 2), min(n_inject, len(rows) - 6))
|
||||
new = list(rows)
|
||||
for i in idxs:
|
||||
cur, prev = rows[i][1], rows[i - 1][1]
|
||||
new[i] = (rows[i][0], cur[:j] + prev[j:36])
|
||||
merged[node] = new
|
||||
sp, _h, det = analyze(merged, k=k, verbose=False)
|
||||
inj_n = n_inject * len(nodes)
|
||||
net = sp - base_sp
|
||||
worst = max((abs(d[5] - d[4]) for d in det), default=0.0)
|
||||
print(f" j={j:<4d} {net:5d}/{inj_n:<5d} {100.0*net/inj_n:5.1f}% "
|
||||
f"最大偏离≈{worst:.4g}")
|
||||
print("(j 越大越靠尾部字节,混合后数值偏离越小、越难检出 —— 这是形态判据的物理上限)")
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
if not args:
|
||||
print(__doc__)
|
||||
return
|
||||
path = args[0]
|
||||
k = 8.0
|
||||
if "--k" in sys.argv:
|
||||
k = float(sys.argv[sys.argv.index("--k") + 1])
|
||||
per = load(path)
|
||||
tot = sum(len(v) for v in per.values())
|
||||
print(f"读入 {path}: {tot} 帧,节点 {sorted(per)}")
|
||||
for node, rows in sorted(per.items()):
|
||||
print(f" node{node}: {len(rows)} 个不同 seq,"
|
||||
f"seq 范围 {rows[0][0]}..{rows[-1][0]}")
|
||||
if "--selftest" in sys.argv:
|
||||
selftest(per, k=k)
|
||||
return
|
||||
analyze(per, k=k)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,677 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把扫描数据渲染成自包含 HTML 调研报告(图表为内联 SVG)。
|
||||
|
||||
为什么手写 SVG 而不用 matplotlib:产出的 HTML 要**单文件自包含**,不引外部
|
||||
JS/CSS/图片依赖,也不能要求阅读者装 matplotlib;SVG 直接内嵌,浏览器打开即看,
|
||||
且数值标注可以精确控制位置(matplotlib 的 annotate 在这类密集小图上很容易压线)。
|
||||
|
||||
纪律同 plot_ecat_sweep.py:
|
||||
* 图里画的**只有实测点**。模型/参考一律用虚线并写明"外推",不与实测混同。
|
||||
* 无功效的轴(整段恒零)不画成"平坦的零线",改成功效声明块。
|
||||
* 从站停报的档位(wkc 不达预期 / load_perm 为残值)一律排除出图,并在页面上
|
||||
显式列出被排除的档位与原因 —— 空图比假图诚实。
|
||||
|
||||
用法:
|
||||
python scripts/build_ecat_report_html.py
|
||||
python scripts/build_ecat_report_html.py --out ECAT_RATE_TRADEOFF.html
|
||||
"""
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.dirname(HERE)
|
||||
sys.path.insert(0, HERE)
|
||||
from ecat_sweep_common import post # noqa: E402 (仅用于探测 native 是否在跑)
|
||||
|
||||
import csv as _csv
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────── 数据读取 ─────────────────────────────────────────────────────────
|
||||
def read_csv(path):
|
||||
if not os.path.isfile(path):
|
||||
return []
|
||||
rows = []
|
||||
for r in _csv.DictReader(open(path, encoding="utf-8")):
|
||||
def num(k):
|
||||
v = r.get(k, "")
|
||||
try:
|
||||
return float(v) if v not in ("", "None", None) else None
|
||||
except ValueError:
|
||||
return None
|
||||
rows.append({
|
||||
"period_ms": float(r["period_ms"]),
|
||||
"hz_set": 1000.0 / float(r["period_ms"]),
|
||||
"hz_meas": num("measured_hz"),
|
||||
"achieve": num("achieve_pct"),
|
||||
"load": num("gd32_load_pct"),
|
||||
"load_samples": num("gd32_load_samples"),
|
||||
"load_stale": num("gd32_load_stale"),
|
||||
"host": num("host_cpu_1core_pct"),
|
||||
"lambda_": num("can_lambda_per_s"),
|
||||
"drop": num("drop_per_s"),
|
||||
"drop_pct": num("drop_pct_of_lambda"),
|
||||
"skip": num("skip_per_s"),
|
||||
"wkc": r.get("wkc"), "exp_wkc": r.get("expected_wkc"),
|
||||
"wr": r.get("wr"), "hijacked": r.get("hijacked"),
|
||||
"pid_stable": r.get("pid_stable"),
|
||||
"online_frac": num("online_frac"),
|
||||
"untrusted": num("drop_untrusted"),
|
||||
"path": path})
|
||||
rows[-1]["hz"] = rows[-1]["hz_meas"] or rows[-1]["hz_set"]
|
||||
rows[-1]["wkc_ok"] = rows[-1]["wkc"] == rows[-1]["exp_wkc"]
|
||||
rows.sort(key=lambda r: r["hz"])
|
||||
return rows
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────── SVG 图元 ─────────────────────────────────────────────────────────
|
||||
def _ticks(lo, hi, n=5):
|
||||
"""线性刻度:给一组整齐步进的刻度值。"""
|
||||
if hi <= lo:
|
||||
return [lo]
|
||||
raw = (hi - lo) / max(1, n - 1)
|
||||
mag = 10 ** math.floor(math.log10(raw)) if raw else 1
|
||||
step = mag * next((k for k in (1, 2, 2.5, 5, 10) if raw / mag <= k), 10)
|
||||
v = math.ceil(lo / step) * step
|
||||
out = []
|
||||
while v <= hi + 1e-9:
|
||||
out.append(round(v, 6))
|
||||
v += step
|
||||
return out
|
||||
|
||||
|
||||
def _log_ticks(lo, hi):
|
||||
out = []
|
||||
for e in range(int(math.floor(math.log10(lo))), int(math.ceil(math.log10(hi))) + 1):
|
||||
for m in (1, 2, 5):
|
||||
v = m * (10 ** e)
|
||||
if lo - 1e-9 <= v <= hi + 1e-9:
|
||||
out.append(v)
|
||||
return out or [lo]
|
||||
|
||||
|
||||
def _lab(v):
|
||||
if v is None:
|
||||
return "n/a"
|
||||
a = abs(v)
|
||||
if a >= 1000:
|
||||
return f"{v:,.0f}"
|
||||
if a >= 100:
|
||||
return f"{v:.0f}"
|
||||
if a >= 10:
|
||||
return f"{v:.1f}"
|
||||
if a >= 1:
|
||||
return f"{v:.1f}" if v != int(v) else str(int(v))
|
||||
return f"{v:.2f}" if a else "0"
|
||||
|
||||
|
||||
class Svg:
|
||||
"""极简 SVG 累加器。"""
|
||||
|
||||
def __init__(self, w, h):
|
||||
self.w, self.h, self.b = w, h, []
|
||||
self.L, self.R = 64, w - 16
|
||||
self.T, self.B = 26, h - 42
|
||||
|
||||
def scale(self, xlog=False, ylog=False, xr=None, yr=None):
|
||||
self.xlog, self.ylog = xlog, ylog
|
||||
self.xr, self.yr = xr, yr
|
||||
|
||||
def X(self, v):
|
||||
if self.xlog:
|
||||
lo, hi = self.xr
|
||||
return self.L + (math.log10(max(v, 1e-9)) - math.log10(lo)) / \
|
||||
(math.log10(hi) - math.log10(lo)) * (self.R - self.L)
|
||||
lo, hi = self.xr
|
||||
return self.L + (v - lo) / (hi - lo or 1) * (self.R - self.L)
|
||||
|
||||
def Y(self, v):
|
||||
if self.ylog:
|
||||
lo, hi = self.yr
|
||||
return self.B - (math.log10(max(v, 1e-9)) - math.log10(lo)) / \
|
||||
(math.log10(hi) - math.log10(lo)) * (self.B - self.T)
|
||||
lo, hi = self.yr
|
||||
return self.B - (v - lo) / (hi - lo or 1) * (self.B - self.T)
|
||||
|
||||
def grid(self, xt, yt, xf=_lab, yf=_lab):
|
||||
for v in yt:
|
||||
try:
|
||||
y = self.Y(v)
|
||||
except ValueError:
|
||||
continue
|
||||
if not (self.T - 1 <= y <= self.B + 1):
|
||||
continue
|
||||
self.b.append(f'<line x1="{self.L}" y1="{y:.1f}" x2="{self.R}" y2="{y:.1f}" '
|
||||
f'stroke="#e8ebf0" stroke-width="1"/>')
|
||||
self.b.append(f'<text x="{self.L-8}" y="{y+3.5:.1f}" text-anchor="end" '
|
||||
f'font-size="10.5" fill="#6b7280">{html.escape(yf(v))}</text>')
|
||||
for v in xt:
|
||||
x = self.X(v)
|
||||
if not (self.L - 1 <= x <= self.R + 1):
|
||||
continue
|
||||
self.b.append(f'<line x1="{x:.1f}" y1="{self.T}" x2="{x:.1f}" y2="{self.B}" '
|
||||
f'stroke="#eef1f5" stroke-width="1"/>')
|
||||
self.b.append(f'<text x="{x:.1f}" y="{self.B+16}" text-anchor="middle" '
|
||||
f'font-size="10.5" fill="#6b7280">{html.escape(xf(v))}</text>')
|
||||
|
||||
def axes(self, xl, yl):
|
||||
self.b.append(f'<line x1="{self.L}" y1="{self.B}" x2="{self.R}" y2="{self.B}" '
|
||||
f'stroke="#9aa3af" stroke-width="1.2"/>')
|
||||
self.b.append(f'<line x1="{self.L}" y1="{self.T}" x2="{self.L}" y2="{self.B}" '
|
||||
f'stroke="#9aa3af" stroke-width="1.2"/>')
|
||||
self.b.append(f'<text x="{(self.L+self.R)/2:.0f}" y="{self.h-6}" text-anchor="middle" '
|
||||
f'font-size="11.5" fill="#374151">{html.escape(xl)}</text>')
|
||||
self.b.append(f'<text transform="rotate(-90 14 {(self.T+self.B)/2:.0f})" x="14" '
|
||||
f'y="{(self.T+self.B)/2:.0f}" text-anchor="middle" font-size="11.5" '
|
||||
f'fill="#374151">{html.escape(yl)}</text>')
|
||||
|
||||
def line(self, pts, color, dash=None, wid=2.4, marker="circle", r=4.2,
|
||||
label=None, lab_dxy=(9, -9)):
|
||||
live = [(x, y) for x, y in pts if x is not None and y is not None]
|
||||
if not live:
|
||||
return
|
||||
if len(live) > 1:
|
||||
d = " ".join(f"{'M' if i == 0 else 'L'}{self.X(x):.1f},{self.Y(y):.1f}"
|
||||
for i, (x, y) in enumerate(live))
|
||||
da = f' stroke-dasharray="{dash}"' if dash else ""
|
||||
self.b.append(f'<path d="{d}" fill="none" stroke="{color}" '
|
||||
f'stroke-width="{wid}"{da} stroke-linejoin="round"/>')
|
||||
for i, (x, y) in enumerate(live):
|
||||
cx, cy = self.X(x), self.Y(y)
|
||||
if marker == "circle":
|
||||
self.b.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r}" fill="{color}" '
|
||||
f'stroke="#fff" stroke-width="1.4"/>')
|
||||
elif marker == "square":
|
||||
self.b.append(f'<rect x="{cx-r:.1f}" y="{cy-r:.1f}" width="{2*r:.1f}" '
|
||||
f'height="{2*r:.1f}" fill="{color}" stroke="#fff" '
|
||||
f'stroke-width="1.4"/>')
|
||||
elif marker == "tri":
|
||||
p = f"{cx:.1f},{cy-r-1:.1f} {cx-r-1:.1f},{cy+r:.1f} {cx+r+1:.1f},{cy+r:.1f}"
|
||||
self.b.append(f'<polygon points="{p}" fill="{color}" stroke="#fff" '
|
||||
f'stroke-width="1.4"/>')
|
||||
if label and i == len(live) - 1:
|
||||
dx, dy = lab_dxy
|
||||
self.b.append(f'<text x="{cx+dx:.1f}" y="{cy+dy:.1f}" font-size="10.5" '
|
||||
f'fill="{color}" font-weight="600">{html.escape(label)}</text>')
|
||||
|
||||
def vtext(self, x, y, txt, color="#374151", anchor="middle", size=10.5, weight="400"):
|
||||
self.b.append(f'<text x="{self.X(x):.1f}" y="{self.Y(y):.1f}" text-anchor="{anchor}" '
|
||||
f'font-size="{size}" font-weight="{weight}" '
|
||||
f'fill="{color}">{html.escape(txt)}</text>')
|
||||
|
||||
def legend(self, entries, x=None, y=None):
|
||||
x = self.L + 10 if x is None else x
|
||||
y = self.T + 12 if y is None else y
|
||||
for i, (c, t, dash) in enumerate(entries):
|
||||
yy = y + i * 17
|
||||
da = f' stroke-dasharray="{dash}"' if dash else ""
|
||||
self.b.append(f'<line x1="{x}" y1="{yy}" x2="{x+22}" y2="{yy}" stroke="{c}" '
|
||||
f'stroke-width="2.6"{da}/>')
|
||||
self.b.append(f'<circle cx="{x+11}" cy="{yy}" r="3.2" fill="{c}"/>')
|
||||
self.b.append(f'<text x="{x+29}" y="{yy+3.6}" font-size="11" '
|
||||
f'fill="#374151">{html.escape(t)}</text>')
|
||||
|
||||
def hline(self, v, color="#9ca3af", dash="3,3", wid=1.2):
|
||||
y = self.Y(v)
|
||||
if not (self.T - 1 <= y <= self.B + 1):
|
||||
return
|
||||
self.b.append(f'<line x1="{self.L}" y1="{y:.1f}" x2="{self.R}" y2="{y:.1f}" '
|
||||
f'stroke="{color}" stroke-width="{wid}" stroke-dasharray="{dash}"/>')
|
||||
|
||||
def band(self, x0, x1, color="#ef4444", alpha=0.07):
|
||||
a, b = self.X(x0), self.X(x1)
|
||||
self.b.append(f'<rect x="{min(a,b):.1f}" y="{self.T}" width="{abs(b-a):.1f}" '
|
||||
f'height="{self.B-self.T}" fill="{color}" opacity="{alpha}"/>')
|
||||
|
||||
def note(self, txt, x=None, y=None, color="#6b7280", anchor="start", size=10.5):
|
||||
x = self.L + 6 if x is None else x
|
||||
y = self.B - 8 if y is None else y
|
||||
self.b.append(f'<text x="{x}" y="{y}" text-anchor="{anchor}" font-size="{size}" '
|
||||
f'fill="{color}">{html.escape(txt)}</text>')
|
||||
|
||||
def finish(self, title, sub=""):
|
||||
head = ""
|
||||
if sub:
|
||||
head = f'<text x="{self.L-40}" y="15" font-size="10.5" fill="#6b7280">' \
|
||||
f'{html.escape(sub)}</text>'
|
||||
return (f'<svg viewBox="0 0 {self.w} {self.h}" width="100%" '
|
||||
f'xmlns="http://www.w3.org/2000/svg" role="img" aria-label="{html.escape(title)}">'
|
||||
f'{head}{"".join(self.b)}</svg>')
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────── 具体图 ─────────────────────────────────────────────────────────
|
||||
C_RED, C_BLUE, C_GREEN, C_PINK, C_ORANGE, C_GREY, C_PURPLE = (
|
||||
"#dc2626", "#2563eb", "#16a34a", "#db2777", "#ea580c", "#6b7280", "#7c3aed")
|
||||
|
||||
|
||||
def chart_achieve(rows, rows_no=()):
|
||||
s = Svg(560, 320)
|
||||
allr = list(rows) + list(rows_no)
|
||||
lo = min(r["hz_set"] for r in allr) * 0.7
|
||||
hi = max(r["hz_set"] for r in allr) * 1.5
|
||||
s.scale(True, True, (lo, hi), (lo, hi))
|
||||
s.grid(_log_ticks(lo, hi), _log_ticks(lo, hi))
|
||||
s.line([(lo, lo), (hi, hi)], C_GREY, dash="5,4", label=None)
|
||||
s.line([(r["hz_set"], r["hz_meas"]) for r in rows], C_RED, marker="circle")
|
||||
s.line([(r["hz_set"], r["hz_meas"]) for r in rows_no], C_BLUE, marker="square")
|
||||
for r in allr: # 只标偏离点,免得满图都是 99.x%
|
||||
if (r["achieve"] or 100) < 99.5:
|
||||
s.vtext(r["hz_set"], r["hz_meas"] * 0.72,
|
||||
_lab(r["achieve"]) + "%", color="#1e40af", size=9.5, anchor="middle")
|
||||
s.legend([(C_GREY, "理想 1:1(设定率)", "5,4"),
|
||||
(C_RED, "实测循环率 · 带 CAN", None),
|
||||
(C_BLUE, "实测循环率 · 无 CAN", None)], x=s.L + 12, y=s.T + 46)
|
||||
s.note("标注为达成率%(<99.5% 才标)。无 CAN 设定 >8kHz 后实测全顶在 ≈8.2kHz —— 硬上限",
|
||||
y=s.B - 8)
|
||||
s.axes("设定循环率 (Hz,对数)", "实测循环率 (Hz,对数)")
|
||||
return s.finish("循环率:设定 vs 实测")
|
||||
|
||||
|
||||
def chart_load(rows_no, rows_wc):
|
||||
s = Svg(620, 350)
|
||||
allr = rows_no + rows_wc
|
||||
lo = min(r["hz"] for r in allr) * 0.75
|
||||
hi = max(r["hz"] for r in allr) * 1.35
|
||||
s.scale(True, False, (lo, hi), (0, 112))
|
||||
s.grid(_log_ticks(lo, hi), _ticks(0, 110, 6))
|
||||
s.hline(100, color="#6b7280", dash="2,4")
|
||||
s.b.append(f'<text x="{s.R-4}" y="{s.Y(100)-5:.1f}" text-anchor="end" font-size="10" '
|
||||
f'fill="#6b7280">100%</text>')
|
||||
pts_wc = [(r["hz"], r["load"]) for r in rows_wc if r["load"] is not None]
|
||||
pts_no = [(r["hz"], r["load"]) for r in rows_no if r["load"] is not None]
|
||||
s.line(pts_wc, C_RED, marker="circle")
|
||||
s.line(pts_no, C_BLUE, marker="square")
|
||||
wc_by_p = {round(r["period_ms"], 4): r for r in rows_wc}
|
||||
for r in [x for x in rows_no if x["load"] is not None]:
|
||||
w = wc_by_p.get(round(r["period_ms"], 4))
|
||||
if w and w["load"] is not None:
|
||||
# 任一侧饱和(≥95%)时 Δ 只反映夹紧,不标注
|
||||
if (r["load"] or 0) >= 95 or (w["load"] or 0) >= 95:
|
||||
continue
|
||||
s.vtext(r["hz"], (r["load"] + w["load"]) / 2, f"Δ{w['load']-r['load']:.1f}pp",
|
||||
color=C_PURPLE, size=10, weight="600")
|
||||
s.legend([(C_RED, "带 CAN(电机在报 STATUS/CONFIG)", None),
|
||||
(C_BLUE, "拔掉全部 CAN 设备", None)], x=s.L + 12, y=s.T + 12)
|
||||
s.note("纵轴是固件 load_perm(只计 ISR+非延迟段,排除 SSC 轮询)—— 不是整机 CPU 占用率",
|
||||
y=s.B - 8)
|
||||
s.note("无 CAN 补测:0.25/0.125ms 满速;设定再快实测全顶在 ≈8.2kHz。"
|
||||
"≥4kHz 的负载读数全是饱和值,不能定位拐点", y=s.B - 22, color="#b45309")
|
||||
s.note("带 CAN 的 0.25/0.125ms 两点同理停在 ~99.x% —— 饱和读数不等于\"已测到上限\"",
|
||||
y=s.B - 36, color="#b45309")
|
||||
s.axes("实测循环率 (Hz,对数)", "从站 load_perm (%)")
|
||||
return s.finish("从站每拍负担")
|
||||
|
||||
|
||||
def chart_traffic(rows):
|
||||
"""CAN 流量分层:STATUS 刷新(≈CAN 总接收)与 CONFIG 事件流。
|
||||
|
||||
drop 不再入图:它与 rx_count/gseq 同速增长(各约 4000/s),拿它做分子分母
|
||||
只会得到无意义的 100%(本次实际踩过的坑,详见第四节仪表陷阱)。
|
||||
"""
|
||||
s = Svg(620, 350)
|
||||
ok = [r for r in rows if (r["lambda_"] or 0) > 0]
|
||||
if not ok:
|
||||
s.note("无可用的 CAN 流量档位(本轮 CAN 已拔除)", y=(s.T + s.B) / 2, anchor="middle")
|
||||
return s.finish("CAN 流量分层", "(无数据)")
|
||||
lo = min(r["hz"] for r in ok) * 0.75
|
||||
hi = max(r["hz"] for r in ok) * 1.35
|
||||
s.scale(True, True, (lo, hi), (10, 20000))
|
||||
s.grid(_log_ticks(lo, hi), _log_ticks(10, 20000))
|
||||
s.line([(r["hz"], r["lambda_"]) for r in ok], C_GREEN, marker="circle")
|
||||
s.band(2500, 9000)
|
||||
s.vtext(1400, 4000 * 1.35, "带 CAN 轮:STATUS 全流量", color="#166534", size=10)
|
||||
s.vtext(4200, 150, "塌陷", color="#b91c1c", size=11, weight="700")
|
||||
s.legend([(C_GREEN, "CAN 总接收 rx_count ≡ STATUS 刷新 gseq(实测同为 ~4000/s)", None)],
|
||||
x=s.L + 12, y=s.T + 12)
|
||||
s.note("CONFIG 事件流独立实测仅约 92 帧/s(去重后),不随周期放大", y=s.B - 8)
|
||||
s.note(">2kHz 处 STATUS 与 CONFIG 一同塌到 ~150/s", y=s.B - 22, color="#b45309")
|
||||
s.axes("实测循环率 (Hz,对数)", "帧/s(对数)")
|
||||
return s.finish("CAN 流量分层")
|
||||
|
||||
|
||||
def chart_skip(rows_no, rows_wc):
|
||||
"""skip 有真 0 档 → 对数轴画不了。做法:>0 的点画在对数区,真 0/近 0 档
|
||||
落到轴下方的"≈0"标记轨道,两种点含义不同、不混在同一条线上。"""
|
||||
s = Svg(560, 320)
|
||||
pos = [r for r in rows_no + rows_wc if (r["skip"] or 0) > 0.05]
|
||||
zero = [r for r in rows_no + rows_wc if (r["skip"] or 0) <= 0.05]
|
||||
if not pos:
|
||||
s.note("本轮无 skip>0 的档位", y=(s.T + s.B) / 2, anchor="middle")
|
||||
return s.finish("主站错拍率", "(无数据)")
|
||||
lo = min(r["hz"] for r in pos) * 0.75
|
||||
hi = max(r["hz"] for r in pos) * 1.35
|
||||
vlo, vhi = max(min(r["skip"] for r in pos), 0.05), max(r["skip"] for r in pos) * 3
|
||||
s.T -= 6
|
||||
s.scale(True, True, (lo, hi), (vlo, vhi))
|
||||
s.grid(_log_ticks(lo, hi), _log_ticks(vlo, vhi))
|
||||
s.line([(r["hz"], r["skip"]) for r in rows_wc if (r["skip"] or 0) > 0.05],
|
||||
C_RED, marker="circle")
|
||||
s.line([(r["hz"], r["skip"]) for r in rows_no if (r["skip"] or 0) > 0.05],
|
||||
C_BLUE, marker="square")
|
||||
for r in zero: # ≈0 轨道:贴在轴下方,明确不属于对数刻度
|
||||
y = s.B + 4
|
||||
mk = ("circle" if r in rows_wc else "square")
|
||||
col = C_RED if r in rows_wc else C_BLUE
|
||||
cx = s.X(r["hz"])
|
||||
s.b.append(f'<path d="M{cx-4:.1f},{y+8:.1f} L{cx:.1f},{y:.1f} '
|
||||
f'L{cx+4:.1f},{y+8:.1f}" fill="none" stroke="{col}" stroke-width="2"/>')
|
||||
ent = [(C_RED, "带 CAN", None), (C_BLUE, "无 CAN", None)]
|
||||
if zero: # 没有真 0 档时不要挂一条对不上号的图例
|
||||
ent.append(("#9ca3af", "▽ = skip ≤0.05 次/s(真 0 档,不在对数刻度上)", None))
|
||||
s.legend(ent, x=s.L + 12, y=s.T + 12)
|
||||
s.note("skip=错过的网格拍。0.0625ms 设定档 15720 次/s:实测率被钉在 8.2kHz,"
|
||||
"错拍数随设定率线性涨", y=s.B - 10)
|
||||
s.axes("实测循环率 (Hz,对数)", "主站错拍 skip_total (次/s,对数)")
|
||||
return s.finish("主站错拍率")
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────── 页面组装 ─────────────────────────────────────────────────────────
|
||||
PAGE = """<!DOCTYPE html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>EtherCAT 周期速度 × 故障率 × CPU 占用 调研</title>
|
||||
<style>
|
||||
:root{{--fg:#111827;--mut:#6b7280;--bd:#e5e7eb;--bg:#f8fafc;--acc:#1d4ed8;--warn:#b45309;--bad:#b91c1c}}
|
||||
*{{box-sizing:border-box}}
|
||||
body{{margin:0;background:#fff;color:var(--fg);
|
||||
font:15.5px/1.72 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif}}
|
||||
main{{max-width:1120px;margin:0 auto;padding:40px 28px 90px}}
|
||||
h1{{font-size:29px;line-height:1.3;margin:0 0 6px;letter-spacing:-.2px}}
|
||||
h2{{font-size:20px;margin:44px 0 12px;padding-bottom:7px;border-bottom:2px solid var(--bd)}}
|
||||
h3{{font-size:16px;margin:26px 0 8px;color:#374151}}
|
||||
p,li{{margin:8px 0}}
|
||||
.meta{{color:var(--mut);font-size:13.5px;margin-bottom:26px}}
|
||||
.card{{background:var(--bg);border:1px solid var(--bd);border-left:4px solid var(--acc);
|
||||
border-radius:8px;padding:16px 20px;margin:18px 0}}
|
||||
.card.warn{{border-left-color:var(--warn);background:#fffbeb}}
|
||||
.card.bad{{border-left-color:var(--bad);background:#fef2f2}}
|
||||
.card h3{{margin:0 0 8px;font-size:15px;color:inherit}}
|
||||
.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(430px,1fr));gap:18px;margin:18px 0}}
|
||||
.fig{{border:1px solid var(--bd);border-radius:8px;padding:12px 10px 8px;background:#fff}}
|
||||
.fig > .cap{{font-size:12.5px;font-weight:600;color:#374151;margin:0 0 4px 8px}}
|
||||
.fig > .sub{{font-size:11.5px;color:var(--mut);margin:0 0 4px 8px}}
|
||||
table{{border-collapse:collapse;width:100%;font-size:13px;margin:14px 0}}
|
||||
th,td{{border:1px solid var(--bd);padding:6px 9px;text-align:right}}
|
||||
th{{background:#f1f5f9;font-weight:600;text-align:center}}
|
||||
td:first-child,th:first-child{{text-align:left}}
|
||||
tr.best td{{background:#ecfdf5;font-weight:600}}
|
||||
tr.dead td{{background:#fef2f2;color:#7f1d1d}}
|
||||
code{{background:#f1f5f9;padding:1.5px 5px;border-radius:4px;font-size:12.5px;
|
||||
font-family:ui-monospace,"Cascadia Mono",Consolas,monospace}}
|
||||
.kpi{{display:flex;gap:14px;flex-wrap:wrap;margin:22px 0 8px}}
|
||||
.kpi div{{flex:1 1 168px;border:1px solid var(--bd);border-radius:8px;padding:13px 15px;background:#fff}}
|
||||
.kpi b{{display:block;font-size:23px;line-height:1.25;color:var(--acc)}}
|
||||
.kpi span{{font-size:12px;color:var(--mut)}}
|
||||
ul{{padding-left:22px}}
|
||||
.excl{{font-size:12.5px;color:var(--bad);background:#fef2f2;border:1px dashed #fca5a5;
|
||||
border-radius:6px;padding:9px 13px;margin:12px 0}}
|
||||
.foot{{margin-top:44px;padding-top:16px;border-top:1px solid var(--bd);
|
||||
font-size:12.5px;color:var(--mut)}}
|
||||
</style></head><body><main>
|
||||
{body}
|
||||
</main></body></html>
|
||||
"""
|
||||
|
||||
|
||||
def tbl(rows, cols, cap):
|
||||
h = [f'<h3>{html.escape(cap)}</h3><table><tr>' +
|
||||
"".join(f"<th>{html.escape(t)}</th>" for t, _ in cols) + "</tr>"]
|
||||
for r in rows:
|
||||
cls = ""
|
||||
if r.get("wkc_ok") is False:
|
||||
cls = ' class="dead"'
|
||||
elif r["period_ms"] == 0.5:
|
||||
cls = ' class="best"'
|
||||
h.append(f"<tr{cls}>" + "".join(f"<td>{f(r)}</td>" for _, f in cols) + "</tr>")
|
||||
h.append("</table>")
|
||||
return "".join(h)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--nocan", default=os.path.join(ROOT, "lab", "ecat_rate_sweep_nocan.csv"))
|
||||
ap.add_argument("--withcan", default=os.path.join(ROOT, "lab", "ecat_rate_sweep_withcan.csv"))
|
||||
ap.add_argument("--calib", default=os.path.join(ROOT, "lab", "ecat_calib.json"))
|
||||
ap.add_argument("--out", default=os.path.join(ROOT, "ECAT_RATE_TRADEOFF.html"))
|
||||
a = ap.parse_args()
|
||||
|
||||
no, wc = read_csv(a.nocan), read_csv(a.withcan)
|
||||
if not wc:
|
||||
raise SystemExit(f"缺带 CAN 数据:{a.withcan}")
|
||||
cal = json.load(open(a.calib, encoding="utf-8")) if os.path.isfile(a.calib) else {}
|
||||
no_ok = [r for r in no if r["wkc_ok"] and r["load"] is not None]
|
||||
wc_ok = [r for r in wc if r["wkc_ok"] and r["load"] is not None]
|
||||
excl = [r for r in no if not r["wkc_ok"] or r["load"] is None]
|
||||
|
||||
# 电机刷新率:带 CAN 那轮的 8s/档定点实测。(周期文本, 实测Hz, 刷新率帧/s)
|
||||
# 周期直接带文本 —— 由 hz 反算会浮点漂移(1000/1999 → 显示成 0.50025ms)。
|
||||
motor = [("1", 1000.0, 4000.2), ("0.5", 1999.0, 4000.2),
|
||||
("0.25", 3989.0, 87.9), ("0.125", 7762.0, 147.5)]
|
||||
# 跨轮配对必须用**设定周期**做键:两轮的实测 hz 会差零点几(1999.3 vs 1999.6),
|
||||
# 拿四舍五入的 hz 当键会把 0.5ms 档静默丢掉,KPI 均值就跟着算错。
|
||||
wc_by_period = {round(r["period_ms"], 4): r for r in wc_ok}
|
||||
deltas, unm, sat = [], [], []
|
||||
for r in no_ok:
|
||||
w = wc_by_period.get(round(r["period_ms"], 4))
|
||||
if w:
|
||||
# 任一侧 load 已饱和(≥95%)时 Δ 只反映夹紧,不代表 CAN 负担
|
||||
if (r["load"] or 0) >= 95 or (w["load"] or 0) >= 95:
|
||||
sat.append(r["period_ms"])
|
||||
else:
|
||||
deltas.append(w["load"] - r["load"])
|
||||
else:
|
||||
unm.append(r["period_ms"])
|
||||
if unm:
|
||||
print("!! 无 CAN 档位在带 CAN 轮中找不到配对,已从 Δ 中排除:", unm)
|
||||
if sat:
|
||||
print(" 饱和档(Δ 不计入):", sat)
|
||||
|
||||
b = [f'<h1>EtherCAT 周期速度 × 故障率 × CPU 占用 调研</h1>',
|
||||
f'<div class="meta">调研日期 {html.escape(str(cal.get("started_at","2026-09-08"))[:10])}'
|
||||
f' · 本机 I210 + 1 个 GD32E508 从站 · 两轮扫描:带 CAN / 拔掉全部 CAN'
|
||||
f' · 数据 <code>lab/ecat_rate_sweep_*.csv</code> · 生成器 '
|
||||
f'<code>scripts/build_ecat_report_html.py</code></div>']
|
||||
|
||||
b.append('<div class="kpi">'
|
||||
'<div><b>0.5ms</b><span>实测可用上限(2kHz,带 CAN 工况)</span></div>'
|
||||
f'<div><b>≈{(sum(deltas)/len(deltas) if deltas else 0):.1f}pp</b>'
|
||||
f'<span>CAN 每拍恒定负担({len(deltas)} 档配对,饱和档除外)</span></div>'
|
||||
'<div><b>4000 帧/s</b><span>电机 STATUS 实测刷新(1/0.5ms)</span></div>'
|
||||
'<div><b>≈8.2kHz</b><span>纯 ECAT 实测硬上限(设 10/12.5/16kHz 实测均不涨)</span></div></div>')
|
||||
|
||||
b.append('<div class="card"><h3>结论</h3><ul>'
|
||||
'<li><b>带 CAN 工况的可用上限在 0.5ms,瓶颈在从站 GD32 不在主站。</b>'
|
||||
'实测可用到 0.5ms;0.25ms 起电机数据刷新率从 4000 帧/s 掉到 88 帧/s'
|
||||
'(带 CAN 那轮当场确认 <code>online=True</code>,是真失效)。'
|
||||
'可用上限只夹在 (0.25ms, 0.5ms] 内,<b>具体位置没测到</b>。</li>'
|
||||
'<li><b>纯 ECAT 硬上限 ≈8.2kHz,这次测到了墙本身。</b>断电重启后无 CAN 补测:'
|
||||
'0.25/0.125ms 满速通过(99.7% / 98.0%);再往下设 0.1 / 0.08 / 0.0625ms,'
|
||||
'实测循环率全部顶在 8196–8238Hz(达成 82.3% / 65.9% / 51.2%),'
|
||||
'wkc 全程 3/3、从站始终应答。墙的来历:主站单拍硬耗时 <code>cyc p50</code> '
|
||||
'各档恒 117–126µs,1/121µs ≈ 8.2kHz —— 是"每拍必须花的往返+处理时间",'
|
||||
'不是主机 CPU 不够(顶墙时主机 CPU 反而降到 ~85%)。原「3.5kHz 外推」'
|
||||
'<b>被证伪</b>;首轮无 CAN 0.25ms 停报为残状态偶发,不复现。'
|
||||
'注意"跑得快"≠"能用":带 CAN 的可用上限仍由电机数据刷新决定(下一条)。</li>'
|
||||
'<li><b>拔 CAN 对照解开了混淆</b>:未饱和三档 Δ 稳定 8.7–9.3pp,'
|
||||
'不随频率张开 → 快档塌陷不是 CAN 挤出来的;饱和两档 Δ 收窄到 ≤0.5pp,'
|
||||
'正是读数被夹紧的特征。</li>'
|
||||
'<li><b>主机 native CPU 恒约 100% 单核、与周期无关</b>(自旋恒转,'
|
||||
'<code>/api/motor/spin</code> 已是空操作)。唯一的例外是带 CAN 轮的 '
|
||||
'0.125ms 档降到 87.5% —— 因为从站已停答。无 CAN 补测里从站活着,'
|
||||
'同档就回到 99.7%。<b>主机 CPU 掉下来那一刻,正是从站死掉那一刻。</b></li>'
|
||||
'<li>建议:<b>要吃到余量就停在 0.5ms</b>。0.25ms 不是"更快",是功能损坏。</li>'
|
||||
'</ul></div>')
|
||||
|
||||
b.append('<h2>一、曲线</h2>')
|
||||
b.append('<div class="grid">')
|
||||
b.append(f'<div class="fig"><div class="cap">① 循环率:设定 vs 实测(主站侧)</div>'
|
||||
f'{chart_achieve(wc_ok, no_ok)}</div>')
|
||||
b.append(f'<div class="fig"><div class="cap">② 从站每拍负担:带 CAN vs 无 CAN</div>'
|
||||
f'{chart_load(no_ok, wc_ok)}</div>')
|
||||
b.append(f'<div class="fig"><div class="cap">③ CAN 流量分层:STATUS 刷新与 CONFIG 事件流</div>'
|
||||
f'{chart_traffic(wc_ok)}</div>')
|
||||
b.append(f'<div class="fig"><div class="cap">④ 主站错拍率</div>'
|
||||
f'{chart_skip(no_ok, wc_ok)}</div>')
|
||||
b.append('</div>')
|
||||
|
||||
if excl:
|
||||
b.append('<div class="excl">已从图中排除的档位:' + ";".join(
|
||||
f"{r['period_ms']}ms({'wkc 不达预期,从站停报' if not r['wkc_ok'] else '无有效负载读数'}"
|
||||
f",load_perm 为残值)" for r in excl) + '。该档已由断电重启后的补测覆盖:'
|
||||
'无 CAN 的 0.25 / 0.125ms 两点均已满速测得(见无 CAN 表)。空图比假图诚实。</div>')
|
||||
|
||||
b.append('<div class="card"><h3>名词解释(表里这几个计数器到底是什么)</h3><ul>'
|
||||
'<li><b>WKC(working counter,工作计数器)</b>:EtherCAT 报文自带一个计数器,'
|
||||
'每有一个从站硬件处理了该报文就 +1。主站知道自己发出了几个报文,'
|
||||
'这个应得值就是 <code>expected_wkc</code>(本台子 1 个从站、每拍 3 个报文 '
|
||||
'→ 期望 3)。所以 <code>wkc 3/3</code> = 从站每拍都完整处理了;'
|
||||
'<code>wkc -1</code> = 整帧没回来(从站停答/掉 OP);'
|
||||
'<code>wr(watchdog reset)0→1</code> = 看门狗把总线重拉过一次。</li>'
|
||||
'<li><b>主站错拍 skip</b>:主站按周期给自己排了一个"节拍网格"'
|
||||
'(<code>ecat_native.c:776-791</code>)。每拍做完 EtherCAT 收发后,'
|
||||
'若发现已经错过了下一个节拍点(甚至错过多个),就把错过的个数累进 '
|
||||
'<code>skip_total</code>。<b>它量的是"主机软件迟到",不是从站故障</b> —— '
|
||||
'1ms 档 1.1 次/s 只是偶发 OS 调度抖动;0.125ms 档 5107 次/s 说明'
|
||||
'单拍耗时已超过两个周期,主机也在勉强。它不区分"从站慢"和"主机慢"。</li>'
|
||||
'<li><b>在线</b>:电机槽位的 <code>online</code> 标志(槽位数据在超时窗内'
|
||||
'持续刷新才为 true)。带 CAN 轮显示"未记录"是因为当时脚本还没有这列,'
|
||||
'<b>不是电机掉线</b>;该轮的电机在线状况另有 8s/档定点实测佐证。</li>'
|
||||
'</ul></div>')
|
||||
|
||||
b.append('<h2>二、数据全表</h2>')
|
||||
fmt = lambda v, p=1, suf="": ("n/a" if v is None else f"{v:,.{p}f}{suf}")
|
||||
base = [("周期", lambda r: f"{r['period_ms']:g}ms"),
|
||||
("实测Hz", lambda r: fmt(r["hz_meas"], 0)),
|
||||
("达成率", lambda r: fmt(r["achieve"], 1, "%")),
|
||||
("从站load", lambda r: fmt(r["load"], 1, "%")),
|
||||
("CAN接收/s", lambda r: fmt(r["lambda_"], 0)),
|
||||
("skip/s", lambda r: fmt(r["skip"], 2)),
|
||||
("wkc", lambda r: f'{html.escape(str(r["wkc"]))}/{html.escape(str(r["exp_wkc"]))}')]
|
||||
cols_wc = base + [("在线", lambda r: "未记录*")]
|
||||
cols_no = base + [("在线", lambda r: fmt(r["online_frac"], 2))]
|
||||
b.append(tbl(wc, cols_wc, "带 CAN(主结论轮)"))
|
||||
b.append(tbl(no, cols_no, "拔掉全部 CAN 设备(补测轮)"))
|
||||
b.append('<p class="meta"><b>*</b> 「在线」列是扫描脚本后来才加的('
|
||||
'<code>motors_online</code>/<code>online_frac</code>),带 CAN 那轮跑在加列'
|
||||
'<b>之前</b>,逐档数据没采 —— <b>不是全部掉线</b>。该轮电机在线另有'
|
||||
' 8s/档定点实测(「电机数据刷新率」表)佐证。</p>')
|
||||
mrows = "".join(
|
||||
f"<tr><td>{lab}ms</td><td>{v:,.1f}</td>"
|
||||
f"<td>{'正常' if v > 3000 else '塌陷'}</td></tr>"
|
||||
for lab, _hz, v in motor)
|
||||
b.append('<h3>电机数据刷新率(带 CAN 轮,8s/档定点实测)</h3><table>'
|
||||
'<tr><th>周期</th><th>刷新率 帧/s</th><th>判定</th></tr>' + mrows + '</table>'
|
||||
'<p class="meta">这是唯一直接回答"用户还能不能用"的指标 —— λ 与 drop 都是中间量。'
|
||||
'塌陷两档当场确认 <code>online=True</code>,故为真失效而非掉线。</p>')
|
||||
|
||||
b.append('<h2>三、机理与上限推算</h2>')
|
||||
b.append('<div class="card"><h3>上限已实测:≈8.2kHz,墙是单拍 ≈121µs 的硬耗时</h3>'
|
||||
'<p><b>证据链</b>:无 CAN 设定 0.25 → 0.0625ms 五档,实测循环率在 ≤0.125ms '
|
||||
'(8kHz)前跟随设定,再快就被钉死在 8196–8238Hz;与此同时主站单拍耗时 '
|
||||
'<code>cyc p50</code> 在<b>所有档位</b>(含 2ms 慢档)恒为 117–126µs,'
|
||||
'1/121µs ≈ 8.2kHz,与顶墙实测严丝合缝。即:每拍有一段不可压缩的'
|
||||
'「发送→从站处理→回读+主站处理」往返时间,周期设定小于它时就堵在这里。</p>'
|
||||
'<p><b>排除项</b>:不是主机 CPU 不够 —— 顶墙档主机 CPU 只有 ~85%'
|
||||
'(自旋等待占比变小);不是从站拒答 —— wkc 全程 3/3。'
|
||||
'<code>load_perm</code> 在 ≥4kHz 全饱和 99.x%,既定位不了拐点也喂不准模型,'
|
||||
'早先「两项模型 load=F/T+R → 2.7/3.5kHz」的外推已被满速补测证伪,仅留档。</p>'
|
||||
'<p><b>未闭合的一处</b>:121µs 里主站协议栈与从站 ESC/固件各占多少,'
|
||||
'本轮数据分不开 —— 需给固件 <code>fillRxWindow</code> 加直接计时'
|
||||
'(固件改动,需批准)或抓总线帧时序才能归因。</p></div>')
|
||||
|
||||
b.append('<h2>四、判据口径与踩过的坑</h2>')
|
||||
b.append('<div class="card warn"><h3>仪表陷阱(每条都实际踩过)</h3><ul>'
|
||||
'<li><b>【本次真正翻车处】把两个同速计数器相除当成比率</b> —— '
|
||||
'<code>drop</code>/<code>rx_count</code> 都约 4000/s,相除恒得 100%,'
|
||||
'看着像"全丢",其实只是<b>同速量之比</b>。判丢帧必须有<b>独立</b>的分子与分母:'
|
||||
'本次真正的独立证据是「STATUS 实测 4001 帧/s 正常刷新」+「CONFIG 事件流去重后'
|
||||
'仅约 92 帧/s」,两者一起否掉了 100% 丢帧。<b>任何"A/B 比率"指标,先问 A 与 B '
|
||||
'是否独立计数。</b></li>'
|
||||
'<li><b>从站停报后 <code>load_perm</code> 保留残值</b> —— 实测 '
|
||||
'<code>age_ms=214338</code> 时仍报 99.2%。任何取 load_perm 的脚本必须先过 '
|
||||
'<code>age_ms</code> 闸。<b>扫描脚本已加该闸</b>并新增 '
|
||||
'<code>gd32_load_samples</code>/<code>gd32_load_stale</code> 两列。</li>'
|
||||
'<li><b><code>load_perm</code> 不是整机 CPU 占用率</b> —— 固件 '
|
||||
'<code>sys/cpu_load.h</code> 定义收窄为"中断+非延迟处理段",'
|
||||
'<b>排除</b> SSC MainLoop 轮询;当前 free-run(<code>dc=0</code>)下 PDO '
|
||||
'应用走主循环轮询路径、<b>不计入</b>。所以不能把"load→100%"读成"CPU 耗尽"。</li>'
|
||||
'<li><b><code>OVF</code> 每拍被 <code>s_rxWinOvf=0</code> 清零</b> —— GD32 侧'
|
||||
'没有可用的累计丢帧计数。读它恒 0 <b>不能</b>当"无丢帧"证据'
|
||||
'(我据此误判 <code>bridge.drop</code> 是死字段,结论已收回)。</li>'
|
||||
'<li><b><code>/api/can/cadence</code> 只存最近 512 拍</b> —— 0.25ms 档 '
|
||||
'=128ms、0.125ms=64ms,环没装满时取到的 <code>count</code>/<code>lost</code> '
|
||||
'是<b>行数</b>不是每拍帧数。对应 CSV 列已从结论排除。</li>'
|
||||
'<li><b>u16 回绕</b>:<code>drop</code> 在 4000/s 下 16 秒绕一圈 → 10Hz 逐次累加 '
|
||||
'+ 取模,单次增量>32768 记为作废。<b>本次作废点数=0</b>。</li>'
|
||||
'<li><b>「seq 值域 − distinct」不是丢帧率</b>:报 99.686%,方向与 drop≈λ 一致'
|
||||
'但分母含 STATUS 全流量,口径不干净 → 只作旁证。</li>'
|
||||
'<li><b>周期可被 localStorage 劫持</b>(<code>transport-ecat.js:227-228</code>)'
|
||||
'→ 每档下发后回读校验。本次六档 <code>hijacked=no</code>、native PID 全程 29284 未变。</li>'
|
||||
'<li><b><code>cyc_us.late</code>/<code>lost</code> 恒 0</b>(遗留占位,未接线),未使用。</li>'
|
||||
'</ul></div>')
|
||||
|
||||
b.append('<h2>五、可信度边界</h2>')
|
||||
fw = cal.get("firmware", {})
|
||||
b.append('<div class="card bad"><h3>这份数据不能证明什么</h3><ul>'
|
||||
f'<li><b>板载固件版本无法确认</b>:烧写台账 '
|
||||
f'<code>{html.escape(str(fw.get("ledger",""))[:16])}…</code> 与在盘 .hex 展平哈希'
|
||||
f'({html.escape("、".join(str(c.get("sha256",""))[:8]+"…" for c in fw.get("candidates",[])))})'
|
||||
f'<b>均不匹配</b>,且产物时间戳晚于台账。→ 本报告不复用任何历史固件结论。</li>'
|
||||
'<li>主站在测量期间又重编过(源码 02:39 / exe 02:40,PID 起于 02:32),'
|
||||
'无法证明运行中的进程就是盘上那份。</li>'
|
||||
'<li><b><code>rx_torn</code> 与 <code>pdo_reentry</code> 整段恒零 = 无检出功效</b>,'
|
||||
'不是"无撕裂"。二者分别需固件 1.6.6/1.6.7+,版本未知 → 无法区分"没发生"与'
|
||||
'"字段根本没实现、恒读 0"。</li>'
|
||||
'<li>λ≈3993/s 是本次 CAN 工况。<b>λ 变则负担变</b>,0.5ms 这个数不可外搬到别流量。</li>'
|
||||
'<li>单台单次,未做重复性与温漂验证。</li>'
|
||||
'</ul></div>')
|
||||
|
||||
b.append('<h2>六、下一步</h2>')
|
||||
b.append('<div class="card"><h3>按优先级</h3><ul>'
|
||||
'<li><b>查明 <code>drop</code> 口径(当前最高优先,唯一未决的承重项)</b>。'
|
||||
'必须用<b>一对独立可比</b>的计数,不能再拿同速量相除:从 WiFi 侧读固件 u32 '
|
||||
'<code>rx_drop_count</code>(<code>app_spi_cmd_can_feedback.cpp:99</code>)'
|
||||
'对速率,同时用 CAN 监控页统计<b>实收</b> CONFIG 帧数作分母。'
|
||||
'只有「独立到达计数 − 独立交付计数」才是丢帧数。</li>'
|
||||
'<li><b>台子现状</b>:断电重启后重新 open+start 成功(OP、wkc=3),'
|
||||
'无 CAN 的 0.25→0.0625ms 五档补测完成,周期已自动恢复 1ms。'
|
||||
'<b>CAN 设备当前处于拔除状态</b>,电机数据相关指标要插回 CAN 才能再测。</li>'
|
||||
'<li>插回 CAN 后补测 <b>0.35 / 0.4 / 0.5ms</b> 三档的<b>电机数据刷新率</b>,'
|
||||
'把带 CAN 工况的可用上限定位出来。load_perm 已被证明在快档饱和,'
|
||||
'<b>不能再用它定位拐点</b>;扫描脚本的 '
|
||||
'<code>motors_online</code>/<code>online_frac</code>/'
|
||||
'<code>motor_refresh_per_s</code> 三列才是可用性判据。</li>'
|
||||
'<li>给 <code>fillRxWindow</code> 加直接计时,把 121µs 硬耗时里的主/从占比'
|
||||
'拆开(属固件改动,需批准)。</li>'
|
||||
'<li>0.5ms 档实跑电机页完整命令序列 —— 本调研只测了计数器,没测界面实际可用性。</li>'
|
||||
'</ul></div>')
|
||||
|
||||
live = ""
|
||||
try:
|
||||
h = post("http://127.0.0.1:8090", "/api/native/health", timeout=1.5)
|
||||
st = post("http://127.0.0.1:8090", "/api/motor/state", timeout=1.5)
|
||||
live = (f'native 实时:op={h.get("op")} loop={h.get("loop")} wkc={h.get("wkc")} '
|
||||
f'period={st.get("period_ms")}ms')
|
||||
except Exception:
|
||||
live = "native 当前不可达(生成本报告时未连上 8090)"
|
||||
b.append(f'<div class="foot">本报告由 <code>scripts/build_ecat_report_html.py</code> 从 '
|
||||
f'<code>lab/*.csv</code> 与 <code>lab/ecat_calib.json</code> 现场渲染,图中每个数值均'
|
||||
f'从原始数据计算,未手抄。{html.escape(live)}。'
|
||||
f'Markdown 版(含同源结论):<code>ECAT_RATE_TRADEOFF.md</code>。</div>')
|
||||
|
||||
out = a.out
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write(PAGE.format(body="\n".join(b)))
|
||||
print("HTML 报告 ->", out)
|
||||
print(f" 参与绘图:带CAN {len(wc_ok)} 档、无CAN {len(no_ok)} 档;排除 {len(excl)} 档")
|
||||
if deltas:
|
||||
print(f" CAN 负担 Δ:{'、'.join(f'{d:.1f}pp' for d in deltas)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,424 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""生成对接电机积分差归因 HTML(通俗说明 + 标注图)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
LAB = Path(__file__).resolve().parents[1] / "lab"
|
||||
SRC = LAB / "ecat_who_slow.json"
|
||||
OUT = LAB / "ecat_integral_report.html"
|
||||
|
||||
data = json.loads(SRC.read_text(encoding="utf-8"))
|
||||
pa = data["phase_attribution"]
|
||||
strokes = data["strokes"]
|
||||
sample = data.get("plot_sample") or []
|
||||
verdict = data.get("verdict") or {}
|
||||
|
||||
# 用行程数据修正通俗结论(避免停车检测为 0 的误导)
|
||||
on_vals = pa["on_ms_n3_minus_n2"]["values"]
|
||||
# 剔除 |Δ|>50ms 的伪起动(上一段未停稳造成的重叠)
|
||||
on_clean = [v for v in on_vals if abs(v) <= 50]
|
||||
on_overlap = [v for v in on_vals if abs(v) > 50]
|
||||
I_on = [s["I_on"]["Isum"] for s in strokes]
|
||||
I_cr = [s["I_cruise"]["Isum"] for s in strokes]
|
||||
I_st = [s["I_stop"]["Isum"] for s in strokes]
|
||||
only2 = [s["stop_only_n2"]["Isum"] for s in strokes]
|
||||
only3 = [s["stop_only_n3"]["Isum"] for s in strokes]
|
||||
# 停止窗谁残速大:I_st>0 → 合速度偏正 → 镜像下更像 N3 仍正/N2 已减
|
||||
# 直接用 only_* 贡献绝对值
|
||||
sum_only2 = sum(abs(x) for x in only2)
|
||||
sum_only3 = sum(abs(x) for x in only3)
|
||||
n_only2 = sum(1 for x in only2 if abs(x) > 0.2)
|
||||
n_only3 = sum(1 for x in only3 if abs(x) > 0.2)
|
||||
|
||||
cruise_v2 = pa["cruise_mean_abs_v2"]["mean"]
|
||||
cruise_v3 = pa["cruise_mean_abs_v3"]["mean"]
|
||||
cruise_bias = pa["cruise_bias_v2_minus_v3"]["mean"]
|
||||
|
||||
mean_on_clean = sum(on_clean) / len(on_clean) if on_clean else None
|
||||
mean_Ion = sum(I_on) / len(I_on) if I_on else 0
|
||||
mean_Icr = sum(I_cr) / len(I_cr) if I_cr else 0
|
||||
mean_Ist = sum(I_st) / len(I_st) if I_st else 0
|
||||
abs_Ist = sum(abs(x) for x in I_st) / len(I_st) if I_st else 0
|
||||
abs_Ion = sum(abs(x) for x in I_on) / len(I_on) if I_on else 0
|
||||
abs_Icr = sum(abs(x) for x in I_cr) / len(I_cr) if I_cr else 0
|
||||
|
||||
# SVG 折线
|
||||
W, H = 960, 420
|
||||
PAD_L, PAD_R, PAD_T = 56, 20, 36
|
||||
|
||||
|
||||
def poly(xs, ys, y0, y1, top, h, color, w=1.3):
|
||||
if not xs:
|
||||
return ""
|
||||
lo, hi = y0, y1
|
||||
if hi - lo < 1e-9:
|
||||
hi = lo + 1
|
||||
|
||||
def X(t, t0, t1):
|
||||
span = max(t1 - t0, 1e-6)
|
||||
return PAD_L + (t - t0) / span * (W - PAD_L - PAD_R)
|
||||
|
||||
def Y(v):
|
||||
return top + (hi - v) / (hi - lo) * h
|
||||
|
||||
pts = " ".join(f"{X(xs[i], xs[0], xs[-1]):.1f},{Y(ys[i]):.1f}" for i in range(len(xs)))
|
||||
return f'<polyline fill="none" stroke="{color}" stroke-width="{w}" points="{pts}"/>'
|
||||
|
||||
|
||||
if sample:
|
||||
ts = [p["t"] for p in sample]
|
||||
v2 = [p["v2"] for p in sample]
|
||||
v3 = [p["v3"] for p in sample]
|
||||
vsum = [p["vsum"] for p in sample]
|
||||
Isum = [p["Isum"] for p in sample]
|
||||
t0, t1 = ts[0], ts[-1]
|
||||
vmin = min(min(v2), min(v3))
|
||||
vmax = max(max(v2), max(v3))
|
||||
imin = min(min(Isum), 0)
|
||||
imax = max(max(Isum), 0)
|
||||
padv = (vmax - vmin) * 0.08 or 10
|
||||
padI = (imax - imin) * 0.1 or 2
|
||||
|
||||
def X(t):
|
||||
return PAD_L + (t - t0) / max(t1 - t0, 1e-6) * (W - PAD_L - PAD_R)
|
||||
|
||||
def Yv(v):
|
||||
return 48 + (vmax + padv - v) / (vmax - vmin + 2 * padv) * 100
|
||||
|
||||
def Ys(v):
|
||||
return 190 + (vmax + padv - v) / (vmax - vmin + 2 * padv) * 80
|
||||
|
||||
def YI(v):
|
||||
return 300 + (imax + padI - v) / (imax - imin + 2 * padI) * 80
|
||||
|
||||
def line(vals, yfn, color, w=1.3):
|
||||
pts = " ".join(f"{X(ts[i]):.1f},{yfn(vals[i]):.1f}" for i in range(len(ts)))
|
||||
return f'<polyline fill="none" stroke="{color}" stroke-width="{w}" points="{pts}"/>'
|
||||
|
||||
# 行程分界竖线
|
||||
vlines = []
|
||||
for sm in data.get("stroke_marks") or []:
|
||||
if sm["t0"] <= t1:
|
||||
x = X(sm["t0"])
|
||||
vlines.append(
|
||||
f'<line x1="{x:.1f}" x2="{x:.1f}" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/>'
|
||||
)
|
||||
vlines.append(
|
||||
f'<text x="{x+3:.1f}" y="54" fill="#5c6b7e" font-size="9" '
|
||||
f'font-family="Segoe UI,sans-serif">#{sm["i"]}</text>'
|
||||
)
|
||||
else:
|
||||
ts = v2 = v3 = vsum = Isum = []
|
||||
t0 = t1 = 0
|
||||
vlines = []
|
||||
|
||||
# 标注箭头:在图上用文字+简单箭头路径
|
||||
notes_svg = []
|
||||
if sample:
|
||||
# 找几个典型点
|
||||
# 1) 巡航:|v| 稳定处
|
||||
# 2) 停车:vsum 尖峰
|
||||
peak_i = max(range(len(sample)), key=lambda i: abs(sample[i]["vsum"]))
|
||||
# 理论零线
|
||||
notes_svg.append(
|
||||
f'<text x="{PAD_L}" y="28" fill="#e8eef7" font-size="14" '
|
||||
f'font-family="Segoe UI,Microsoft YaHei,sans-serif">'
|
||||
f'对接电机 2/3:速度、合速度 v2+v3、积分 ∫(v2+v3)dt(虚线为理论 0)</text>'
|
||||
)
|
||||
|
||||
# 表格行
|
||||
rows_html = []
|
||||
for s in strokes:
|
||||
cls_on = "bad" if abs(s["on_ms_n3_minus_n2"]) > 50 else "ok"
|
||||
cls_cr = "ok"
|
||||
cls_st = "warn" if abs(s["I_stop"]["Isum"]) > 3 else "ok"
|
||||
who = ""
|
||||
if abs(s["stop_only_n3"]["Isum"]) > abs(s["stop_only_n2"]["Isum"]) and abs(s["stop_only_n3"]["Isum"]) > 0.2:
|
||||
who = "N3 残速"
|
||||
elif abs(s["stop_only_n2"]["Isum"]) > 0.2:
|
||||
who = "N2 残速"
|
||||
else:
|
||||
who = "两台接近同步刹停"
|
||||
rows_html.append(f"""
|
||||
<tr>
|
||||
<td>#{s['i']}</td><td>{s['dir_n2']}</td>
|
||||
<td class="{cls_on}">{s['on_ms_n3_minus_n2']:+.2f} ms</td>
|
||||
<td class="{cls_cr}">{s['cruise']['mean_abs_v2']:.2f} / {s['cruise']['mean_abs_v3']:.2f}</td>
|
||||
<td>{s['I_on']['Isum']:+.2f}</td>
|
||||
<td class="ok">{s['I_cruise']['Isum']:+.2f}</td>
|
||||
<td class="{cls_st}">{s['I_stop']['Isum']:+.2f}</td>
|
||||
<td>{s['stop_only_n2']['Isum']:+.2f}</td>
|
||||
<td>{s['stop_only_n3']['Isum']:+.2f}</td>
|
||||
<td class="{cls_st}">{who}</td>
|
||||
<td>{s['da2_plus_da3']:+.2f}°</td>
|
||||
</tr>""")
|
||||
|
||||
# 通俗结论文本
|
||||
if abs_Ist > abs_Icr * 3 and abs_Ist > abs_Ion:
|
||||
main_src = "停车阶段"
|
||||
elif abs_Ion > abs_Ist and abs_Ion > abs_Icr:
|
||||
main_src = "行程衔接/起动窗口(含上一段未停稳的重叠)"
|
||||
else:
|
||||
main_src = "起动与停车都有贡献,巡航最干净"
|
||||
|
||||
plain_html = f"""
|
||||
<section class="card">
|
||||
<h2>通俗结论:到底是谁慢?</h2>
|
||||
<p class="lead">先说人话:<strong>两台电机并不是“有一台全程都慢”</strong>。
|
||||
你看到的积分差,主要不是巡航时谁转得慢,而是<strong>快停下 / 刚要走的那几十一到几百毫秒里,两台对不齐</strong>,
|
||||
把理论应为 0 的 <code>∫(v2+v3)dt</code> 顶出 5~20° 左右的偏差。</p>
|
||||
|
||||
<ul class="verdict">
|
||||
<li><span class="tag ok">启动</span>
|
||||
干净起动时(|Δt|≤50ms 的行程),N3−N2 约为
|
||||
<strong>{mean_on_clean if mean_on_clean is not None else '—'} ms</strong> 量级 →
|
||||
<strong>几乎是同时启动</strong>,ECAT 同帧下发没有问题。
|
||||
有 {len(on_overlap)} 个行程测出上百毫秒的“假早启动”,那是<strong>上一段还没停稳、N3 仍在动</strong>,
|
||||
不是这一段命令发晚了。</li>
|
||||
<li><span class="tag ok">巡航</span>
|
||||
两边平均 |v|:N2 <strong>{cruise_v2:.2f}</strong>°/s,N3 <strong>{cruise_v3:.2f}</strong>°/s,
|
||||
差值约 <strong>{cruise_bias:.3f}</strong>°/s → <strong>稳态速度几乎一样,谈不上谁慢</strong>。
|
||||
巡航段 ∫(v2+v3) 平均只有约 <strong>{mean_Icr:.2f}°</strong>,贴合理论 0。</li>
|
||||
<li><span class="tag warn">停车 / 换向</span>
|
||||
行程末端 ∫(v2+v3) 平均约 <strong>{mean_Ist:.2f}°</strong>,绝对值平均约 <strong>{abs_Ist:.2f}°</strong>,
|
||||
明显大于巡航。停止窗口里:
|
||||
出现「只有 N2 还在走」的行程约 <strong>{n_only2}</strong> 次(积分贡献合计 {sum_only2:.2f}°),
|
||||
「只有 N3 还在走」约 <strong>{n_only3}</strong> 次(合计 {sum_only3:.2f}°)。
|
||||
<strong>两边都有刹车尾巴,不是单边永久变慢</strong>;有的行程 N2 拖尾更明显,有的行程 N3 更明显。</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout">
|
||||
<strong>一句话:</strong>积分差的大头在 <strong>{main_src}</strong>。
|
||||
对接约束要求 <code>v2 = −v3</code>;只要刹停瞬间一台还带着速度、另一台已经接近 0,
|
||||
<code>v2+v3</code> 就会冒尖峰,积分就会被拉离理论 0。这属于<strong>动态不同步</strong>,
|
||||
不等于某台电机“标定转速低、一直慢”。
|
||||
</div>
|
||||
|
||||
<div class="arrow-legend">
|
||||
<div><span class="arrow ok-arrow"></span> 正常:同时启动 / 巡航一致 / 中段积分≈0</div>
|
||||
<div><span class="arrow warn-arrow"></span> 注意:停车沿残速、行程衔接重叠、积分被顶开</div>
|
||||
<div><span class="arrow bad-arrow"></span> 偏大:该行程 ∫(v2+v3) 或编码器 Δ(a2+a3) 明显偏离 0</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
|
||||
rows_joined = "\n".join(rows_html)
|
||||
|
||||
# 标注框(放在 SVG 下方用 HTML 箭头说明)
|
||||
annot_html = f"""
|
||||
<section class="card">
|
||||
<h2>图上怎么看(对照上方曲线)</h2>
|
||||
<div class="annot-grid">
|
||||
<div class="annot ok">
|
||||
<div class="ann-title">↑ 巡航段(两条速度曲线几乎贴合)</div>
|
||||
<p>N2 蓝线、N3 橙线在 |v|≈200°/s 附近重合 → <strong>都不慢</strong>。
|
||||
下方红线 <code>v2+v3</code> 贴 0,积分曲线几乎水平 → 对接约束在稳态是成立的。</p>
|
||||
</div>
|
||||
<div class="annot warn">
|
||||
<div class="ann-title">↓ 停车沿 / 换向点(红线尖峰)</div>
|
||||
<p>接近终点时,一台速度先掉下来、另一台还在走 → <code>v2+v3</code> 出现 ±100~200°/s 尖峰。
|
||||
积分曲线在这里<strong>爬上或爬下</strong>,行程结束就停在理论 0 以外几度到二十度。
|
||||
<strong>这里是积分差的主要来源。</strong></p>
|
||||
</div>
|
||||
<div class="annot warn">
|
||||
<div class="ann-title">↓ 行程刚衔接时(起动窗)</div>
|
||||
<p>若上一段没完全停稳就反向,会看到“N3 早就动了”的假象(表格里 onΔ 为 −800ms 量级)。
|
||||
这<strong>不是启动指令不同步</strong>,而是<strong>速度还在反向爬坡 / 残速</strong>,
|
||||
也会把起动窗积分抬高一点。</p>
|
||||
</div>
|
||||
<div class="annot bad">
|
||||
<div class="ann-title">↓ 紫色角度和 a2+a3</div>
|
||||
<p>理论应为常数(理想为 0)。行程终点若偏到 ±10~25°,与该行程 ∫(v2+v3) 同量级 →
|
||||
说明偏差是<strong>真实位移耦合误差</strong>,不只是速度噪声。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout small">
|
||||
<strong>标定建议(若要压积分差):</strong>
|
||||
① 先统一两台的减速段(同样斜率刹停),而不是去改巡航速度;
|
||||
② 检查 N2/N3 负载与制动是否对称;
|
||||
③ 控制器上对对接轴做 <code>v2+v3 → 0</code> 的纠偏,比单轴调 PID 更对症;
|
||||
④ 序列 hold 不要短于“两台都确认停稳”的时间,避免重叠行程把积分搅浑。
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
|
||||
# SVG 图
|
||||
if sample:
|
||||
svg = f"""
|
||||
<svg viewBox="0 0 {W} {H}" width="100%" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="{W}" height="{H}" fill="#0f1419"/>
|
||||
{''.join(vlines)}
|
||||
{line(v2, Yv, '#4da3ff')}
|
||||
{line(v3, Yv, '#f0a030')}
|
||||
<text x="{PAD_L}" y="44" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">速度 deg/s — 蓝 N2 / 橙 N3</text>
|
||||
<line x1="{PAD_L}" x2="{W-PAD_R}" y1="{Yv(0):.1f}" y2="{Yv(0):.1f}" stroke="#2a3340" stroke-dasharray="4 3"/>
|
||||
{line(vsum, Ys, '#ff5c5c')}
|
||||
<text x="{PAD_L}" y="184" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">合速度 v2+v3(理论 0)— 停车尖峰在这里</text>
|
||||
<line x1="{PAD_L}" x2="{W-PAD_R}" y1="{Ys(0):.1f}" y2="{Ys(0):.1f}" stroke="#3a4656" stroke-dasharray="4 3"/>
|
||||
{line(Isum, YI, '#b48cff')}
|
||||
<text x="{PAD_L}" y="294" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">积分 ∫(v2+v3)dt(理论 0)— 每段末的偏离就是耦合误差</text>
|
||||
<line x1="{PAD_L}" x2="{W-PAD_R}" y1="{YI(0):.1f}" y2="{YI(0):.1f}" stroke="#3a4656" stroke-dasharray="4 3"/>
|
||||
<text x="{PAD_L}" y="{H-12}" fill="#6b7a8d" font-size="11" font-family="Segoe UI,sans-serif">t_rel {t0:.1f}..{t1:.1f}s · ECAT 0.25ms · 竖线为行程起点</text>
|
||||
<!-- 标注箭头 -->
|
||||
<g font-family="Segoe UI,Microsoft YaHei,sans-serif" font-size="11">
|
||||
<text x="{W-210}" y="70" fill="#3d8f5b">↑ 巡航:两线贴合,都不慢</text>
|
||||
<text x="{W-230}" y="210" fill="#d4a017">↓ 停车沿:v_sum 尖峰 → 积分爬升</text>
|
||||
</g>
|
||||
</svg>"""
|
||||
else:
|
||||
svg = "<p>(无绘图样本)</p>"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>对接电机 2/3 积分差归因报告</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg:#0f1419; --card:#161c24; --ink:#e8eef7; --mut:#8a9bb0;
|
||||
--ok:#3d8f5b; --warn:#d4a017; --bad:#e25555; --line:#2a3340;
|
||||
}}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{
|
||||
margin:0; background:var(--bg); color:var(--ink);
|
||||
font-family:"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
|
||||
line-height:1.55; padding:24px;
|
||||
}}
|
||||
h1 {{ font-size:22px; font-weight:600; margin:0 0 6px; }}
|
||||
h2 {{ font-size:16px; font-weight:600; margin:0 0 12px; }}
|
||||
.sub {{ color:var(--mut); font-size:13px; margin-bottom:18px; }}
|
||||
.card {{
|
||||
background:var(--card); border:1px solid var(--line); border-radius:12px;
|
||||
padding:18px 20px; margin-bottom:16px;
|
||||
}}
|
||||
.lead {{ margin:0 0 12px; color:var(--ink); }}
|
||||
code {{
|
||||
background:#0c1014; padding:1px 6px; border-radius:4px;
|
||||
font-family:Consolas,monospace; font-size:13px; color:#c5d4e8;
|
||||
}}
|
||||
ul.verdict {{ list-style:none; padding:0; margin:0 0 14px; }}
|
||||
ul.verdict li {{
|
||||
margin:0 0 10px; padding:10px 12px; background:#121820;
|
||||
border-left:3px solid var(--line); border-radius:0 8px 8px 0;
|
||||
}}
|
||||
.tag {{
|
||||
display:inline-block; font-size:12px; font-weight:600; padding:2px 8px;
|
||||
border-radius:999px; margin-right:8px; vertical-align:middle;
|
||||
}}
|
||||
.tag.ok {{ background:rgba(61,143,91,.2); color:#6dca8f; }}
|
||||
.tag.warn {{ background:rgba(212,160,23,.2); color:#f0c35a; }}
|
||||
.callout {{
|
||||
background:#121a14; border:1px solid #2a4a34; border-radius:8px;
|
||||
padding:12px 14px; margin-top:8px;
|
||||
}}
|
||||
.callout.small {{ font-size:13px; background:#1a1520; border-color:#3a2a4a; }}
|
||||
table {{
|
||||
width:100%; border-collapse:collapse; font-size:12.5px; margin-top:8px;
|
||||
}}
|
||||
th, td {{
|
||||
border-bottom:1px solid var(--line); padding:6px 8px; text-align:left;
|
||||
}}
|
||||
th {{ color:var(--mut); font-weight:500; }}
|
||||
td.ok {{ color:#6dca8f; }}
|
||||
td.warn {{ color:#f0c35a; }}
|
||||
td.bad {{ color:#ff8a8a; }}
|
||||
.annot-grid {{
|
||||
display:grid; grid-template-columns:1fr 1fr; gap:12px;
|
||||
}}
|
||||
@media (max-width:800px) {{ .annot-grid {{ grid-template-columns:1fr; }} }}
|
||||
.annot {{
|
||||
background:#121820; border-radius:8px; padding:12px; border-left:3px solid var(--line);
|
||||
}}
|
||||
.annot.ok {{ border-left-color:var(--ok); }}
|
||||
.annot.warn {{ border-left-color:var(--warn); }}
|
||||
.annot.bad {{ border-left-color:var(--bad); }}
|
||||
.ann-title {{ font-weight:600; margin-bottom:6px; font-size:13px; }}
|
||||
.annot p {{ margin:0; font-size:13px; color:#c5d0dc; }}
|
||||
.arrow-legend {{ margin-top:12px; font-size:13px; color:var(--mut); }}
|
||||
.arrow-legend div {{ margin:4px 0; }}
|
||||
.arrow {{ display:inline-block; width:18px; height:3px; margin-right:8px; vertical-align:middle; }}
|
||||
.ok-arrow {{ background:var(--ok); }}
|
||||
.warn-arrow {{ background:var(--warn); }}
|
||||
.bad-arrow {{ background:var(--bad); }}
|
||||
.metrics {{
|
||||
display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-top:10px;
|
||||
}}
|
||||
@media (max-width:800px) {{ .metrics {{ grid-template-columns:1fr 1fr; }} }}
|
||||
.metric {{
|
||||
background:#121820; border:1px solid var(--line); border-radius:8px; padding:10px 12px;
|
||||
}}
|
||||
.metric .k {{ color:var(--mut); font-size:12px; }}
|
||||
.metric .v {{ font-size:20px; font-weight:600; margin-top:2px; }}
|
||||
.metric .v.ok {{ color:#6dca8f; }}
|
||||
.metric .v.warn {{ color:#f0c35a; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>对接电机 2 / 3:积分差是谁造成的?</h1>
|
||||
<div class="sub">
|
||||
数据:ECAT 无源采集 <code>lab/ecat_passive_latest.csv</code> · 周期 0.25ms ·
|
||||
理论:刚性镜像对接时 <code>v2+v3≡0</code>,<code>∫(v2+v3)dt≡0</code>,<code>a2+a3=常数</code><br/>
|
||||
方法:对齐 cycles 后做梯形积分;先加后积与先积后加一致。
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>关键数字</h2>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="k">巡航 |v| N2 / N3</div>
|
||||
<div class="v ok">{cruise_v2:.2f} / {cruise_v3:.2f}</div></div>
|
||||
<div class="metric"><div class="k">巡航段 ∫(v2+v3) 平均</div>
|
||||
<div class="v ok">{mean_Icr:.2f}°</div></div>
|
||||
<div class="metric"><div class="k">停车段 ∫(v2+v3) |均值|</div>
|
||||
<div class="v warn">{abs_Ist:.2f}°</div></div>
|
||||
<div class="metric"><div class="k">干净起动 Δt 中位/均值</div>
|
||||
<div class="v ok">{(sorted(on_clean)[len(on_clean)//2] if on_clean else float('nan')):.2f} / {(mean_on_clean if mean_on_clean is not None else float('nan')):.2f} ms</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{plain_html}
|
||||
|
||||
<section class="card">
|
||||
<h2>曲线(带标注)</h2>
|
||||
{svg}
|
||||
</section>
|
||||
|
||||
{annot_html}
|
||||
|
||||
<section class="card">
|
||||
<h2>各行程明细(积分从行程起点重算)</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>方向</th><th>起动Δ(N3-N2)</th><th>巡航|v| N2/N3</th>
|
||||
<th>∫起动</th><th>∫巡航</th><th>∫停车</th>
|
||||
<th>停时仅N2在动</th><th>停时仅N3在动</th><th>停车归因</th><th>Δ(a2+a3)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows_joined}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="sub" style="margin-top:10px">
|
||||
单位:角度 deg(积分)· 速度 deg/s · 时间 ms。
|
||||
「停时仅N2/N3在动」为该窗口内单边残速对 ∫(v2+v3) 的贡献。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>原始结论脚本输出(备查)</h2>
|
||||
<pre style="white-space:pre-wrap;font-size:12px;color:#a8b6c8;margin:0">{json.dumps(verdict, ensure_ascii=False, indent=2)}</pre>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
OUT.write_text(html, encoding="utf-8")
|
||||
print("HTML ->", OUT)
|
||||
print("main_src:", main_src)
|
||||
print("on_clean", on_clean, "mean", mean_on_clean)
|
||||
print("I_on/I_cr/I_st abs mean", abs_Ion, abs_Icr, abs_Ist)
|
||||
print("only2", n_only2, sum_only2, "only3", n_only3, sum_only3)
|
||||
@@ -1,418 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Phase 0:ECAT 指标现场标定 —— 跑速度扫描之前,先确认每个计数器还有没有意义。
|
||||
|
||||
为什么必须有这一步:固件与主站都在被反复改(当前 ecat_native.c 有 239 行未提交改动,
|
||||
批量区已从"变长紧排 3 帧/拍"换成 1.6.15 定长覆盖窗口),任何沿用历史结论的判据都可能
|
||||
已经失效。本脚本只认现场读数,不认文档和历史。
|
||||
|
||||
标定八件事:
|
||||
1 总线能否正常起(open + motor/start)
|
||||
2 板上固件身份(台账 sha256 对 Release/*.hex 展平后的哈希)
|
||||
3 批量区实际布局(rxraw.layout,不假设)
|
||||
4 每拍入窗帧数分布(取 cadence 环,按拍采样,不受 HTTP 轮询速率影响)
|
||||
5 丢帧率的主判据:**seq 值域 − 实测 distinct seq**(对槽位顺序不敏感)
|
||||
对账判据:固件 OVF / bridge.drop —— 二者与主判据是否一致
|
||||
6 撕裂类计数器是否还有功效(rx_torn / pdo_reentry,恒零即"测不到"≠"没坏")
|
||||
7 主站错拍 skip_total 与达成率、cyc_us 占空
|
||||
8 CPU 两口径(GD32 load_perm / 主机 native)+ 进程 PID 稳定性
|
||||
|
||||
用法:
|
||||
python scripts/calibrate_ecat_metrics.py
|
||||
python scripts/calibrate_ecat_metrics.py --seconds 60 --json lab/ecat_calib.json
|
||||
python scripts/calibrate_ecat_metrics.py --no-start # 总线没起时不自动拉起
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from ecat_sweep_common import delta_saturate, delta_wrap, percentile, post
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_URL = "http://127.0.0.1:8090"
|
||||
|
||||
# 1.6.15 定长覆盖窗口槽布局(对应 ecat_native.c RXB_WIN_OFF_* / 固件 ECAT_RXW_OFF_*)
|
||||
WIN_SLOT_SIZE, WIN_SLOTS = 44, 5
|
||||
WIN_OFF_FLAGS, WIN_OFF_ID, WIN_OFF_SEQ, WIN_OFF_DLC = 2, 4, 8, 1
|
||||
FLAG_WRITTEN = 0x01
|
||||
|
||||
|
||||
def parse_win_slots(rawhex):
|
||||
"""从 rxraw.raw 解出每个槽的 (id, seq)(跳过 bit0=未写过的空槽)。
|
||||
|
||||
parsed 字段不带 seq,而 seq 是这个布局的丢帧判据,必须自己解。
|
||||
"""
|
||||
try:
|
||||
buf = bytes.fromhex(rawhex or "")
|
||||
except ValueError:
|
||||
return []
|
||||
out = []
|
||||
for k in range(WIN_SLOTS):
|
||||
o = k * WIN_SLOT_SIZE
|
||||
if o + WIN_SLOT_SIZE > len(buf):
|
||||
break
|
||||
if not buf[o + WIN_OFF_FLAGS] & FLAG_WRITTEN:
|
||||
continue
|
||||
out.append((int.from_bytes(buf[o + WIN_OFF_ID:o + WIN_OFF_ID + 4], "little"),
|
||||
int.from_bytes(buf[o + WIN_OFF_SEQ:o + WIN_OFF_SEQ + 4], "little")))
|
||||
return out
|
||||
|
||||
|
||||
def window_coverage(seqmap, slot_slots=WIN_SLOTS):
|
||||
"""覆盖窗口观测到的 seq 值域 / distinct seq 数(**描述性指标,不是丢帧判据**)。
|
||||
|
||||
曾用「值域 − distinct」当丢帧主判据,已被现场证伪:窗口只有 5 槽、HTTP 又只几
|
||||
Hz,值域内根本不可能观测到全部 seq,实测报出 99.686% "丢帧"而固件 OVF=0,自相
|
||||
矛盾 —— 它量的是**采样覆盖率**。真丢帧主判据是 bridge.drop(固件
|
||||
canfd.h:53 rx_drop_count,已实测确认为回绕计数器且增量与 rx_count 增量相等)。
|
||||
"""
|
||||
out = {}
|
||||
for fid, obs in seqmap.items():
|
||||
if not obs:
|
||||
continue
|
||||
lo, hi = min(obs), max(obs)
|
||||
out[fid] = {"n_obs": len(obs), "span": hi - lo + 1,
|
||||
"coverage_pct": round(len(obs) / (hi - lo + 1) * 100, 3)}
|
||||
return {"per_id": out, "ids": len(out),
|
||||
"note": "覆盖率仅说明观测密度,不可当丢帧率引用"}
|
||||
|
||||
|
||||
def cadence_rings(url):
|
||||
"""按拍采到的环形缓冲:每拍 count/lost/pending/wkc 的真实分布。
|
||||
|
||||
HTTP 轮询只有几 Hz,用它看"每拍几帧"是欠采样;cadence 环由 native 自己在
|
||||
每拍写,512 拍一段,才是每拍口径的真值。
|
||||
"""
|
||||
try:
|
||||
c = post(url, "/api/can/cadence")
|
||||
except Exception as e:
|
||||
return {"error": str(e)[:120]}
|
||||
cols, rows = c.get("cols") or [], c.get("rows") or []
|
||||
if not rows:
|
||||
return {"error": "环为空"}
|
||||
ix = {k: i for i, k in enumerate(cols)}
|
||||
def col(name):
|
||||
return [r[ix[name]] for r in rows] if name in ix else []
|
||||
out = {"n": len(rows), "period_ms": c.get("period_ms")}
|
||||
for name in ("count", "lost", "pending", "backlog", "wkc"):
|
||||
v = col(name)
|
||||
if v:
|
||||
out[name + "_hist"] = {str(k): n for k, n in sorted(collections.Counter(v).items())}
|
||||
out[name + "_max"] = max(v)
|
||||
span_ms = (col("t")[-1] - col("t")[0]) if len(rows) > 1 else 0
|
||||
out["span_ms"] = round(span_ms, 1)
|
||||
if span_ms > 0 and "rxcnt" in ix:
|
||||
out["lambda_per_s"] = round((col("rxcnt")[-1] - col("rxcnt")[0]) / (span_ms / 1000.0), 1)
|
||||
out["gseq_per_s"] = round((col("gseq")[-1] - col("gseq")[0]) / (span_ms / 1000.0), 1) \
|
||||
if "gseq" in ix else None
|
||||
return out
|
||||
|
||||
|
||||
def find_native_pid(url):
|
||||
"""反查占用该端口的进程 PID。PID 变了 = 换了实例 = 之前采的全部作废。"""
|
||||
try:
|
||||
out = subprocess.check_output(["netstat", "-ano", "-p", "TCP"],
|
||||
text=True, errors="replace")
|
||||
except Exception as e:
|
||||
return None, f"netstat 失败: {e}"
|
||||
port = url.rsplit(":", 1)[-1].strip("/")
|
||||
for line in out.splitlines():
|
||||
cols = line.split()
|
||||
if len(cols) >= 5 and cols[0] == "TCP" and cols[3] == "LISTENING" \
|
||||
and cols[1].endswith(":" + port):
|
||||
return int(cols[-1]), None
|
||||
return None, f"没人监听 :{port}"
|
||||
|
||||
|
||||
def firmware_identity():
|
||||
"""台账 sha256 对固件产物。
|
||||
|
||||
口径必须与烧写脚本一致:`lab/_last_gd32_sha.txt` 记的是 Intel-Hex **展平成
|
||||
APP 基址连续 blob 后**的 sha256(flash_gd32.py:91-92),不是 .hex 文件本身的哈希,
|
||||
产物也是 `.hex` 而非 `.bin`。按原始字节算或按 .bin 过滤会一个都扫不到,
|
||||
把"本可判定"误报成"无法判定"。
|
||||
|
||||
即便命中也只能说明"板上镜像与当前产物同源";产物比台账新时必然不等。
|
||||
**不能据此推断板上正在运行的版本** —— 那要设备回报版本字。
|
||||
"""
|
||||
shafile = os.path.join(REPO, "lab", "_last_gd32_sha.txt")
|
||||
rec = {"ledger": None, "ledger_path": shafile, "match": None,
|
||||
"candidates": [], "method": "hex_to_flat + sha256"}
|
||||
if not os.path.isfile(shafile):
|
||||
rec["error"] = "台账不存在"
|
||||
return rec
|
||||
with open(shafile, encoding="utf-8", errors="replace") as f:
|
||||
rec["ledger"] = f.read().strip()
|
||||
try:
|
||||
from flash_gd32 import hex_to_flat
|
||||
except Exception as e:
|
||||
rec["error"] = f"无法复用烧写脚本的展平算法({e})"
|
||||
return rec
|
||||
app = os.path.abspath(os.path.join(REPO, "..", "..", "DCU_APP", "out"))
|
||||
if not os.path.isdir(app):
|
||||
rec["error"] = f"固件产物目录不存在: {app}"
|
||||
return rec
|
||||
hexes = []
|
||||
for dirpath, _d, files in os.walk(app):
|
||||
hexes += [os.path.join(dirpath, fn) for fn in files if fn.endswith(".hex")]
|
||||
if not hexes:
|
||||
rec["error"] = "DCU_APP/out 下没有 .hex 产物"
|
||||
return rec
|
||||
for p in sorted(hexes):
|
||||
try:
|
||||
d = hashlib.sha256(hex_to_flat(p)).hexdigest()
|
||||
except (OSError, SystemExit, ValueError) as e:
|
||||
rec["candidates"].append({"path": p, "error": str(e)[:120]})
|
||||
continue
|
||||
rec["candidates"].append({"path": p, "sha256": d[:16], "mtime": int(os.path.getmtime(p))})
|
||||
if d == rec["ledger"]:
|
||||
rec["match"] = p
|
||||
rec["candidates"].sort(key=lambda c: -c.get("mtime", 0))
|
||||
rec["candidates"] = rec["candidates"][:5]
|
||||
return rec
|
||||
|
||||
|
||||
def ensure_loop(url, autostart, substr):
|
||||
st = post(url, "/api/motor/state")
|
||||
if st.get("ok") and st.get("cycles"):
|
||||
return st, "已在跑"
|
||||
if not autostart:
|
||||
return st, "循环未跑(--no-start)"
|
||||
op = post(url, "/api/open", {"substr": substr}, timeout=25.0)
|
||||
if not op.get("ok"):
|
||||
return op, "open 失败: %s" % (op.get("log") or [])
|
||||
ms = post(url, "/api/motor/start", {"period_ms": st.get("period_ms") or 1.0}, timeout=40.0)
|
||||
if not ms.get("ok"):
|
||||
return ms, "motor/start 失败(典型=SSC 邮箱楔死): %s" % (ms.get("log") or [])
|
||||
return post(url, "/api/motor/state"), "已自动 open+start"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="ECAT 指标现场标定")
|
||||
ap.add_argument("--url", default=DEFAULT_URL)
|
||||
ap.add_argument("--seconds", type=float, default=30.0)
|
||||
ap.add_argument("--hz", type=float, default=10.0,
|
||||
help="HTTP 采样频率。u16 的 drop 在 4000/s 下 16s 就绕一圈,"
|
||||
"采样间隔须远小于回绕周期")
|
||||
ap.add_argument("--no-start", action="store_true")
|
||||
ap.add_argument("--substr", default="I210")
|
||||
ap.add_argument("--json", default=None)
|
||||
a = ap.parse_args()
|
||||
|
||||
rep = {"url": a.url, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"seconds": a.seconds, "sample_hz": a.hz, "logical_cpus": os.cpu_count()}
|
||||
pid, perr = find_native_pid(a.url)
|
||||
rep["native_pid"], rep["native_pid_error"] = pid, perr
|
||||
rep["firmware"] = firmware_identity()
|
||||
rep["cadence"] = cadence_rings(a.url)
|
||||
|
||||
st, how = ensure_loop(a.url, not a.no_start, a.substr)
|
||||
rep["loop_start"] = {"how": how, "ok": bool(st.get("ok"))}
|
||||
if not st.get("ok"):
|
||||
print("总线起不来,标定终止:", how)
|
||||
return 2
|
||||
rep["period_ms_set"] = st.get("period_ms")
|
||||
rep["pcore_mask"] = st.get("pcore_mask")
|
||||
|
||||
proc = None
|
||||
try:
|
||||
import psutil
|
||||
for p in psutil.process_iter(["name", "pid"]):
|
||||
if p.info["pid"] == pid:
|
||||
proc = psutil.Process(pid)
|
||||
proc.cpu_percent(None)
|
||||
break
|
||||
rep["psutil"] = proc is not None
|
||||
except Exception as e:
|
||||
rep["psutil"] = f"不可用({e})"
|
||||
|
||||
interval = 1.0 / a.hz
|
||||
seqmap = collections.defaultdict(set)
|
||||
counts, loads, hc1, hctot, cyc_hz = collections.Counter(), [], [], [], []
|
||||
drop_rate, rx_rate, cyc_samples = [], [], []
|
||||
drop_untrusted = 0
|
||||
skip_first = skip_last = None
|
||||
torn_c = reentry_c = 0
|
||||
torn_cap = reentry_cap = False
|
||||
torn_prev = reentry_prev = None
|
||||
prev = None
|
||||
prev_t = None
|
||||
layout = collections.Counter()
|
||||
err = iters = 0
|
||||
t_end = time.perf_counter() + a.seconds
|
||||
while time.perf_counter() < t_end:
|
||||
tc = time.perf_counter()
|
||||
try:
|
||||
s = post(a.url, "/api/motor/state")
|
||||
L = post(a.url, "/api/app/load")
|
||||
R = post(a.url, "/api/motor/rxraw")
|
||||
D = post(a.url, "/api/motor/dbg")
|
||||
except Exception:
|
||||
err += 1
|
||||
time.sleep(interval)
|
||||
continue
|
||||
iters += 1
|
||||
b = s.get("bridge") or {}
|
||||
rb = s.get("rx_batch") or {}
|
||||
cur = {"cycles": s.get("cycles"), "drop": b.get("drop"), "rx": b.get("rx_count")}
|
||||
if prev is not None and prev_t is not None and tc > prev_t:
|
||||
dt = tc - prev_t
|
||||
if cur["cycles"] is not None and prev["cycles"] is not None:
|
||||
hz = (cur["cycles"] - prev["cycles"]) / dt
|
||||
if 0 < hz < 1e6:
|
||||
cyc_hz.append(hz)
|
||||
if cur["drop"] is not None and prev["drop"] is not None:
|
||||
d = delta_wrap(prev["drop"], cur["drop"], 16)
|
||||
# u16 回绕计数器:单次增量逼近模数一半即说明两次采样间已绕圈,
|
||||
# 该次不可信,只能丢弃并计数暴露采样率不足(真值下降同理)。
|
||||
if d < 32768:
|
||||
drop_rate.append(d / dt)
|
||||
else:
|
||||
drop_untrusted += 1
|
||||
if cur["rx"] is not None and prev["rx"] is not None:
|
||||
dr = delta_wrap(prev["rx"], cur["rx"], 32)
|
||||
if dr < (1 << 31):
|
||||
rx_rate.append(dr / dt)
|
||||
for fid, sq in parse_win_slots(R.get("raw")):
|
||||
seqmap[fid].add(sq)
|
||||
counts[rb.get("count")] += 1
|
||||
layout[R.get("layout")] += 1
|
||||
if L.get("valid") and L.get("load_perm") is not None:
|
||||
loads.append(L["load_perm"] / 10.0)
|
||||
if L.get("rx_torn") is not None:
|
||||
if torn_prev is not None:
|
||||
d, capped = delta_saturate(torn_prev, L["rx_torn"])
|
||||
torn_c += d
|
||||
torn_cap |= capped or L["rx_torn"] >= 65535
|
||||
torn_prev = L["rx_torn"]
|
||||
if L.get("pdo_reentry") is not None:
|
||||
if reentry_prev is not None:
|
||||
d, capped = delta_saturate(reentry_prev, L["pdo_reentry"])
|
||||
reentry_c += d
|
||||
reentry_cap |= capped or L["pdo_reentry"] >= 65535
|
||||
reentry_prev = L["pdo_reentry"]
|
||||
sk = D.get("skip_total")
|
||||
if sk is not None:
|
||||
if skip_first is None:
|
||||
skip_first = sk
|
||||
skip_last = sk
|
||||
if s.get("cyc_us"):
|
||||
cyc_samples.append(s["cyc_us"])
|
||||
if proc is not None:
|
||||
try:
|
||||
c = proc.cpu_percent(None)
|
||||
hc1.append(c)
|
||||
hctot.append(c / max(1, rep["logical_cpus"] or 1))
|
||||
except Exception:
|
||||
pass
|
||||
prev, prev_t = cur, tc
|
||||
time.sleep(max(0.0, interval - (time.perf_counter() - tc)))
|
||||
|
||||
dur = time.perf_counter() - (t_end - a.seconds)
|
||||
stf = post(a.url, "/api/motor/state")
|
||||
pid2, _ = find_native_pid(a.url)
|
||||
cad = rep["cadence"]
|
||||
|
||||
lam = percentile(rx_rate, 0.5) or cad.get("lambda_per_s") or 0
|
||||
dropr = percentile(drop_rate, 0.5) or 0
|
||||
rep["dur_s"] = round(dur, 2)
|
||||
rep["iters"] = iters
|
||||
rep["errors"] = err
|
||||
rep["drop_untrusted_samples"] = drop_untrusted
|
||||
rep["period_ms_effective"] = stf.get("period_ms")
|
||||
rep["wkc"] = [stf.get("wkc"), stf.get("expected_wkc")]
|
||||
rep["wr_end"] = stf.get("wr")
|
||||
rep["native_pid_end"] = pid2
|
||||
rep["pid_stable"] = (pid == pid2)
|
||||
rep["layout_seen"] = dict(layout)
|
||||
rep["rx_batch_count_hist_http"] = {str(k): v for k, v in sorted(counts.items(), key=lambda x: (x[0] is None, x[0]))}
|
||||
rep["window_coverage"] = window_coverage(seqmap)
|
||||
rep["can_lambda_per_s"] = round(lam, 1)
|
||||
rep["drop_per_s"] = round(dropr, 1)
|
||||
rep["drop_pct_of_lambda"] = round(dropr / lam * 100, 2) if lam else None
|
||||
rep["achieved_hz_p50"] = round(percentile(cyc_hz, 0.5) or 0, 1) if cyc_hz else None
|
||||
pm = rep["period_ms_effective"] or 1
|
||||
rep["achieve_pct"] = round((rep["achieved_hz_p50"] or 0) / (1000.0 / pm) * 100, 1)
|
||||
rep["skip_delta"] = (skip_last - skip_first) if (skip_first is not None and skip_last is not None) else None
|
||||
rep["skip_per_s"] = round((rep["skip_delta"] or 0) / dur, 2) if rep["skip_delta"] is not None else None
|
||||
rep["skip_total_cum"] = skip_last
|
||||
rep["rx_torn_delta"] = torn_c
|
||||
rep["rx_torn_capped"] = torn_cap
|
||||
rep["pdo_reentry_delta"] = reentry_c
|
||||
rep["pdo_reentry_capped"] = reentry_cap
|
||||
tot = [(c.get("total") or {}) for c in cyc_samples if c.get("total")]
|
||||
rep["cyc_us_total"] = {"p50": percentile([t.get("p50") for t in tot if t.get("p50") is not None], 0.5),
|
||||
"p90": percentile([t.get("p90") for t in tot if t.get("p90") is not None], 0.5),
|
||||
"max": max([t.get("max") or 0 for t in tot], default=None)}
|
||||
rep["cyc_us_late_lost_sample"] = tot[0] if tot else None
|
||||
rep["gd32_load_pct_p50"] = round(percentile(loads, 0.5) or 0, 1)
|
||||
rep["host_cpu_1core_p50"] = round(percentile(hc1, 0.5) or 0, 1)
|
||||
rep["host_cpu_total_p50"] = round(percentile(hctot, 0.5) or 0, 2)
|
||||
|
||||
cap = cad.get("count_max") or 0
|
||||
rep["per_cycle_cap_observed"] = cap
|
||||
rep["capacity_model"] = {
|
||||
"lambda_per_s": round(lam, 1), "per_cycle_cap": cap,
|
||||
"drain_per_s_at_current_T": round(cap / pm * 1000, 0) if pm else None,
|
||||
"knee_ms_if_linear": round(cap / lam * 1000, 3) if lam and cap else None,
|
||||
"note": "cap 取自 cadence 环实测 count 峰值;拐点以扫描实测为准,此值仅供事后对照"}
|
||||
rep["power"] = {
|
||||
"drop_rx_queue": dropr > 0,
|
||||
"skip_total": (rep["skip_delta"] or 0) > 0,
|
||||
"achieve_deficit": (rep["achieve_pct"] or 100) < 99.0,
|
||||
"rx_torn": torn_c > 0,
|
||||
"pdo_reentry": reentry_c > 0,
|
||||
"cadence_lost_or_pending": bool((cad.get("lost_max") or 0) or (cad.get("pending_max") or 0)),
|
||||
"cyc_us_late_lost": bool((rep["cyc_us_late_lost_sample"] or {}).get("late")
|
||||
or (rep["cyc_us_late_lost_sample"] or {}).get("lost"))}
|
||||
|
||||
print(f"=== Phase 0 标定 · {dur:.0f}s · HTTP 采样 {iters} 轮(错 {err}) @ {a.hz:g}Hz ===")
|
||||
print(f"native PID {pid} -> {pid2} 稳定={rep['pid_stable']} 钉核 mask={rep['pcore_mask']}")
|
||||
fw = rep["firmware"]
|
||||
print(f"固件台账 {str(fw.get('ledger'))[:16]}… 匹配={os.path.basename(fw['match']) if fw.get('match') else fw.get('error') or '无'}")
|
||||
print(f"周期 设定 {rep['period_ms_set']}ms -> 生效 {pm}ms 实测 {rep['achieved_hz_p50']}Hz 达成率 {rep['achieve_pct']}%")
|
||||
print(f"批量区布局 {dict(layout)}")
|
||||
print(f"每拍入窗帧数(cadence 环 {cad.get('n')}拍): count={cad.get('count_hist')} "
|
||||
f"lost={cad.get('lost_hist')} pending={cad.get('pending_hist')}")
|
||||
print(f" ↑ 每拍口径必须用环,HTTP 轮询看 count 是欠采样")
|
||||
print(f"【主判据】λ={lam:.0f}/s drop={dropr:.0f}/s 丢帧占 λ = {rep['drop_pct_of_lambda']}%"
|
||||
f" (drop=固件 canfd.h rx_drop_count,CAN RX 队列(kRxQueueDepth=16)满即丢)")
|
||||
if drop_untrusted:
|
||||
print(f" !! {drop_untrusted} 个采样点单次增量 >32768,说明已绕圈 -> 采样率不足,提高 --hz")
|
||||
print(f" 对账: cadence lost_max={cad.get('lost_max')} pending_max={cad.get('pending_max')}"
|
||||
f"(覆盖窗口下这两个旧计数器语义已改,仅供对账)")
|
||||
print(f" 窗口 seq 覆盖率(非丢帧率!): {rep['window_coverage']['per_id']}")
|
||||
print(f"rx_torn +{torn_c}(封顶={torn_cap}) pdo_reentry +{reentry_c}(封顶={reentry_cap})")
|
||||
print(f"主站错拍 skip_total Δ={rep['skip_delta']} = {rep['skip_per_s']}/s(累计 {rep['skip_total_cum']})")
|
||||
print(f"cyc_us.total p50={rep['cyc_us_total']['p50']} p90={rep['cyc_us_total']['p90']} "
|
||||
f"max={rep['cyc_us_total']['max']} -> 占空 {round((rep['cyc_us_total']['p50'] or 0)/(pm*1000)*100,1)}%")
|
||||
print(f"CPU: GD32 load_perm {rep['gd32_load_pct_p50']}% 主机 native 单核 {rep['host_cpu_1core_p50']}% "
|
||||
f"/ 整机 {rep['host_cpu_total_p50']}%(逻辑核 {rep['logical_cpus']})")
|
||||
print(f"wkc {rep['wkc']} wr={rep['wr_end']}")
|
||||
km = rep["capacity_model"]
|
||||
print(f"\n容量对照: cap={km['per_cycle_cap']} λ={km['lambda_per_s']:.0f} "
|
||||
f"当前档排空 {km['drain_per_s_at_current_T']} 线性外推拐点 {km['knee_ms_if_linear']}ms"
|
||||
f"(仅事后对照,拐点以扫描实测为准)")
|
||||
dead = [k for k, v in rep["power"].items() if not v]
|
||||
live = [k for k, v in rep["power"].items() if v]
|
||||
print("有功效:", live or "无")
|
||||
if dead:
|
||||
print(f"无功效: {dead}")
|
||||
print(" → 这些轴在报告里只能写「本次测不到」,**禁止**写成「无故障/已修好」")
|
||||
|
||||
if a.json:
|
||||
os.makedirs(os.path.dirname(os.path.abspath(a.json)), exist_ok=True)
|
||||
with open(a.json, "w", encoding="utf-8") as f:
|
||||
json.dump(rep, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n标定结果: {a.json}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""逐层对账 CAN→ECAT:谁在丢帧、丢多少、哪一层丢的。
|
||||
|
||||
动机:面板上的 `drop`(段1/段3"最近一帧"单格被未取先覆的帧数)恒等于 λ − 实测Hz,
|
||||
是个会自证的数,拿它说"丢帧"没有说服力。真丢帧要逐层各数一遍:
|
||||
|
||||
L1 CAN 控制器 err/TEC/REC —— ESP32 /can/feedback
|
||||
L2 CAN 软件队列 rx_drop_count(深度 16 满则丢) —— 只在 ESP32 侧有,过程数据里没有
|
||||
L3 ECAT 桥 bridge.rx_count(进桥帧数)
|
||||
L4 段4 覆盖窗口 s_rxWinSeq 每入窗帧 +2 → Δmax_seq/2 是**精确**的入窗帧数
|
||||
L5 主站取帧 rx_batch.lost(主站 seq 空洞) / pending(GD32 OVF) —— 单拍值,
|
||||
由本脚本按 cycle 去重后统计,仍是**下界**(轮询看不到每一拍)
|
||||
|
||||
L3−L4 就是 STATUS 帧数(STATUS 在入窗之前就 return,见固件
|
||||
ethercat_can_bridge.cpp:435),每节点 STATUS 率另用段2 槽 seq 步长反推
|
||||
(率 = λ/step,与轮询率无关)。
|
||||
|
||||
只读:全部走只读诊断路由,不发报、不改周期、不碰生命周期。
|
||||
用法: python scripts/can_bridge_reconcile.py [秒数=20] [esp32_ip=192.168.31.127]
|
||||
"""
|
||||
import json
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
# 控制台默认 GBK:一个字符编不出来就整轮数据白采,所以先把输出流换成 UTF-8
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
ECAT_PORTS = range(8090, 8096)
|
||||
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 20.0
|
||||
ESP_IP = sys.argv[2] if len(sys.argv) > 2 else "192.168.31.127"
|
||||
|
||||
# 段4 覆盖窗口(固件 1.6.15 定长布局):raw 从 PDO 偏移 364 起,5 槽 × 44B
|
||||
WIN_SLOT_SIZE = 44
|
||||
WIN_SLOTS = 5
|
||||
W_DLC, W_FLAGS, W_ID, W_SEQ = 1, 2, 4, 8
|
||||
|
||||
|
||||
def post(path, port, timeout=6.0):
|
||||
"""裸 socket + 连接阶段不设 timeout:给 urllib 传 timeout 会撞上 Windows
|
||||
非阻塞 connect 的 ~10ms 调度粒度,把仪表自己变成扰动源。"""
|
||||
body = b"{}"
|
||||
req = (f"POST {path} HTTP/1.0\r\nHost: {HOST}:{port}\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
f"Content-Length: {len(body)}\r\n\r\n").encode("ascii") + body
|
||||
s = socket.create_connection((HOST, port))
|
||||
try:
|
||||
s.settimeout(timeout)
|
||||
s.sendall(req)
|
||||
buf = bytearray()
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf.extend(c)
|
||||
finally:
|
||||
s.close()
|
||||
return json.loads(bytes(buf).partition(b"\r\n\r\n")[2].decode("utf-8", "replace"))
|
||||
|
||||
|
||||
def find_port():
|
||||
for p in ECAT_PORTS:
|
||||
try:
|
||||
if post("/api/motor/state", p, 2.0).get("ok"):
|
||||
return p
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def wrap(a, b, bits):
|
||||
return (b - a) % (1 << bits)
|
||||
|
||||
|
||||
def le32(bs, off):
|
||||
return int.from_bytes(bs[off:off + 4], "little")
|
||||
|
||||
|
||||
def can_ch(ip):
|
||||
"""一次读全部 CAN 通道,返回 {通道号: 通道 dict};读不到返回 None(绝不填 0)。
|
||||
|
||||
不能在这里挑"rx 最大的通道":ESP32 的 ID 快照是分页轮转的,开机初期某通道还没
|
||||
轮到 valid 时 rx=0,两次采样各挑各的就会挑到不同通道,差值直接变成整个累计值
|
||||
(实测虚报成 3.0M 帧/s)。通道由调用方跨两次采样锁定。"""
|
||||
try:
|
||||
with urllib.request.urlopen(f"http://{ip}/can/feedback", timeout=3) as r:
|
||||
d = json.loads(r.read().decode("utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
chs = {c.get("ch"): c for c in (d.get("channels") or []) if c.get("valid")}
|
||||
return chs or None
|
||||
|
||||
|
||||
def can_pick(a, b):
|
||||
"""两次采样都 valid 且 rx 最大的那个通道 —— 保证差分是同一计数器的两端。"""
|
||||
if not a or not b:
|
||||
return None
|
||||
common = [k for k in a if k in b]
|
||||
if not common:
|
||||
return None
|
||||
return max(common, key=lambda k: max(a[k].get("rx") or 0, b[k].get("rx") or 0))
|
||||
|
||||
|
||||
def win_slots(raw_hex):
|
||||
"""原始字节 → [(seq, id, dlc)],只收 flags.bit0=1 且 seq 为偶数(整槽一致)的槽。"""
|
||||
bs = bytes.fromhex(raw_hex)
|
||||
out = []
|
||||
for i in range(WIN_SLOTS):
|
||||
p = bs[i * WIN_SLOT_SIZE:(i + 1) * WIN_SLOT_SIZE]
|
||||
if len(p) < W_ID + 4 or (p[W_FLAGS] & 1) == 0:
|
||||
continue
|
||||
sq = le32(p, W_SEQ)
|
||||
if sq == 0 or sq & 1:
|
||||
continue
|
||||
out.append((sq, le32(p, W_ID), p[W_DLC]))
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
port = find_port()
|
||||
if not port:
|
||||
print("找不到在监听的 ECAT native(8090..8095)—— 主站没起,先连一次总线")
|
||||
return 1
|
||||
print(f"native :{port} 窗口 {DUR_S:.0f}s ESP32 {ESP_IP}")
|
||||
|
||||
seen_cycle = None
|
||||
seq_first = seq_last = None
|
||||
per_id = {}
|
||||
lost_max = pend_max = cnt_max = online = 0
|
||||
n_state = n_raw = 0
|
||||
t0 = time.perf_counter()
|
||||
c0 = can_ch(ESP_IP)
|
||||
s0 = post("/api/motor/state", port)
|
||||
while time.perf_counter() - t0 < DUR_S:
|
||||
st = post("/api/motor/state", port)
|
||||
n_state += 1
|
||||
rb = st.get("rx_batch") or {}
|
||||
online = max(online, sum(1 for m in (st.get("motors") or []) if m.get("online")))
|
||||
# 每拍回填清零的单拍值,只能取抽样最大值当下界
|
||||
lost_max = max(lost_max, rb.get("lost") or 0)
|
||||
pend_max = max(pend_max, rb.get("pending") or 0)
|
||||
cnt_max = max(cnt_max, rb.get("count") or 0)
|
||||
try:
|
||||
rr = post("/api/motor/rxraw", port)
|
||||
except Exception:
|
||||
rr = None
|
||||
if rr and rr.get("layout") != "win":
|
||||
print(f"段4 布局是 {rr.get('layout')!r},不是 1.6.15 覆盖窗口(win) —— 本脚本的解析器不适用")
|
||||
return 2
|
||||
# 注意:rxraw 的 count/lost/pending 是**旧布局偏移**(360/361/362)的原始字节,
|
||||
# 在 win 布局下分别是"窗口槽数常量""环形写游标 0..4""GD32 OVF"。只有第三个
|
||||
# 有意义,前两个拿来当丢帧数会得出"lost 最大 4、84% 的拍非 0"这种假结论。
|
||||
if rr and rr.get("cycle") != seen_cycle: # 按拍去重:同一拍重复读会重复计数
|
||||
seen_cycle = rr.get("cycle")
|
||||
n_raw += 1
|
||||
for sq, _id, _dlc in win_slots(rr.get("raw") or ""):
|
||||
seq_first = sq if seq_first is None else min(seq_first, sq)
|
||||
seq_last = sq if seq_last is None else max(seq_last, sq)
|
||||
per_id.setdefault(_id, set()).add(sq)
|
||||
t1 = time.perf_counter()
|
||||
time.sleep(0.05) # 约 20Hz:开环全速轮询会让仪表自己变成扰动源
|
||||
t1 = time.perf_counter()
|
||||
c1 = can_ch(ESP_IP)
|
||||
s1 = post("/api/motor/state", port)
|
||||
dt = t1 - t0
|
||||
|
||||
hz = wrap(s0.get("cycles", 0), s1.get("cycles", 0), 64) / dt
|
||||
lam = wrap((s0.get("head") or {}).get("global_seq", 0),
|
||||
(s1.get("head") or {}).get("global_seq", 0), 32) / dt
|
||||
bridge = wrap((s0.get("bridge") or {}).get("rx_count", 0),
|
||||
(s1.get("bridge") or {}).get("rx_count", 0), 32) / dt
|
||||
win_rate = ((seq_last - seq_first) / 2 / dt) if seq_last is not None else None
|
||||
got = sum(len(v) for v in per_id.values()) / dt
|
||||
drop_single = wrap((s0.get("bridge") or {}).get("drop", 0),
|
||||
(s1.get("bridge") or {}).get("drop", 0), 16) / dt
|
||||
|
||||
print(f"\n窗口 {dt:.2f}s 档位 {s1.get('period_ms')}ms PDO {hz:.0f}Hz "
|
||||
f"轮询 state {n_state} 次 / rxraw 新拍 {n_raw} 次"
|
||||
f"({n_raw / dt:.1f} 拍/s → 抽样比 {n_raw / max(1e-9, hz * dt):.1%})")
|
||||
print("\n层 计数 帧/s 判读")
|
||||
ck = can_pick(c0, c1)
|
||||
can_rate = None
|
||||
if ck is not None:
|
||||
rx0 = c0[ck].get("rx") or 0
|
||||
dr0 = c0[ck].get("drop") or 0
|
||||
rx1 = c1[ck].get("rx") or 0
|
||||
dr1 = c1[ck].get("drop") or 0
|
||||
err, tec, rec, bus = (c1[ck].get(k) for k in ("err", "tec", "rec", "bus_state"))
|
||||
can_rate = wrap(rx0, rx1, 32) / dt # rx / drop 都是 u32
|
||||
can_drop = wrap(dr0, dr1, 32) / dt
|
||||
print(f"L1 CAN err/TEC/REC/bus —— {err}/{tec}/{rec}/{bus}"
|
||||
f" {'干净' if not (err or tec or rec) else '有错'} (通道 {ck})")
|
||||
print(f"L2 CAN 软件队列满丢 {can_drop:10.0f} "
|
||||
f"{'0 = 队列(深度16)没满过' if not can_drop else '在丢帧,查从站取帧速率'}")
|
||||
print(f"L3 CAN 控制器收帧 {can_rate:10.0f}")
|
||||
else:
|
||||
print("L1/L2/L3 ESP32 /can/feedback 不可读 —— 不填 0,这三层本轮无数据")
|
||||
print(f"L3 ECAT桥 进桥 rx_count {bridge:10.0f}")
|
||||
if can_rate is not None:
|
||||
gap = can_rate - bridge
|
||||
print(f" CAN - 桥 {gap:10.0f} "
|
||||
f"{'≈0 两层同域' if abs(gap) < bridge * 0.01 else '差额:到了 CAN 但没进桥计数'}")
|
||||
print(f"L4 段4窗 入窗帧 Δseq/2 {win_rate if win_rate else float('nan'):10.0f}"
|
||||
f" 精确值(窗口 seq 是累计计数器,与抽样率无关)")
|
||||
print(f" STATUS(=桥−入窗) {bridge - (win_rate or 0):10.0f} "
|
||||
f"STATUS 在入窗前就 return,固件 ethercat_can_bridge.cpp:435")
|
||||
print(f" 本脚本见到的唯一入窗帧 {got:10.0f} 下界:轮询只覆盖 {n_state / max(1e-9, hz * dt):.1%} 的拍")
|
||||
print(f"L5 单拍值 主站空洞 lost 最大 {lost_max:10.0f} 每拍回填清零,只能当下界")
|
||||
print(f" GD32 OVF pending 最大 {pend_max:10.0f} 同上")
|
||||
print(f" 主站上报 count 最大 {cnt_max:10.0f} 单拍上报帧数(窗口 5 槽)")
|
||||
print(f"-- 段1/段3 单格 drop {drop_single:10.0f} "
|
||||
f"恒等于 λ−Hz = {lam - hz:.0f},不参与判丢帧")
|
||||
|
||||
if online:
|
||||
print(f"\n按节点拆(在线 {online} 个): STATUS ≈ {(bridge - (win_rate or 0)) / online:6.0f} 帧/s/节点,"
|
||||
f"入窗 ≈ {(win_rate or 0) / online:6.0f} 帧/s/节点")
|
||||
if per_id:
|
||||
print("\n入窗帧按 ID(唯一 seq 计数,下界):")
|
||||
for _id in sorted(per_id):
|
||||
n = len(per_id[_id])
|
||||
print(f" 0x{_id:08X}: {n / dt:8.0f} 帧/s 子码 0x{_id & 0xFFFF:04X}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,59 +0,0 @@
|
||||
param([int]$Seconds = 8)
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public static class W {
|
||||
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);
|
||||
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
|
||||
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string c, string t);
|
||||
}
|
||||
"@
|
||||
$SW_RESTORE = 9
|
||||
$SW_MINIMIZE = 6
|
||||
$win = $null
|
||||
Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -match 'ESP32' } | ForEach-Object {
|
||||
$win = $_; Write-Output ("found window: pid={0} title='{1}'" -f $_.Id, $_.MainWindowTitle)
|
||||
}
|
||||
if (-not $win) { Write-Output 'NO ESP32 WINDOW FOUND'; exit 1 }
|
||||
$hwnd = $win.MainWindowHandle
|
||||
Write-Output ("before: minimized=" + [W]::IsIconic($hwnd))
|
||||
|
||||
function Snap {
|
||||
$h = @{}
|
||||
foreach ($p in (Get-Process | Where-Object { $_.ProcessName -match 'msedgewebview2|ecat_native|pythonw' })) {
|
||||
try { $h[[string]$p.Id] = [pscustomobject]@{ name = $p.ProcessName; cpu = $p.CPU; ws = [math]::Round($p.WorkingSet64/1MB,0) } } catch {}
|
||||
}
|
||||
return $h
|
||||
}
|
||||
function Measure-Phase($label, $dt) {
|
||||
$a = Snap
|
||||
Start-Sleep -Seconds $dt
|
||||
$b = Snap
|
||||
$rows = foreach ($k in $b.Keys) {
|
||||
if ($a.ContainsKey($k)) {
|
||||
$d = ($b[$k].cpu - $a[$k].cpu)
|
||||
if ($d -gt 0) {
|
||||
[pscustomobject]@{ Phase = $label; Pid = [int]$k; Name = $b[$k].name; Pct1Core = [math]::Round($d/$dt*100,1); WS_MB = $b[$k].ws }
|
||||
}
|
||||
}
|
||||
}
|
||||
return $rows
|
||||
}
|
||||
|
||||
$r1 = Measure-Phase 'visible' $Seconds
|
||||
Write-Output '--- minimize ---'
|
||||
[void][W]::ShowWindow($hwnd, $SW_MINIMIZE)
|
||||
Start-Sleep -Seconds 2
|
||||
$r2 = Measure-Phase 'minimized' $Seconds
|
||||
Write-Output '--- restore ---'
|
||||
[void][W]::ShowWindow($hwnd, $SW_RESTORE)
|
||||
Start-Sleep -Seconds 2
|
||||
$r3 = Measure-Phase 'restored' $Seconds
|
||||
|
||||
$all = @() + $r1 + $r2 + $r3
|
||||
$out = $all | Sort-Object Phase, Pct1Core -Descending
|
||||
$out | Format-Table -AutoSize | Out-String -Width 200 | Write-Output
|
||||
foreach ($ph in @('visible','minimized','restored')) {
|
||||
$s = ($out | Where-Object { $_.Phase -eq $ph } | Measure-Object -Property Pct1Core -Sum).Sum
|
||||
Write-Output ("TOTAL {0,-10} = {1,7:N1} % of one core" -f $ph, $s)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$cpus = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
|
||||
Write-Output "logical_cpus=$cpus"
|
||||
$pat = 'msedgewebview2|ecat_native|pythonw|python'
|
||||
function Snap {
|
||||
$h = @{}
|
||||
foreach ($p in (Get-Process | Where-Object { $_.ProcessName -match $pat })) {
|
||||
$h[[string]$p.Id] = [pscustomobject]@{ name = $p.ProcessName; cpu = $p.CPU; ws = $p.WorkingSet64 }
|
||||
}
|
||||
return $h
|
||||
}
|
||||
$a = Snap
|
||||
Start-Sleep -Seconds 8
|
||||
$b = Snap
|
||||
$dt = 8
|
||||
$rows = foreach ($k in $b.Keys) {
|
||||
if ($a.ContainsKey($k)) {
|
||||
$d = ($b[$k].cpu - $a[$k].cpu)
|
||||
if ($d -gt 0) {
|
||||
[pscustomobject]@{
|
||||
pid = [int]$k
|
||||
name = $b[$k].name
|
||||
pct = [math]::Round($d / $dt * 100, 1)
|
||||
wsMB = [math]::Round($b[$k].ws / 1MB, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$rows | Where-Object { $_.pct -ge 0.3 } | Sort-Object pct -Descending | Format-Table -AutoSize | Out-String -Width 200 | Write-Output
|
||||
$total = ($rows | Measure-Object -Property pct -Sum).Sum
|
||||
Write-Output ("total_pct_of_one_core=" + [math]::Round($total,1))
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ECAT native 主站的采样公共件:HTTP 调用 + 计数器差分。
|
||||
|
||||
固件 1.6.15 覆盖窗口布局下,各计数器的口径并不一致,混用会静默算错:
|
||||
* bridge.rx_count / head.global_seq —— u32,**回绕**
|
||||
* bridge.drop —— u16,**回绕**;段 1/段 3 单格"未取先覆"数,恒等于 λ − 实测Hz
|
||||
* rx_batch.lost(主站按 seq 空洞算的单拍丢帧) / pending(从站窗口 OVF) ——
|
||||
**每拍回填清零的单拍值**(ecat_native.c:461),不是累计计数:拿它做差得速率恒为 0,
|
||||
拿轮询见过的最大值当总丢帧也只是下界
|
||||
* app/load.rx_torn / pdo_reentry —— u16,**饱和**(到 65535 就停,不是回绕)
|
||||
饱和量按回绕算会把"已封顶"误报成"发生了 6 万多次",故二者分开处理。
|
||||
"""
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
SATURATE = 65535 # u16 饱和计数的顶
|
||||
|
||||
|
||||
def post(url, path, body=None, timeout=6.0):
|
||||
req = urllib.request.Request(
|
||||
url + path, data=json.dumps(body or {}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
|
||||
|
||||
def delta_wrap(a, b, bits=16):
|
||||
"""回绕计数器的增量。真值下降(重启清零)会表现为一个巨大的正数,
|
||||
调用方须用 sanity 上界剔除 —— 这里只负责把模数算对。"""
|
||||
m = 1 << bits
|
||||
return (b - a) % m
|
||||
|
||||
|
||||
def delta_saturate(a, b):
|
||||
"""饱和计数的增量。返回 (增量, 是否已封顶)。
|
||||
|
||||
封顶后增量恒为 0 —— 读数 0 既可能是"真没发生"也可能是"早发生了但计不动",
|
||||
报告里必须写成下界而不是精确值。
|
||||
"""
|
||||
if b >= SATURATE:
|
||||
return 0, True
|
||||
if b < a:
|
||||
return 0, False # 计数器被回填清零:该窗口作废,不臆造增量
|
||||
return b - a, False
|
||||
|
||||
|
||||
def percentile(values, q):
|
||||
"""小样本分位数(最近邻)。不引 numpy,采样窗口只有百来个点。"""
|
||||
if not values:
|
||||
return None
|
||||
s = sorted(values)
|
||||
i = min(len(s) - 1, max(0, int(round(q * (len(s) - 1)))))
|
||||
return s[i]
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""GD32 固件经 ESP32 中继刷写(tools/flash_gd32.py 删除后的等价实现)。
|
||||
|
||||
流程(与 ESP32 侧 http_handlers.cpp 的 ota_upload_handler /
|
||||
ota_relay_trigger_handler 契约一致):
|
||||
1. Release 的 Intel HEX 展平成从 0x08048000 起的连续 BIN(间隙填 0xFF);
|
||||
2. POST /ota/upload,请求体 = {"total_size":N} 紧跟 BIN 字节;
|
||||
3. POST /ota/relay,请求体 = {"sha256":"<该 BIN 的 sha256>"};设备侧还会做
|
||||
包身份防呆(复位向量必须落在 APP 区,误传 BOOT 会被拒);
|
||||
4. 轮询 /system/status 直到 ota.sys_state=GD32_COMPLETE 且 ota.gd32_app 是
|
||||
期望版本;**并且必须拿到"GD32 真的在跑新代码"的证据**:版本号相对刷前变了,
|
||||
或 gd32_adc.uptime_sec 归零(复位过)。两者都没有 → 判"未生效",不写台账、
|
||||
非零退出。(实测踩过:01:44 刷进 1.6.22 后板子又跑了 2 小时老镜像,
|
||||
布局/耗时全程不变,台账却已更新 —— 写入 ≠ 在跑。)
|
||||
5. 把该 BIN 的 sha256 写进 lab/_last_gd32_sha.txt(板上镜像台账)。
|
||||
|
||||
用法: python scripts/flash_gd32.py [期望版本 如 1.6.5.0]
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
ESP = os.environ.get("DCU_ESP_IP", "192.168.31.127")
|
||||
APP_BASE = 0x08048000
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
|
||||
HEX = os.path.join(ROOT, "DCU_APP", "out", "GD32E508_DCU_APP", "GD32E508VE",
|
||||
"Release", "GD32E508_DCU_APP.hex")
|
||||
LEDGER = os.path.join(HERE, "..", "lab", "_last_gd32_sha.txt")
|
||||
|
||||
|
||||
def hex_to_flat(path):
|
||||
"""Intel HEX → 从 APP_BASE 起的连续字节。间隙填 0xFF(擦除态)。"""
|
||||
mem = {}
|
||||
base = 0
|
||||
with open(path, "r", encoding="ascii", errors="ignore") as f:
|
||||
for ln, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line.startswith(":"):
|
||||
continue
|
||||
raw = bytes.fromhex(line[1:])
|
||||
cnt, addr, rtype = raw[0], struct.unpack(">H", raw[1:3])[0], raw[3]
|
||||
data = raw[4:4 + cnt]
|
||||
if len(raw) != cnt + 5 or (sum(raw) & 0xFF) != 0:
|
||||
raise SystemExit(f"{path}:{ln} 校验和不对")
|
||||
if rtype == 0x00:
|
||||
for i, b in enumerate(data):
|
||||
mem[base + addr + i] = b
|
||||
elif rtype == 0x02:
|
||||
base = struct.unpack(">H", data[:2])[0] << 4
|
||||
elif rtype == 0x04:
|
||||
base = struct.unpack(">H", data[:2])[0] << 16
|
||||
elif rtype == 0x01:
|
||||
break
|
||||
if not mem:
|
||||
raise SystemExit("HEX 里没有数据记录")
|
||||
lo, hi = min(mem), max(mem)
|
||||
if lo > APP_BASE:
|
||||
raise SystemExit(f"镜像起点 0x{lo:08X} 高于 APP 基址 0x{APP_BASE:08X},不是 APP 包")
|
||||
if lo < APP_BASE:
|
||||
raise SystemExit(f"镜像含 0x{lo:08X} 的地址,落在 APP 区之外(疑似 BOOT 包)")
|
||||
out = bytearray(b"\xFF" * (hi - APP_BASE + 1))
|
||||
for a, b in mem.items():
|
||||
out[a - APP_BASE] = b
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def post(path, body, timeout=60):
|
||||
req = urllib.request.Request(f"http://{ESP}{path}", data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/octet-stream")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.status, r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", "replace")
|
||||
|
||||
|
||||
def get(path, timeout=10):
|
||||
with urllib.request.urlopen(f"http://{ESP}{path}", timeout=timeout) as r:
|
||||
return json.loads(r.read().decode("utf-8", "replace"))
|
||||
|
||||
|
||||
def main():
|
||||
want = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
if not os.path.isfile(HEX):
|
||||
raise SystemExit(f"找不到 {HEX}")
|
||||
blob = hex_to_flat(HEX)
|
||||
sha = hashlib.sha256(blob).hexdigest()
|
||||
print(f"镜像 {HEX}\n 展平 {len(blob)}B @0x{APP_BASE:08X} sha256={sha}")
|
||||
|
||||
hdr = json.dumps({"total_size": len(blob)}).encode("ascii")
|
||||
b_ver, b_up = "", None
|
||||
try:
|
||||
b = get("/system/status")
|
||||
b_ver = str((b.get("ota") or {}).get("gd32_app") or "").lstrip("v")
|
||||
b_up = (b.get("gd32_adc") or {}).get("uptime_sec")
|
||||
except Exception as e:
|
||||
print(f"刷前 /system/status 读失败({type(e).__name__}):生效判定只能靠 uptime")
|
||||
print(f"刷前: gd32_app=v{b_ver or '?'} gd32 uptime={b_up}")
|
||||
t0 = time.time()
|
||||
st, body = post("/ota/upload", hdr + blob, timeout=120)
|
||||
print(f"upload -> {st} {body[:200]} ({time.time()-t0:.1f}s)")
|
||||
if st != 200:
|
||||
raise SystemExit("上传失败")
|
||||
|
||||
t0 = time.time()
|
||||
st, body = post("/ota/relay", json.dumps({"sha256": sha}).encode("ascii"), 60)
|
||||
print(f"relay -> {st} {body[:300]} ({time.time()-t0:.1f}s)")
|
||||
|
||||
deadline = time.time() + 90
|
||||
last = ""
|
||||
while time.time() < deadline:
|
||||
time.sleep(2)
|
||||
try:
|
||||
s = get("/system/status")
|
||||
except Exception as e:
|
||||
print(f" 等待中… status 读失败 {type(e).__name__}")
|
||||
continue
|
||||
o = s.get("ota", {})
|
||||
line = (f" sys_state={o.get('sys_state')} gd32_app={o.get('gd32_app')} "
|
||||
f"alive={o.get('gd32_app_alive')} uptime={s.get('uptime_sec')}")
|
||||
if line != last:
|
||||
print(line)
|
||||
last = line
|
||||
if o.get("sys_state") == "GD32_COMPLETE" and o.get("gd32_app"):
|
||||
ver = str(o["gd32_app"]).lstrip("v")
|
||||
a_up = (s.get("gd32_adc") or {}).get("uptime_sec")
|
||||
if want and ver != want:
|
||||
raise SystemExit(f"板上版本 {o['gd32_app']} 与期望 {want} 不符 —— 刷写未生效")
|
||||
changed = bool(b_ver) and ver != b_ver
|
||||
rebooted = a_up is not None and (b_up is None or a_up < b_up)
|
||||
if not changed and not rebooted:
|
||||
raise SystemExit(
|
||||
f"写入完成,但没有 GD32 换跑新代码的证据:板上仍是 v{ver}、"
|
||||
f"uptime={a_up}(刷前 {b_up})—— 老镜像还在跑,台账不更新。"
|
||||
"断电重启(或复位 APP)后重跑本脚本再确认")
|
||||
with open(LEDGER, "w", encoding="ascii") as f:
|
||||
f.write(sha)
|
||||
print(f"完成: 板上 gd32_app=v{ver}(依据: "
|
||||
f"{'版本 ' + b_ver + '→' + ver if changed else 'uptime 归零 ' + str(b_up) + '→' + str(a_up)}"
|
||||
f"),台账已写 {os.path.normpath(LEDGER)}")
|
||||
return
|
||||
raise SystemExit("90s 内未见 GD32_COMPLETE —— 刷写未完成,别继续测试")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,112 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""对比「带 CAN」与「拔掉 CAN」两轮扫描,分离每拍负担的组成。
|
||||
|
||||
用途:判断 ECAT 速度上限究竟由什么决定 —— 是每拍都要跑的过程数据交换
|
||||
(PDI_Isr / PDO 填充,与 CAN 无关),还是叠加在上面的 CAN 收发处理。
|
||||
|
||||
判读:两条 load 曲线的**垂直间距**就是 CAN 在每个周期档上贡献的负担。
|
||||
间距近似恒定 → CAN 负担与周期无关,上限由纯 ECAT 那条线单独决定;
|
||||
间距随频率张开 → CAN 负担本身也按拍放大,提速会被 CAN 先拖垮。
|
||||
|
||||
用法:
|
||||
python scripts/plot_ecat_nocan_vs_can.py \
|
||||
--nocan lab/ecat_rate_sweep_nocan.csv --withcan lab/ecat_rate_sweep_withcan.csv
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib import rcParams
|
||||
|
||||
rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
|
||||
rcParams["axes.unicode_minus"] = False
|
||||
rcParams["figure.dpi"] = 130
|
||||
|
||||
|
||||
def load(path):
|
||||
out = {}
|
||||
for r in csv.DictReader(open(path, encoding="utf-8")):
|
||||
def num(k):
|
||||
v = r.get(k, "")
|
||||
try:
|
||||
return float(v) if v not in ("", "None", None) else None
|
||||
except ValueError:
|
||||
return None
|
||||
out[float(r["period_ms"])] = {
|
||||
"load": num("gd32_load_pct"), "hz": num("measured_hz"),
|
||||
"achieve": num("achieve_pct"), "skip": num("skip_per_s"),
|
||||
"wkc_ok": r.get("wkc") == r.get("expected_wkc"),
|
||||
"stale": num("gd32_load_stale")}
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="带 CAN vs 无 CAN 每拍负担分离")
|
||||
d = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
ap.add_argument("--nocan", default=os.path.join(d, "lab", "ecat_rate_sweep_nocan.csv"))
|
||||
ap.add_argument("--withcan", default=os.path.join(d, "lab", "ecat_rate_sweep_withcan.csv"))
|
||||
ap.add_argument("--out", default=None)
|
||||
a = ap.parse_args()
|
||||
|
||||
no, wc = load(a.nocan), load(a.withcan)
|
||||
common = sorted(set(no) & set(wc), reverse=True)
|
||||
out = a.out or os.path.splitext(a.nocan)[0] + "_vs.png"
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13.0, 5.2))
|
||||
fig.suptitle("每拍负担分离:拔掉 CAN 之后,速度上限由谁决定", fontsize=13)
|
||||
|
||||
# 只取两侧 wkc 都达预期的档位:从站停报后 load_perm 会保留**残值**
|
||||
# (实测楔死后仍报 99.2%),留在图里会被当成真数据、把 Δ 趋势带歪。
|
||||
healthy = [t for t in common if no[t]["wkc_ok"] and wc[t]["wkc_ok"]]
|
||||
dropped = [t for t in common if t not in healthy]
|
||||
T = [t for t in healthy if no[t]["load"] is not None and wc[t]["load"] is not None]
|
||||
if not T:
|
||||
raise SystemExit("两文件没有可对比的公共档位(且均需有效负载读数)")
|
||||
Hz = [1000.0 / t for t in T]
|
||||
ax1.plot(Hz, [wc[t]["load"] for t in T], "o-", color="#d62728", lw=2,
|
||||
label="带 CAN(电机在报 STATUS/CONFIG)")
|
||||
ax1.plot(Hz, [no[t]["load"] for t in T], "s-", color="#1f77b4", lw=2,
|
||||
label="拔掉全部 CAN 设备")
|
||||
for t, h in zip(T, Hz):
|
||||
ax1.annotate(f"Δ={wc[t]['load'] - no[t]['load']:.1f}pp", (h, no[t]["load"]),
|
||||
textcoords="offset points", xytext=(0, -14), ha="center", fontsize=8)
|
||||
ax1.axhline(100, ls=":", color="k", lw=1)
|
||||
ax1.set_xscale("log"); ax1.set_ylim(0, 112)
|
||||
ax1.set_xlabel("循环率 (Hz)"); ax1.set_ylabel("load_perm (%)")
|
||||
ax1.set_title("从站每拍负担(load_perm 口径,非整机 CPU)")
|
||||
ax1.legend(fontsize=8, loc="upper left"); ax1.grid(alpha=0.3)
|
||||
|
||||
ax2.plot(Hz, [wc[t]["skip"] for t in T], "o-", color="#d62728", lw=2, label="带 CAN")
|
||||
ax2.plot(Hz, [no[t]["skip"] for t in T], "s-", color="#1f77b4", lw=2, label="无 CAN")
|
||||
ax2.set_xscale("log"); ax2.set_yscale("symlog", linthresh=1)
|
||||
ax2.set_xlabel("循环率 (Hz)"); ax2.set_ylabel("skip_total (次/s)")
|
||||
ax2.set_title("主站错拍率:CAN 有无对节拍精度的影响")
|
||||
ax2.legend(fontsize=8, loc="upper left"); ax2.grid(alpha=0.3)
|
||||
|
||||
fig.tight_layout(rect=(0, 0, 1, 0.94))
|
||||
fig.savefig(out, bbox_inches="tight")
|
||||
print(f"对比图 -> {out}\n")
|
||||
print(f"{'周期':>7} {'循环率':>8} {'带CAN':>8} {'无CAN':>8} {'Δpp':>7} {'带CAN skip':>11} {'无CAN skip':>11}")
|
||||
for t in T:
|
||||
print(f"{t:>6}ms {1000.0/t:>7.0f}Hz {wc[t]['load']:>7.1f}% {no[t]['load']:>7.1f}% "
|
||||
f"{wc[t]['load']-no[t]['load']:>6.1f} {wc[t]['skip']:>10.2f} {no[t]['skip']:>10.2f}")
|
||||
skipped = [t for t in common if t not in T]
|
||||
if skipped:
|
||||
print(f"\n排除的档位:{skipped}")
|
||||
for t in skipped:
|
||||
why = []
|
||||
if not no[t]["wkc_ok"]:
|
||||
why.append("无CAN 侧 wkc 不达预期(从站已停报,load_perm 为残值)")
|
||||
if not wc[t]["wkc_ok"]:
|
||||
why.append("带CAN 侧 wkc 不达预期")
|
||||
if no[t]["load"] is None or wc[t]["load"] is None:
|
||||
why.append("缺有效负载读数")
|
||||
print(f" {t}ms: " + "; ".join(why or ["未知"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把 sweep_ecat_rate.py 的 CSV 画成「速度-故障率-CPU」四联图。
|
||||
|
||||
画法纪律(对应调研报告的判据要求):
|
||||
* 先画实测点/折线,参考模型只在事后叠加,且线型与实测明显区分 —— 避免拿模型
|
||||
冒充数据。容量参考线用的是**本次实测**的每拍入窗帧数,不是源码常量。
|
||||
* 无功效的轴(整段恒零)不画成"平坦的零线" —— 那会被读成"没问题"。改为在子图
|
||||
里写明"本次测不到",并保留轴刻度供对比。
|
||||
* 横轴用频率(=1000/周期)而不是周期:物理上限在时间轴上是反比双曲线,
|
||||
在频率轴上是直线,读者不会看错方向。
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import sys
|
||||
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib import rcParams
|
||||
|
||||
rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
|
||||
rcParams["axes.unicode_minus"] = False
|
||||
rcParams["figure.dpi"] = 130
|
||||
|
||||
MX = MY = []
|
||||
DEFAULT_CSV = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"lab", "ecat_rate_sweep.csv")
|
||||
|
||||
|
||||
def load(path):
|
||||
rows = list(csv.DictReader(open(path, encoding="utf-8")))
|
||||
if not rows:
|
||||
raise SystemExit(f"{path} 没有数据行")
|
||||
for r in rows:
|
||||
r["hz_set"] = 1000.0 / float(r["period_ms"])
|
||||
for k in ("measured_hz", "achieve_pct", "can_lambda_per_s", "drop_per_s",
|
||||
"drop_pct_of_lambda", "skip_per_s", "torn_per_s", "reentry_per_s",
|
||||
"gd32_load_pct", "host_cpu_1core_pct", "cyc_p50_us", "cyc_p90_us",
|
||||
"cyc_duty_pct", "lambda_u16_crosscheck", "rxb_count_mode_per_cycle"):
|
||||
v = r.get(k, "") # 旧 CSV 可能没有后加的列
|
||||
try:
|
||||
r[k] = float(v) if v not in ("", "None", None) else None
|
||||
except ValueError:
|
||||
r[k] = None
|
||||
# CPU / 丢帧曲线的真正自变量是**实测**循环率:快档未必设得到设定值,
|
||||
# 拿设定周期当横轴会把点画到错的位置上。
|
||||
r["hz"] = r["measured_hz"] or r["hz_set"]
|
||||
rows.sort(key=lambda r: r["hz"])
|
||||
return rows
|
||||
|
||||
|
||||
def ys(rows, key):
|
||||
"""取该列的 y 值序列;x 一律用 xs()。col() 曾按 (y, x) 顺序返回,调用处按
|
||||
(x, y) 解包会把 y 当横轴画(实测把 CPU% 画成了 2–100Hz),故改名并各自独立。"""
|
||||
return [r[key] for r in rows]
|
||||
|
||||
|
||||
def xs(rows):
|
||||
return [r["hz"] for r in rows]
|
||||
|
||||
|
||||
def no_power(rows, key):
|
||||
"""该轴整段恒零 → 无检出功效,不能画成"平坦的零线"。"""
|
||||
vals = [r[key] for r in rows if r[key] is not None]
|
||||
return (not vals) or max(vals) == 0
|
||||
|
||||
|
||||
def annotate_flags(ax, rows):
|
||||
"""把采样作废/劫持/PID 易主这些不可信标记写在图边上,避免读者信了坏数据。"""
|
||||
bad = []
|
||||
for r in rows:
|
||||
if r.get("hijacked") not in ("no", None):
|
||||
bad.append(f"{r['period_ms']}ms 周期被劫持")
|
||||
if r.get("pid_stable") != "yes":
|
||||
bad.append(f"{r['period_ms']}ms native PID 易主")
|
||||
if int(r.get("drop_untrusted") or 0) > 0:
|
||||
bad.append(f"{r['period_ms']}ms drop 有 {r['drop_untrusted']} 点绕圈作废")
|
||||
if bad:
|
||||
ax.set_xlabel(ax.get_xlabel() + " ⚠ " + "; ".join(bad[:3]), color="darkred")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="画 ECAT 速度-故障率-CPU 曲线")
|
||||
ap.add_argument("--csv", default=DEFAULT_CSV)
|
||||
ap.add_argument("--out", default=None, help="输出 PNG(默认与 CSV 同名 .png)")
|
||||
a = ap.parse_args()
|
||||
|
||||
rows = load(a.csv)
|
||||
out = a.out or os.path.splitext(a.csv)[0] + ".png"
|
||||
# 电机槽 seq 前进速率:扫描后另用 8s/档定点实测(判据见报告"方法"节)。
|
||||
# 单独测是因为它要看的是 u32 槽 seq 的秒增量,与扫描的 60s 平均口径不同源。
|
||||
global MX, MY
|
||||
MX = [1000.0, 1999.0, 3989.0, 7762.0]
|
||||
MY = [4000.2, 4000.2, 87.9, 147.5]
|
||||
fig, axes = plt.subplots(2, 2, figsize=(13.5, 9.2))
|
||||
fig.suptitle("EtherCAT 周期速度 × 故障率 × CPU 占用(实测)", fontsize=13, y=0.985)
|
||||
|
||||
# ---- 1 速度达成:设定 vs 实测 ----
|
||||
ax = axes[0][0]
|
||||
xset = [r["hz_set"] for r in rows]
|
||||
ax.plot([min(xset), max(xset)], [min(xset), max(xset)], "k--", lw=1,
|
||||
label="理想 1:1(设定率)")
|
||||
ax.plot(xset, [r["measured_hz"] for r in rows], "o-", color="#1f77b4", label="实测达成")
|
||||
ax.set_xscale("log"); ax.set_yscale("log")
|
||||
ax.set_xlabel("设定循环率 (Hz)"); ax.set_ylabel("实测循环率 (Hz)")
|
||||
ax.set_title("① 循环率:设定 vs 实测(主站侧)"); ax.legend(fontsize=8, loc="upper left")
|
||||
ax.grid(alpha=0.3, which="both")
|
||||
txt = "达成率 " + "\n".join(
|
||||
f"{r['period_ms']}ms: {r['achieve_pct']:.1f}%" for r in rows if r["achieve_pct"])
|
||||
ax.annotate(txt, (0.98, 0.5), xycoords="axes fraction", fontsize=7.5,
|
||||
ha="right", va="center")
|
||||
|
||||
# ---- 2 CPU 占用(两路:从站负载 vs 主机自旋)----
|
||||
ax = axes[0][1]
|
||||
X = xs(rows)
|
||||
gy, hy = ys(rows, "gd32_load_pct"), ys(rows, "host_cpu_1core_pct")
|
||||
ax.plot(X, gy, "s-", color="#d62728", lw=2, label="GD32 负载 load_perm")
|
||||
ax.plot(X, hy, "^-", color="#7f7f7f", lw=1.4, label="主机 native(自旋,单核%)")
|
||||
ax.axhline(95, ls=":", color="darkred", lw=1)
|
||||
ax.annotate("95% 逼近饱和", (1.02, 95), fontsize=7.5, color="darkred", va="bottom")
|
||||
ax.set_xscale("log"); ax.set_ylim(0, 112)
|
||||
ax.set_xlabel("循环率 (Hz)"); ax.set_ylabel("CPU 占用 (%)")
|
||||
ax.set_title("② CPU 占用:GD32 与主机 native 两路")
|
||||
ax.legend(fontsize=8, loc="center left"); ax.grid(alpha=0.3)
|
||||
sat = [r for r in rows if r["gd32_load_pct"] is not None and r["gd32_load_pct"] >= 95]
|
||||
if sat:
|
||||
ax.annotate(f"≥95% 的档位: {', '.join(str(r['period_ms'])+'ms' for r in sat)}",
|
||||
(0.02, 0.04), xycoords="axes fraction", fontsize=7.5, color="darkred")
|
||||
|
||||
# ---- 3 故障率主曲线:功能是否还活着(电机数据刷新率)vs 从站负载 ----
|
||||
# 横轴用对数;λ 与 drop 两条线**刻意画在一起**,用来展示"加速对丢帧无改善"
|
||||
ax = axes[1][0]
|
||||
ax.plot(X, ys(rows, "can_lambda_per_s"), "o-", color="#2ca02c", lw=2,
|
||||
label="λ CAN 到帧(u32 gseq)")
|
||||
ax.plot(X, ys(rows, "drop_per_s"), "v--", color="#e377c2", lw=2,
|
||||
label="drop 队列满丢帧(与 λ 重合)")
|
||||
ax.plot(MX, MY, "^-", color="#d62728", lw=2.2,
|
||||
label="电机数据刷新率(真故障轴)")
|
||||
ax.axvspan(2000, 4000, color="red", alpha=0.07)
|
||||
ax.annotate("GD32 饱和 → 功能塌陷", (2550, 40), fontsize=8, color="darkred")
|
||||
ax.set_xscale("log"); ax.set_yscale("log") # 帧/s 恒为正,用 symlog 会把轴拖到负值
|
||||
ax.set_ylim(10, 20000)
|
||||
ax.set_xlabel("实测循环率 (Hz)"); ax.set_ylabel("帧/s")
|
||||
ax.set_title("③ 故障率:加速对丢帧毫无改善,且 >2kHz 直接打断功能")
|
||||
ax.grid(alpha=0.3, which="both")
|
||||
ax.legend(fontsize=7.5, loc="lower left")
|
||||
|
||||
# ---- 4 错拍率 + 撕裂/重入功效声明 ----
|
||||
ax = axes[1][1]
|
||||
ax.plot(X, ys(rows, "skip_per_s"), "D-", color="#ff7f0e", lw=2,
|
||||
label="主站错拍 skip_total(次/s)")
|
||||
ax.set_xscale("log"); ax.set_yscale("symlog", linthresh=1) # skip 有真 0 档,需 symlog
|
||||
ax.set_xlabel("循环率 (Hz)"); ax.set_ylabel("次/s")
|
||||
ax.set_ylim(-10, None)
|
||||
ax.set_title("④ 主站错拍率,及撕裂/重入轴功效声明"); ax.legend(fontsize=8, loc="upper left")
|
||||
ax.grid(alpha=0.3)
|
||||
dead = [n for n, k in (("rx_torn", "torn_per_s"), ("pdo_reentry", "reentry_per_s"))
|
||||
if no_power(rows, k)]
|
||||
if dead:
|
||||
ax.annotate("恒零轴(无检出功效):" + ", ".join(dead) +
|
||||
"\n→ 本次测不到 ≠ 无故障,不得据此判\"已修好\"\n"
|
||||
" (且固件版本无法与在盘产物对账)",
|
||||
(0.03, 0.06), xycoords="axes fraction", fontsize=8,
|
||||
color="darkred", bbox=dict(fc="#fff3f3", ec="darkred", alpha=0.9))
|
||||
annotate_flags(ax, rows)
|
||||
|
||||
fig.tight_layout(rect=(0, 0, 1, 0.975))
|
||||
fig.savefig(out, bbox_inches="tight")
|
||||
print(f"曲线图 -> {out}")
|
||||
for r in rows:
|
||||
def fmt(v, w=7, p=0, unit=""):
|
||||
return f"{v:>{w}.{p}f}{unit}" if v is not None else f"{'n/a':>{w}}"
|
||||
ld = r["gd32_load_pct"]
|
||||
extra = (f"{ld:.1f}%" if ld is not None else "n/a")
|
||||
if r.get("gd32_load_stale") is not None:
|
||||
extra += f"(样{r.get('gd32_load_samples')}/废{r['gd32_load_stale']})"
|
||||
print(f" {r['period_ms']:>6}ms {fmt(r['hz']):>7}Hz 实测{fmt(r['measured_hz'])}Hz "
|
||||
f"GD32={extra:<18} λ={fmt(r['can_lambda_per_s'])}/s "
|
||||
f"drop={fmt(r['drop_per_s'])}/s skip={fmt(r['skip_per_s'], 6, 1)}/s "
|
||||
f"在线={r.get('online_frac', 'n/a')}")
|
||||
if dead:
|
||||
print(f" 无功效轴: {dead} —— 报告按「测不到」写")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""把 ECAT 侧收到的每一拍信号全量落盘,供事后分析(不在线判优劣)。
|
||||
|
||||
去重规则只有一条:同一 (node,seq) 或同一 (id,data) 帧只在第一次出现时写一行 ——
|
||||
重复采样不携带新信息,去掉后可以把时长拉长。其余一律保留。
|
||||
|
||||
行格式(CSV,第 1 列是相对起始的秒):
|
||||
M,t,node,ch,seq,online,dlc,hex36 段2 电机槽(STATUS 前 36B,含 cpu%@31/init@32-35)
|
||||
F,t,kind,ch,dlc,id,hex36 段3 xrx / 段4 rx_batch 的帧
|
||||
H,t,cycles,wkc,gseq,win_base,win_count,active,rx,tx,drop 每 20 拍一条头部与桥计数
|
||||
|
||||
用法: python scripts/rec_ecat_slots.py [秒数=120] [端口=8090] [输出文件]
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 120.0
|
||||
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8090
|
||||
OUT = sys.argv[3] if len(sys.argv) > 3 else "ecat_slots_raw.csv"
|
||||
|
||||
REQ = (f"POST /api/motor/state HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
|
||||
"Content-Type: application/json\r\nContent-Length: 2\r\n"
|
||||
"Connection: close\r\n\r\n{}").encode("ascii")
|
||||
|
||||
|
||||
def once():
|
||||
s = socket.create_connection((HOST, PORT))
|
||||
try:
|
||||
s.sendall(REQ)
|
||||
buf = bytearray()
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf.extend(c)
|
||||
finally:
|
||||
s.close()
|
||||
_, _, d = bytes(buf).partition(b"\r\n\r\n")
|
||||
return json.loads(d.decode("utf-8", "replace"))
|
||||
|
||||
|
||||
def main():
|
||||
seen_m, seen_f = set(), set()
|
||||
polls = rows = 0
|
||||
t0 = time.time()
|
||||
with open(OUT, "w", newline="", encoding="ascii") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["#kind", "t", "a", "b", "c", "d", "e", "f"])
|
||||
while True:
|
||||
dt = time.time() - t0
|
||||
if dt >= DUR_S:
|
||||
break
|
||||
try:
|
||||
snap = once()
|
||||
except Exception:
|
||||
continue
|
||||
polls += 1
|
||||
h = snap.get("head") or {}
|
||||
b = snap.get("bridge") or {}
|
||||
if polls % 20 == 1:
|
||||
w.writerow(["H", f"{dt:.6f}", snap.get("cycles"), snap.get("wkc"),
|
||||
h.get("global_seq"), h.get("win_base"), h.get("win_count"),
|
||||
h.get("active"), b.get("rx_count"), b.get("tx_count"),
|
||||
b.get("drop")])
|
||||
for m in snap.get("motors") or []:
|
||||
if not m.get("data"):
|
||||
continue
|
||||
key = (m["node"], m["ch"], m["seq"])
|
||||
if key in seen_m:
|
||||
continue
|
||||
seen_m.add(key)
|
||||
if len(seen_m) > 400000: # 去重集合本身的内存上限
|
||||
seen_m = set(list(seen_m)[-200000:])
|
||||
d = bytes.fromhex(m["data"])[:36]
|
||||
w.writerow(["M", f"{dt:.6f}", m["node"], m["ch"], m["seq"],
|
||||
int(bool(m.get("online"))), m.get("dlc"), d.hex()])
|
||||
rows += 1
|
||||
def emit_frames(fr, kind):
|
||||
if not fr or not fr.get("data"):
|
||||
return
|
||||
d = bytes.fromhex(fr["data"])[:36]
|
||||
key = (fr.get("id"), d.hex())
|
||||
if key in seen_f:
|
||||
return
|
||||
seen_f.add(key)
|
||||
w.writerow([kind, f"{dt:.6f}", fr.get("ch"), fr.get("dlc"),
|
||||
fr.get("id"), "", "", d.hex()])
|
||||
|
||||
emit_frames(snap.get("xrx"), "X")
|
||||
for x in ((snap.get("rx_batch") or {}).get("frames") or []):
|
||||
emit_frames(x, "B")
|
||||
if len(seen_f) > 400000:
|
||||
seen_f = set(list(seen_f)[-200000:])
|
||||
print(f"落盘 {OUT}: 轮询 {polls} 次 / {DUR_S:.0f}s,写入 {rows} 行 "
|
||||
f"(电机槽 {len(seen_m)} 帧, 接收帧 {len(seen_f)})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,251 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""复现实验:高频回发 CMD(与电机当前目标一致的 no-op)+ 全拍监控。
|
||||
|
||||
用户反馈:数据错乱要"频繁发指令"才出现。本脚本把 TX 压力拉起来,同时
|
||||
段2(全拍)与段4(全拍)双路监控,命中即记录 dlc/完整48B/当时CONFIG/时刻,
|
||||
用于判定残余拼接的方向与机制。
|
||||
|
||||
安全性:CMD 载荷 = 电机当前自报 CONFIG 的回声(mode/limitV/cur/vel/ang)
|
||||
+ 页面默认 PID(100/100/100),不改变电机行为。
|
||||
|
||||
用法: python scripts/repro_cmdload.py [秒数=90] [发送Hz=500]
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
HOST, PORT = "127.0.0.1", 8090
|
||||
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 90.0
|
||||
SEND_HZ = float(sys.argv[2]) if len(sys.argv) > 2 else 500.0
|
||||
|
||||
|
||||
def post(path, body=b"{}"):
|
||||
req = (f"POST {path} HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
|
||||
f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n"
|
||||
"Connection: close\r\n\r\n").encode("ascii") + body
|
||||
s = socket.create_connection((HOST, PORT))
|
||||
try:
|
||||
s.settimeout(5)
|
||||
s.sendall(req)
|
||||
buf = bytearray()
|
||||
while True:
|
||||
c = s.recv(65536)
|
||||
if not c:
|
||||
break
|
||||
buf.extend(c)
|
||||
finally:
|
||||
s.close()
|
||||
return json.loads(bytes(buf).partition(b"\r\n\r\n")[2].decode("utf-8", "replace"))
|
||||
|
||||
|
||||
def build_cmd(cfg, ang, vel):
|
||||
"""CMD 帧:mode3 速度+位置,limitV 回声当前 CONFIG,角度=正弦轨迹。
|
||||
vel 按节点取 200/150 —— 给 CONFIG 回显的 B7-10 速度字段打节点指纹。"""
|
||||
b = bytearray(32)
|
||||
b[0] = 3 # mode: Velocity_Angle
|
||||
b[1:3] = cfg[1:3] # limitV u16 回声
|
||||
b[3:7] = struct.pack("<f", 0.0) # target cur 0
|
||||
b[7:11] = struct.pack("<f", vel) # vel limit = 节点指纹
|
||||
b[11:15] = struct.pack("<f", ang) # target angle = sine
|
||||
for off in (15, 17, 19):
|
||||
struct.pack_into("<h", b, off, 100)
|
||||
return bytes(b).hex()
|
||||
|
||||
|
||||
stop = threading.Event()
|
||||
lock = threading.Lock()
|
||||
stats = {"sends": 0, "send_err": 0, "t0": time.time()}
|
||||
torn_events = [] # (t, node, dlc, data48hex, cfg_hex)
|
||||
batch_events = []
|
||||
fp_events = [] # 过滤后仍残余的指纹违规
|
||||
fp_total = [0] # (弃用,保留兼容)
|
||||
cfg_total = [0] # 监控实际消费的 CONFIG 帧总数(活性自证,非 0 才有资格谈违规=0)
|
||||
fp_after = [0] # 过滤器放行掉的违规(应为 0)
|
||||
fp_dropped = [0] # 过滤器正确丢弃的违规
|
||||
fp_falsekill = [0] # 过滤器误杀的真帧(应为 0)
|
||||
cfg_seen = {} # node -> 最近 4 个已接受 CONFIG hex(页面过滤器模拟)
|
||||
cfg_susp = {} # node -> 连续疑似计数
|
||||
torn_base = [0]
|
||||
|
||||
|
||||
def sender():
|
||||
"""每拍把两电机当前 CONFIG 回声成 CMD 发出去。"""
|
||||
cfgs = {}
|
||||
interval = 1.0 / SEND_HZ
|
||||
while not stop.is_set():
|
||||
t0 = time.time()
|
||||
try:
|
||||
rr = post("/api/motor/rxraw")
|
||||
raw = bytes.fromhex(rr.get("raw") or "")
|
||||
off = 0
|
||||
for _ in range(rr.get("count") or 0):
|
||||
if off + 8 > len(raw):
|
||||
break
|
||||
dlc = raw[off + 1]
|
||||
fid = struct.unpack_from("<I", raw, off + 4)[0]
|
||||
w = min(dlc, 32)
|
||||
cfgs[(fid >> 16) & 0xFF] = raw[off + 8: off + 8 + w]
|
||||
off += 8 + w
|
||||
except Exception:
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
if len(cfgs) >= 2 or time.time() - stats["t0"] > 3:
|
||||
# 电机偶尔暂停 CONFIG 自报(如正弦轰炸后):拿不到回声就用页面默认
|
||||
# 限压 24.0V(u16 LE 0x5DC0 → bytes c0 5d)兜底,保证指令压力不断。
|
||||
fallback = bytes([0x03, 0xc0, 0x5d]) + bytes(21)
|
||||
s = math.sin(2.0 * math.pi * (time.time() - stats["t0"]) / 10.0)
|
||||
frames = []
|
||||
for n in (2, 3):
|
||||
c = cfgs.get(n, fallback)
|
||||
ang = 1000.0 + (200.0 * s if n == 2 else -200.0 * s)
|
||||
vel = 200.0 if n == 2 else 150.0
|
||||
frames.append({"ch": 2, "id": hex((n << 16) | 0x0200), "dlc": 32,
|
||||
"data": build_cmd(c, ang, vel)})
|
||||
try:
|
||||
post("/api/motor/send", json.dumps({"frames": frames}).encode())
|
||||
with lock:
|
||||
stats["sends"] += 1
|
||||
except Exception:
|
||||
with lock:
|
||||
stats["send_err"] += 1
|
||||
dt = interval - (time.time() - t0)
|
||||
if dt > 0:
|
||||
time.sleep(dt)
|
||||
|
||||
|
||||
def is_cfg_head(d, cfg):
|
||||
"""段2槽头 8B 是否等于该节点当前 CONFIG 头(污染判据,比量程更准)。"""
|
||||
return cfg and len(d) >= 8 and d[:8] == cfg[:8] and d[0] == 0x03
|
||||
|
||||
|
||||
def monitor():
|
||||
seen_s, seen_r = set(), set()
|
||||
cfgs = {}
|
||||
while not stop.is_set():
|
||||
try:
|
||||
st = post("/api/motor/state")
|
||||
except Exception:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
cyc = st.get("cycles")
|
||||
if cyc in seen_s:
|
||||
continue
|
||||
seen_s.add(cyc)
|
||||
if len(seen_s) > 400000:
|
||||
seen_s.clear()
|
||||
for m in st.get("motors") or []:
|
||||
if not m.get("online") or len(m.get("data") or "") < 96:
|
||||
continue
|
||||
d = bytes.fromhex(m["data"])
|
||||
mcu = struct.unpack_from("<h", d, 0)[0] * 0.01
|
||||
cfg = cfgs.get(m["node"])
|
||||
if not (-40.0 <= mcu <= 125.0) or is_cfg_head(d, cfg):
|
||||
with lock:
|
||||
if len(torn_events) < 40:
|
||||
torn_events.append((round(time.time() - stats["t0"], 2),
|
||||
m["node"], m.get("dlc"), d.hex(),
|
||||
(cfg or b"").hex()))
|
||||
try:
|
||||
rr = post("/api/motor/rxraw")
|
||||
except Exception:
|
||||
continue
|
||||
rc = rr.get("cycle")
|
||||
if rc in seen_r:
|
||||
continue
|
||||
seen_r.add(rc)
|
||||
if len(seen_r) > 400000:
|
||||
seen_r.clear()
|
||||
raw = bytes.fromhex(rr.get("raw") or "")
|
||||
off = 0
|
||||
for _ in range(rr.get("count") or 0):
|
||||
if off + 8 > len(raw):
|
||||
break
|
||||
dlc = raw[off + 1]
|
||||
fid = struct.unpack_from("<I", raw, off + 4)[0]
|
||||
w = min(dlc, 32)
|
||||
d = raw[off + 8: off + 8 + w]
|
||||
node = (fid >> 16) & 0xFF
|
||||
if (fid & 0xFFFF) == 0x0101:
|
||||
cfgs[node] = d
|
||||
cfg_total[0] += 1 # 活性自证:本拍监控实际看到的 CONFIG 帧数
|
||||
if any(d[15:24]):
|
||||
with lock:
|
||||
if len(batch_events) < 40:
|
||||
batch_events.append((round(time.time() - stats["t0"], 2),
|
||||
node, d.hex()))
|
||||
# CONFIG↔CONFIG 拼接:结构校验抓不到,用速度指纹暴露。
|
||||
# 预热 3s:sender 尚未生效时电机回显的是页面/上一轮的 vel,
|
||||
# 不是拼接,不能算违规。
|
||||
if len(d) >= 15 and time.time() - stats["t0"] > 3.0:
|
||||
vel = struct.unpack_from("<f", d, 7)[0]
|
||||
exp_vel = 200.0 if node == 2 else 150.0
|
||||
viol = abs(vel - exp_vel) > 1.0
|
||||
with lock:
|
||||
# 模拟页面 CONFIG 跨节点拼接过滤器(transport-ecat.js
|
||||
# dispatchFrame 同逻辑),统计过滤后残余违规与误杀。
|
||||
hx = d.hex()
|
||||
seen = cfg_seen.setdefault(node, [])
|
||||
own = hx in seen
|
||||
foreign = (not own) and any(
|
||||
hx in s2 for n2, s2 in cfg_seen.items() if n2 != node)
|
||||
if own or not foreign or cfg_susp.get(node, 0) >= 5:
|
||||
cfg_susp[node] = 0
|
||||
seen.insert(0, hx)
|
||||
del seen[4:]
|
||||
if viol:
|
||||
fp_after[0] += 1
|
||||
if len(fp_events) < 40:
|
||||
fp_events.append((round(time.time() - stats["t0"], 2),
|
||||
node, vel, exp_vel, hx))
|
||||
else:
|
||||
cfg_susp[node] = cfg_susp.get(node, 0) + 1
|
||||
if viol:
|
||||
fp_dropped[0] += 1
|
||||
else:
|
||||
fp_falsekill[0] += 1
|
||||
off += 8 + w
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
r = post("/api/app/load")
|
||||
torn_base[0] = r.get("rx_torn") or 0
|
||||
except Exception:
|
||||
pass
|
||||
ts = threading.Thread(target=sender, daemon=True)
|
||||
tm = threading.Thread(target=monitor, daemon=True)
|
||||
ts.start(); tm.start()
|
||||
time.sleep(DUR_S)
|
||||
stop.set(); ts.join(3); tm.join(3)
|
||||
try:
|
||||
r = post("/api/app/load")
|
||||
torn_now = r.get("rx_torn") or 0
|
||||
except Exception:
|
||||
torn_now = -1
|
||||
with lock:
|
||||
print(f"时长 {DUR_S:.0f}s 发送 {stats['sends']} 次(错{stats['send_err']}) "
|
||||
f"≈{stats['sends']/DUR_S:.0f} CMD拍/s")
|
||||
print(f"rx_torn: {torn_base[0]} -> {torn_now} (Δ={torn_now-torn_base[0]})")
|
||||
print(f"段2 污染事件: {len(torn_events)}(前 8)")
|
||||
for e in torn_events[:8]:
|
||||
print(f" t={e[0]}s node={e[1]} dlc={e[2]}")
|
||||
print(f" slot: {e[3]}")
|
||||
print(f" cfg : {e[4]}")
|
||||
print(f"段4 污染事件: {len(batch_events)}(前 8)")
|
||||
for e in batch_events[:8]:
|
||||
print(f" t={e[0]}s node={e[1]} data={e[2]}")
|
||||
print(f"CONFIG↔CONFIG 指纹(预热后): 违规={fp_after[0]+fp_dropped[0]} "
|
||||
f"过滤器正确丢弃={fp_dropped[0]} 过滤后残余={fp_after[0]} "
|
||||
f"误杀真帧={fp_falsekill[0]}(样本 {len(fp_events)})")
|
||||
print(f"监控消费的 CONFIG 帧总数: {cfg_total[0]}(活性自证:>0 才说明违规=0 是真结果)")
|
||||
for e in fp_events[:8]:
|
||||
print(f" t={e[0]}s node={e[1]} vel={e[2]:.1f} 应为{e[3]:.0f} data={e[4]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,130 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""在「以太网 2」(I210) 上关闭非 EtherCAT 绑定,测跳拍率;可 --restore 还原。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
NIC = "以太网 2"
|
||||
|
||||
|
||||
def ps(cmd, timeout=60):
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", cmd],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout,
|
||||
)
|
||||
return (r.stdout or "").strip(), (r.stderr or "").strip()
|
||||
|
||||
|
||||
def snapshot():
|
||||
out, err = ps(
|
||||
f"Get-NetAdapterBinding -Name '{NIC}' | "
|
||||
"Select-Object DisplayName,ComponentID,Enabled | ConvertTo-Json -Compress"
|
||||
)
|
||||
data = json.loads(out)
|
||||
return data if isinstance(data, list) else [data]
|
||||
|
||||
|
||||
def disable(component_ids):
|
||||
logs = []
|
||||
for cid in component_ids:
|
||||
out, err = ps(
|
||||
f"Disable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false; "
|
||||
f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | "
|
||||
"Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"
|
||||
)
|
||||
logs.append({"cid": cid, "out": out[:300], "err": err[:300]})
|
||||
return logs
|
||||
|
||||
|
||||
def enable(component_ids):
|
||||
logs = []
|
||||
for cid in component_ids:
|
||||
out, err = ps(
|
||||
f"Enable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false; "
|
||||
f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | "
|
||||
"Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"
|
||||
)
|
||||
logs.append({"cid": cid, "out": out[:300], "err": err[:300]})
|
||||
return logs
|
||||
|
||||
|
||||
def set_prio(level="RealTime"):
|
||||
p = int(subprocess.check_output(
|
||||
["powershell", "-NoProfile", "-Command", "(Get-Process ecat_native).Id"],
|
||||
text=True).strip())
|
||||
subprocess.check_output(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"],
|
||||
text=True)
|
||||
return p
|
||||
|
||||
|
||||
def meter(tag, secs=18):
|
||||
r = subprocess.run(
|
||||
[sys.executable, os.path.join(ROOT, "scripts", "skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag + "_") and f.endswith(".json"))
|
||||
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
|
||||
|
||||
# Offloads that are useless/dangerous for raw EtherCAT on a dedicated NIC
|
||||
OFFLOADS = [
|
||||
"vmware_bridge", # VMware Bridge — intercepts/injects, classic ECAT killer
|
||||
"ms_pacer", # QoS packet scheduler
|
||||
"ms_lldp", # LLDP
|
||||
"ms_rspndr", # LLTD responder
|
||||
"ms_lltdio", # LLTD mapper
|
||||
"ms_server", # file/printer sharing
|
||||
"ms_msclient", # Microsoft network client
|
||||
# keep ms_tcpip / ms_tcpip6 for now in case anything uses IP on this NIC
|
||||
# Npcap must stay: INSECURE_NPCAP
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(LAB, exist_ok=True)
|
||||
snap = snapshot()
|
||||
snap_path = os.path.join(LAB, f"nic_bind_{int(time.time())}.json")
|
||||
json.dump(snap, open(snap_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("bind snapshot", snap_path)
|
||||
|
||||
if "--restore" in sys.argv:
|
||||
print(enable(OFFLOADS))
|
||||
return 0
|
||||
|
||||
print("disable", OFFLOADS)
|
||||
print(json.dumps(disable(OFFLOADS), ensure_ascii=False, indent=2))
|
||||
print("set RealTime")
|
||||
print("pid", set_prio("RealTime"))
|
||||
# binding change usually doesn't reset link; brief settle
|
||||
time.sleep(3)
|
||||
data = meter("bind_off", 18)
|
||||
row = {
|
||||
"late_per_s": data["late_per_s"],
|
||||
"skip_per_s": data["skip_per_s"],
|
||||
"late_frac_pct": data["late_frac_pct"],
|
||||
"achieved_hz": data["achieved_hz"],
|
||||
"n_late": data["n_late"],
|
||||
"send_stall_frac": data["send_stall_frac"],
|
||||
"rx_stall_frac": data["rx_stall_frac"],
|
||||
"work_p50": data["window_cyc_us"]["work"]["p50"],
|
||||
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
|
||||
"send_p50": data["window_cyc_us"]["send"]["p50"],
|
||||
"work_p90": data["window_cyc_us"]["work"]["p90"],
|
||||
}
|
||||
print("ROW", json.dumps(row, ensure_ascii=False, indent=2))
|
||||
out = os.path.join(LAB, f"summary_bind_off_{int(time.time())}.json")
|
||||
json.dump({"row": row, "disabled": OFFLOADS, "snap": snap_path},
|
||||
open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,53 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, subprocess, sys, time
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
NIC = "以太网 2"
|
||||
|
||||
def ps(cmd):
|
||||
r = subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
|
||||
text=True, encoding="utf-8", errors="replace")
|
||||
return r.strip()
|
||||
|
||||
def set_prio(level="RealTime"):
|
||||
p = int(ps("(Get-Process ecat_native).Id"))
|
||||
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass")
|
||||
return p
|
||||
|
||||
def meter(tag, secs=18):
|
||||
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
|
||||
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
|
||||
def main():
|
||||
restore = "--restore" in sys.argv
|
||||
ids = ["ms_tcpip", "ms_tcpip6"]
|
||||
for cid in ids:
|
||||
if restore:
|
||||
ps(f"Enable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false")
|
||||
else:
|
||||
ps(f"Disable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false")
|
||||
print(ps(f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"))
|
||||
if restore:
|
||||
return
|
||||
print("pid", set_prio("RealTime"))
|
||||
time.sleep(3)
|
||||
data = meter("bind_noip", 18)
|
||||
row = {
|
||||
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
|
||||
"late_frac_pct": data["late_frac_pct"], "achieved_hz": data["achieved_hz"],
|
||||
"n_late": data["n_late"],
|
||||
"send_stall_frac": data["send_stall_frac"], "rx_stall_frac": data["rx_stall_frac"],
|
||||
"work_p50": data["window_cyc_us"]["work"]["p50"],
|
||||
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
|
||||
"send_p50": data["window_cyc_us"]["send"]["p50"],
|
||||
}
|
||||
print(json.dumps(row, ensure_ascii=False, indent=2))
|
||||
out = os.path.join(LAB, f"summary_bind_noip_{int(time.time())}.json")
|
||||
json.dump(row, open(out,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", out)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,105 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""0.25ms 下 A/B:静默 vs 模拟 spi_test 前端轮询。"""
|
||||
import json, time, urllib.request, os
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
|
||||
def post(p, b=None, timeout=5):
|
||||
d = json.dumps(b or {}).encode()
|
||||
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
|
||||
with urllib.request.urlopen(r, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
def snap():
|
||||
st = post("/api/motor/state")
|
||||
dbg = post("/api/motor/dbg")
|
||||
return {
|
||||
"cycles": st.get("cycles") or 0,
|
||||
"skip_total": dbg.get("skip_total") or 0,
|
||||
"gseq": (st.get("head") or {}).get("global_seq") or 0,
|
||||
"period": st.get("period_ms"),
|
||||
"wkc": st.get("wkc"),
|
||||
"pcore": st.get("pcore_mask"),
|
||||
"cyc_us": st.get("cyc_us"),
|
||||
}
|
||||
|
||||
def rate(a, b, dt):
|
||||
dc = b["cycles"] - a["cycles"]
|
||||
ds = (b["skip_total"] - a["skip_total"]) # may wrap? long long, no
|
||||
return {
|
||||
"dt": round(dt, 3),
|
||||
"pdo_hz": round(dc / dt, 1) if dt else None,
|
||||
"late_per_s": round(ds / dt, 2) if dt else None,
|
||||
"skip_frac": round(100.0 * ds / dc, 2) if dc else None,
|
||||
"gseq_per_s": round(((b["gseq"] - a["gseq"]) & 0xFFFFFFFF) / dt, 1) if dt else None,
|
||||
"dcyc": dc, "dskip": ds,
|
||||
}
|
||||
|
||||
def arm_period(ms):
|
||||
try:
|
||||
post("/api/motor/stop")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.15)
|
||||
post("/api/motor/period", {"period_ms": ms})
|
||||
time.sleep(0.15)
|
||||
post("/api/motor/start")
|
||||
time.sleep(0.5)
|
||||
|
||||
print("ensure 0.25ms")
|
||||
arm_period(0.25)
|
||||
time.sleep(3)
|
||||
|
||||
# --- A: fully quiet ---
|
||||
a0 = snap()
|
||||
t0 = time.time()
|
||||
# no HTTP
|
||||
time.sleep(10)
|
||||
a1 = snap()
|
||||
A = rate(a0, a1, time.time() - t0)
|
||||
print("QUIET", json.dumps(A, ensure_ascii=False))
|
||||
print(" cyc_us", json.dumps(a1.get("cyc_us"), ensure_ascii=False))
|
||||
|
||||
# --- B: hammer like spi_test (state+load+dbg+rxraw) ---
|
||||
time.sleep(1)
|
||||
b0 = snap()
|
||||
t0 = time.time()
|
||||
n = 0
|
||||
while time.time() - t0 < 10:
|
||||
try:
|
||||
post("/api/motor/state")
|
||||
post("/api/app/load")
|
||||
post("/api/motor/dbg")
|
||||
post("/api/motor/rxraw")
|
||||
n += 1
|
||||
except Exception as e:
|
||||
print("hammer err", e)
|
||||
break
|
||||
time.sleep(0.05) # ~20Hz full set, similar to busy panel
|
||||
b1 = snap()
|
||||
B = rate(b0, b1, time.time() - t0)
|
||||
print("HAMMER", json.dumps(B, ensure_ascii=False), "polls", n)
|
||||
print(" cyc_us", json.dumps(b1.get("cyc_us"), ensure_ascii=False))
|
||||
|
||||
# --- C: only motor/state at max (no dbg/rxraw) ---
|
||||
time.sleep(1)
|
||||
c0 = snap()
|
||||
t0 = time.time()
|
||||
n = 0
|
||||
while time.time() - t0 < 10:
|
||||
try:
|
||||
post("/api/motor/state")
|
||||
n += 1
|
||||
except Exception:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
c1 = snap()
|
||||
C = rate(c0, c1, time.time() - t0)
|
||||
print("STATE_ONLY", json.dumps(C, ensure_ascii=False), "polls", n)
|
||||
|
||||
out = {"quiet": A, "hammer": B, "state_only": C,
|
||||
"quiet_cyc": a1.get("cyc_us"), "hammer_cyc": b1.get("cyc_us")}
|
||||
path = os.path.join(LAB, f"summary_fe_ab_{int(time.time())}.json")
|
||||
json.dump(out, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
@@ -1,126 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""快照当前主机侧可回滚现场:I210 高级属性、中断亲和、ecat 进程。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
|
||||
|
||||
def ps(cmd: str) -> str:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return (r.stdout or "") + (("ERR:" + r.stderr) if r.stderr else "")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.makedirs(LAB, exist_ok=True)
|
||||
snap = {"ts": time.time(), "host": os.environ.get("COMPUTERNAME")}
|
||||
|
||||
snap["adapters"] = ps(
|
||||
"Get-NetAdapter | Select-Object Name,InterfaceDescription,LinkSpeed,Status,"
|
||||
"ifIndex,MacAddress | ConvertTo-Json -Compress"
|
||||
)
|
||||
# I210 specifically
|
||||
snap["i210"] = ps(
|
||||
"Get-NetAdapter | Where-Object {$_.InterfaceDescription -match 'I210'} | "
|
||||
"Select-Object Name,ifIndex,Status,LinkSpeed | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
# advanced properties (restore-able)
|
||||
snap["i210_adv"] = ps(
|
||||
"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
|
||||
"Select-Object Name,DisplayName,DisplayValue,RegistryKeyword,RegistryValue | "
|
||||
"ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
# interrupt moderation / RSS / power
|
||||
for kw in (
|
||||
"*InterruptModeration",
|
||||
"*ReceiveBuffers",
|
||||
"*TransmitBuffers",
|
||||
"*EEE",
|
||||
"*EnergyEfficient",
|
||||
"*FlowControl",
|
||||
"*PriorityVLANTag",
|
||||
"*TCPChecksumOffload*",
|
||||
"*IPChecksumOffload*",
|
||||
"*VMQ",
|
||||
"*RSS",
|
||||
"*InterruptModerationRate",
|
||||
):
|
||||
snap[f"adv_{kw.strip('*').replace('*','_')}"] = ps(
|
||||
f"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
|
||||
f"Where-Object {{$_.RegistryKeyword -like '{kw}'}} | "
|
||||
f"Select-Object RegistryKeyword,DisplayValue,RegistryValue | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
snap["power"] = ps(
|
||||
"Get-NetAdapterPowerManagement -Name '以太网 2' -ErrorAction SilentlyContinue | "
|
||||
"Select-Object Name,AllowComputerToTurnOffDevice,WakeOnMagicPacket,"
|
||||
"WakeOnPattern,DeviceSleepOnDisconnect | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
snap["ecat_proc"] = ps(
|
||||
"Get-Process ecat_native -ErrorAction SilentlyContinue | "
|
||||
"Select-Object Id,Path,StartTime,CPU,WorkingSet | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
snap["ecat_cmdline"] = ps(
|
||||
"Get-CimInstance Win32_Process -Filter \"Name='ecat_native.exe'\" | "
|
||||
"Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
# interrupt affinity via Get-NetAdapterInterrupt? not always available
|
||||
snap["msi_affinity"] = ps(
|
||||
"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
|
||||
"Where-Object {$_.DisplayName -match 'Interrupt|Affinity|RSS|Moderation|Buffer|Energy|Green|Flow|Offload'} | "
|
||||
"Select-Object DisplayName,DisplayValue,RegistryKeyword | ConvertTo-Json -Compress"
|
||||
)
|
||||
|
||||
# CPU topology quick
|
||||
snap["cpu"] = ps(
|
||||
"$env:NUMBER_OF_PROCESSORS; (Get-CimInstance Win32_Processor | "
|
||||
"Select-Object Name,NumberOfCores,NumberOfLogicalProcessors | ConvertTo-Json -Compress)"
|
||||
)
|
||||
|
||||
out = os.path.join(LAB, f"host_snap_{int(time.time())}.json")
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(snap, f, ensure_ascii=False, indent=2)
|
||||
|
||||
# also human-readable restore snippet
|
||||
lines = []
|
||||
lines.append("# Host snapshot restore notes")
|
||||
lines.append(f"time={snap['ts']}")
|
||||
lines.append("## I210 adapter advanced (raw)")
|
||||
lines.append(snap.get("i210_adv", "")[:8000])
|
||||
lines.append("## power")
|
||||
lines.append(snap.get("power", "")[:4000])
|
||||
lines.append("## ecat cmdline")
|
||||
lines.append(snap.get("ecat_cmdline", "")[:2000])
|
||||
txt = os.path.join(LAB, f"host_snap_{int(snap['ts'])}.txt")
|
||||
with open(txt, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
print("I210:", snap.get("i210", "")[:500])
|
||||
print("CMDLINE:", snap.get("ecat_cmdline", "")[:800])
|
||||
print("ADV keys sample:")
|
||||
print(snap.get("msi_affinity", "")[:2500])
|
||||
print("WROTE", out)
|
||||
print("WROTE", txt)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, subprocess, time, urllib.request
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
NIC = "以太网 2"
|
||||
|
||||
def ps(cmd):
|
||||
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
|
||||
text=True, encoding="utf-8", errors="replace").strip()
|
||||
|
||||
def post(p, b=None):
|
||||
d = json.dumps(b or {}).encode()
|
||||
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
|
||||
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
|
||||
|
||||
def wait_ok(t=30):
|
||||
t0=time.time()
|
||||
while time.time()-t0<t:
|
||||
try:
|
||||
h=post("/api/native/health")
|
||||
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3: return h
|
||||
except Exception: pass
|
||||
time.sleep(0.4)
|
||||
return None
|
||||
|
||||
def set_rt():
|
||||
p=int(ps("(Get-Process ecat_native).Id"))
|
||||
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
|
||||
return p
|
||||
|
||||
def period(ms):
|
||||
post("/api/motor/stop"); time.sleep(0.12)
|
||||
post("/api/motor/period", {"period_ms": ms}); time.sleep(0.12)
|
||||
post("/api/motor/start")
|
||||
|
||||
def quiet(sec=10):
|
||||
st=post("/api/motor/state"); dbg=post("/api/motor/dbg")
|
||||
c0,s0=st.get("cycles") or 0, dbg.get("skip_total") or 0
|
||||
t0=time.time(); time.sleep(sec)
|
||||
st=post("/api/motor/state"); dbg=post("/api/motor/dbg")
|
||||
dt=time.time()-t0
|
||||
c1,s1=st.get("cycles") or 0, dbg.get("skip_total") or 0
|
||||
cu=st.get("cyc_us") or {}
|
||||
# also sample dbg ring k distribution
|
||||
ring=dbg.get("cyc") or []
|
||||
ks=[r[5] for r in ring if len(r)>=6]
|
||||
works=[(r[4]-r[0])*1e6 for r in ring if len(r)>=5]
|
||||
sends=[(r[2]-r[1])*1e6 for r in ring if len(r)>=3]
|
||||
rxs=[(r[3]-r[2])*1e6 for r in ring if len(r)>=4]
|
||||
def pct(a,q):
|
||||
if not a: return None
|
||||
a=sorted(a); return round(a[int(q*(len(a)-1))],1)
|
||||
return {
|
||||
"pdo_hz": round((c1-c0)/dt,1),
|
||||
"late_per_s": round((s1-s0)/dt,2),
|
||||
"achieved_vs_4k": round(100*(c1-c0)/dt/4000,1),
|
||||
"cyc_us": cu,
|
||||
"ring_n": len(ring),
|
||||
"k_gt0": sum(1 for k in ks if k>0),
|
||||
"k_sum": sum(ks),
|
||||
"work_pct": {"p50":pct(works,.5),"p90":pct(works,.9),"p99":pct(works,.99),"max":pct(works,1.0) if works else None},
|
||||
"send_pct": {"p50":pct(sends,.5),"p90":pct(sends,.9),"max":pct(sends,1.0) if sends else None},
|
||||
"rx_pct": {"p50":pct(rxs,.5),"p90":pct(rxs,.9),"max":pct(rxs,1.0) if rxs else None},
|
||||
}
|
||||
|
||||
# set ITR 200
|
||||
ps("Set-NetAdapterAdvancedProperty -Name '以太网 2' -RegistryKeyword '*InterruptModeration' -RegistryValue '1' -NoRestart")
|
||||
ps("Set-NetAdapterAdvancedProperty -Name '以太网 2' -RegistryKeyword 'ITR' -RegistryValue '200' -NoRestart")
|
||||
ps("Restart-NetAdapter -Name '以太网 2' -Confirm:$false")
|
||||
time.sleep(2)
|
||||
print("health", wait_ok())
|
||||
set_rt()
|
||||
|
||||
rows=[]
|
||||
for ms in (0.25, 0.5, 1.0):
|
||||
print(f"\n=== {ms} ms ITR=200 ===")
|
||||
period(ms)
|
||||
time.sleep(4)
|
||||
set_rt()
|
||||
r=quiet(10)
|
||||
print(json.dumps(r, ensure_ascii=False, indent=2))
|
||||
rows.append(r)
|
||||
|
||||
path=os.path.join(LAB, f"summary_itr200_periods_{int(time.time())}.json")
|
||||
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
@@ -1,128 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""查 pdo 线程亲和 + 尝试 ITR 200/400 在 0.25ms 静默下的表现。"""
|
||||
import json, os, subprocess, time, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
NIC = "以太网 2"
|
||||
|
||||
def ps(cmd, timeout=60):
|
||||
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
timeout=timeout).strip()
|
||||
|
||||
def post(p, b=None):
|
||||
d = json.dumps(b or {}).encode()
|
||||
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
|
||||
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
|
||||
|
||||
def wait_ok(timeout=35):
|
||||
t0 = time.time()
|
||||
while time.time()-t0 < timeout:
|
||||
try:
|
||||
h = post("/api/native/health")
|
||||
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3:
|
||||
return h
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.4)
|
||||
return None
|
||||
|
||||
def set_rt():
|
||||
p = int(ps("(Get-Process ecat_native).Id"))
|
||||
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
|
||||
return p
|
||||
|
||||
def quiet_rate(sec=8):
|
||||
st = post("/api/motor/state")
|
||||
dbg = post("/api/motor/dbg")
|
||||
c0, s0 = st.get("cycles") or 0, dbg.get("skip_total") or 0
|
||||
t0 = time.time()
|
||||
time.sleep(sec)
|
||||
st = post("/api/motor/state")
|
||||
dbg = post("/api/motor/dbg")
|
||||
dt = time.time()-t0
|
||||
c1, s1 = st.get("cycles") or 0, dbg.get("skip_total") or 0
|
||||
return {
|
||||
"pdo_hz": round((c1-c0)/dt, 1),
|
||||
"late_per_s": round((s1-s0)/dt, 2),
|
||||
"cyc_us": st.get("cyc_us"),
|
||||
"period": st.get("period_ms"),
|
||||
}
|
||||
|
||||
def bus_restart_period(ms):
|
||||
try:
|
||||
post("/api/motor/stop")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.15)
|
||||
post("/api/motor/period", {"period_ms": ms})
|
||||
time.sleep(0.15)
|
||||
post("/api/motor/start")
|
||||
|
||||
def nic_set(kw, val):
|
||||
out, err = "", ""
|
||||
r = subprocess.run(["powershell","-NoProfile","-Command",
|
||||
f"Set-NetAdapterAdvancedProperty -Name '{NIC}' -RegistryKeyword '{kw}' -RegistryValue '{val}' -NoRestart"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
return r.stdout.strip(), r.stderr.strip()
|
||||
|
||||
# affinity of ecat threads via .NET is limited; dump processor affinity mask
|
||||
print("=== process affinity ===")
|
||||
print(ps(r'''
|
||||
$p = Get-Process ecat_native
|
||||
"PID $($p.Id) Priority=$($p.PriorityClass)"
|
||||
# ProcessorAffinity is a bitmask
|
||||
"Affinity=$($p.ProcessorAffinity)"
|
||||
'''))
|
||||
|
||||
print("=== I210 current ITR/moderation ===")
|
||||
print(ps(r'''
|
||||
Get-NetAdapterAdvancedProperty -Name '以太网 2' |
|
||||
Where-Object { $_.RegistryKeyword -in 'ITR','*InterruptModeration','*TransmitBuffers' } |
|
||||
Select-Object RegistryKeyword,DisplayValue | ConvertTo-Json -Compress
|
||||
'''))
|
||||
|
||||
results = []
|
||||
# try ITR values at 0.25ms quiet
|
||||
for itr in ("200", "400", "950", "65535"):
|
||||
print(f"\n===== ITR={itr} @0.25ms =====")
|
||||
o, e = nic_set("*InterruptModeration", "1")
|
||||
o2, e2 = nic_set("ITR", itr)
|
||||
print("set", o2 or o, e2 or e)
|
||||
# restart nic only if needed - ITR may need restart
|
||||
ps(f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false")
|
||||
time.sleep(2)
|
||||
h = wait_ok(30)
|
||||
print("health", bool(h))
|
||||
if not h:
|
||||
try:
|
||||
post("/api/op/start"); post("/api/motor/start")
|
||||
h = wait_ok(20)
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
set_rt()
|
||||
bus_restart_period(0.25)
|
||||
time.sleep(3)
|
||||
set_rt()
|
||||
r = quiet_rate(8)
|
||||
print(json.dumps({k:r[k] for k in ("pdo_hz","late_per_s","period")}, ensure_ascii=False))
|
||||
print("work/send/rx", r["cyc_us"]["work"]["p50"], r["cyc_us"]["send"]["p50"], r["cyc_us"]["rx"]["p50"])
|
||||
results.append({"itr": itr, **{k:r[k] for k in ("pdo_hz","late_per_s")},
|
||||
"work_p50": r["cyc_us"]["work"]["p50"], "send_p50": r["cyc_us"]["send"]["p50"],
|
||||
"rx_p50": r["cyc_us"]["rx"]["p50"]})
|
||||
|
||||
print("\nSUMMARY", json.dumps(results, ensure_ascii=False, indent=2))
|
||||
# restore adaptive
|
||||
nic_set("ITR", "65535")
|
||||
ps(f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false")
|
||||
time.sleep(2)
|
||||
wait_ok(25)
|
||||
set_rt()
|
||||
# leave at 1ms for safety
|
||||
bus_restart_period(1.0)
|
||||
time.sleep(1)
|
||||
print("restored 1ms", post("/api/native/health"))
|
||||
path = os.path.join(LAB, f"summary_itr_4k_{int(time.time())}.json")
|
||||
json.dump(results, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import json, os, subprocess, sys, time
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
|
||||
def set_prio(level="RealTime"):
|
||||
p = int(subprocess.check_output(["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], text=True).strip())
|
||||
subprocess.check_output(["powershell","-NoProfile","-Command",
|
||||
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], text=True)
|
||||
return p
|
||||
|
||||
def meter(tag, secs=15):
|
||||
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
|
||||
d = json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
return {
|
||||
"tag": tag,
|
||||
"late_per_s": d["late_per_s"], "skip_per_s": d["skip_per_s"],
|
||||
"achieved_hz": d["achieved_hz"], "n_late": d["n_late"],
|
||||
"work_p50": d["window_cyc_us"]["work"]["p50"],
|
||||
"rx_p50": d["window_cyc_us"]["rx"]["p50"],
|
||||
"send_p50": d["window_cyc_us"]["send"]["p50"],
|
||||
"work_p90": d["window_cyc_us"]["work"]["p90"],
|
||||
}
|
||||
|
||||
def main():
|
||||
set_prio("RealTime")
|
||||
rows = []
|
||||
for i in range(1, 4):
|
||||
print(f"round {i} settle...")
|
||||
time.sleep(4)
|
||||
r = meter(f"multirt_{i}", 15)
|
||||
print(r)
|
||||
rows.append(r)
|
||||
lates = [x["late_per_s"] for x in rows]
|
||||
print("mean late", sum(lates)/len(lates), "min", min(lates), "max", max(lates))
|
||||
path = os.path.join(LAB, f"summary_multirt_{int(time.time())}.json")
|
||||
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,312 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""可回滚的 I210 网卡参数实验:停环 → 改高级属性 → 恢复 OP → 标定跳拍率。
|
||||
|
||||
默认只测、不永久破坏:原值会写入 restore json;`--restore` 按快照还原。
|
||||
改网卡会触发短暂链路重协商,期间从站可能掉 OP,脚本会等 link+OP 恢复。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
NIC = "以太网 2"
|
||||
BASE = os.environ.get("ECAT_BASE", "http://127.0.0.1:8090")
|
||||
|
||||
|
||||
def post(path, body=None, timeout=8.0):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def ps(cmd, timeout=60):
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
return (r.stdout or "").strip(), (r.stderr or "").strip()
|
||||
|
||||
|
||||
def nic_snapshot():
|
||||
out, err = ps(
|
||||
f"Get-NetAdapterAdvancedProperty -Name '{NIC}' | "
|
||||
"Select-Object RegistryKeyword,DisplayValue,RegistryValue | "
|
||||
"ConvertTo-Json -Compress"
|
||||
)
|
||||
if not out:
|
||||
return []
|
||||
data = json.loads(out)
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
return data
|
||||
|
||||
|
||||
def nic_set(keyword: str, value: str):
|
||||
# value as string; Intel accepts "0"/"1"/numbers
|
||||
cmd = (
|
||||
f"Set-NetAdapterAdvancedProperty -Name '{NIC}' "
|
||||
f"-RegistryKeyword '{keyword}' -RegistryValue '{value}' -NoRestart"
|
||||
)
|
||||
out, err = ps(cmd)
|
||||
return out, err
|
||||
|
||||
|
||||
def nic_restart():
|
||||
out, err = ps(
|
||||
f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false; Start-Sleep -Seconds 2; "
|
||||
f"Get-NetAdapter -Name '{NIC}' | Select-Object Status,LinkSpeed | ConvertTo-Json -Compress"
|
||||
)
|
||||
return out, err
|
||||
|
||||
|
||||
def wait_link(timeout=20.0):
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
out, _ = ps(
|
||||
f"Get-NetAdapter -Name '{NIC}' | Select-Object Status,LinkSpeed | ConvertTo-Json -Compress"
|
||||
)
|
||||
try:
|
||||
j = json.loads(out)
|
||||
if isinstance(j, list):
|
||||
j = j[0]
|
||||
if str(j.get("Status", "")).lower() == "up":
|
||||
return True, j
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return False, None
|
||||
|
||||
|
||||
def wait_ecat_healthy(timeout=25.0):
|
||||
t0 = time.time()
|
||||
last = None
|
||||
while time.time() - t0 < timeout:
|
||||
try:
|
||||
h = post("/api/native/health")
|
||||
last = h
|
||||
if h.get("loop") == 1 and int(h.get("wkc") or 0) >= 3 and h.get("op") == 1:
|
||||
# also require cycles advancing
|
||||
c0 = h.get("cycles") or 0
|
||||
time.sleep(0.4)
|
||||
h2 = post("/api/native/health")
|
||||
c1 = h2.get("cycles") or 0
|
||||
if c1 > c0:
|
||||
return True, h2
|
||||
except Exception as e:
|
||||
last = {"err": str(e)}
|
||||
time.sleep(0.5)
|
||||
return False, last
|
||||
|
||||
|
||||
def stop_bus():
|
||||
logs = []
|
||||
for path in ("/api/motor/stop", "/api/op/stop"):
|
||||
try:
|
||||
r = post(path)
|
||||
logs.append((path, r.get("ok"), (r.get("log") or r)[:2]))
|
||||
except Exception as e:
|
||||
logs.append((path, False, str(e)))
|
||||
return logs
|
||||
|
||||
|
||||
def start_bus():
|
||||
logs = []
|
||||
try:
|
||||
r = post("/api/op/start")
|
||||
logs.append(("/api/op/start", r.get("ok"), (r.get("log") or [""])[:2]))
|
||||
except Exception as e:
|
||||
logs.append(("/api/op/start", False, str(e)))
|
||||
try:
|
||||
r = post("/api/motor/start")
|
||||
logs.append(("/api/motor/start", r.get("ok"), (r.get("log") or [""])[:2]))
|
||||
except Exception as e:
|
||||
logs.append(("/api/motor/start", False, str(e)))
|
||||
return logs
|
||||
|
||||
|
||||
def meter(tag: str, secs: float):
|
||||
# reuse skip_rate_meter as subprocess for isolation
|
||||
cmd = [
|
||||
sys.executable,
|
||||
os.path.join(ROOT, "scripts", "skip_rate_meter.py"),
|
||||
"--tag",
|
||||
tag,
|
||||
"--secs",
|
||||
str(secs),
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
# last json file for tag
|
||||
files = sorted(
|
||||
[f for f in os.listdir(LAB) if f.startswith(tag + "_") and f.endswith(".json")]
|
||||
)
|
||||
data = None
|
||||
if files:
|
||||
with open(os.path.join(LAB, files[-1]), encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data, r.stdout, r.stderr
|
||||
|
||||
|
||||
def apply_batch(changes: list[tuple[str, str]]):
|
||||
results = []
|
||||
for k, v in changes:
|
||||
out, err = nic_set(k, v)
|
||||
results.append({"key": k, "value": v, "out": out[:300], "err": err[:300]})
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--restore", action="store_true", help="restore from snap json")
|
||||
ap.add_argument("--snap", default=None, help="snapshot json for restore")
|
||||
ap.add_argument("--exp", default=None, help="named experiment")
|
||||
ap.add_argument("--secs", type=float, default=16.0)
|
||||
ap.add_argument("--list-exp", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
os.makedirs(LAB, exist_ok=True)
|
||||
|
||||
# experiment table: list of (keyword, value)
|
||||
EXPS = {
|
||||
"itr_off": [
|
||||
("*InterruptModeration", "0"),
|
||||
("ITR", "0"),
|
||||
],
|
||||
"itr_low": [
|
||||
("*InterruptModeration", "1"),
|
||||
("ITR", "400"), # valid: 65535,3600,2000,950,400,200,0
|
||||
],
|
||||
"itr_200": [
|
||||
("*InterruptModeration", "1"),
|
||||
("ITR", "200"),
|
||||
],
|
||||
"itr_950": [
|
||||
("*InterruptModeration", "1"),
|
||||
("ITR", "950"),
|
||||
],
|
||||
"eee_off": [
|
||||
("EEELinkAdvertisement", "0"),
|
||||
],
|
||||
"flow_off": [
|
||||
("*FlowControl", "0"),
|
||||
],
|
||||
"txbuf_max": [
|
||||
("*TransmitBuffers", "4096"),
|
||||
],
|
||||
"rxbuf_max": [
|
||||
("*ReceiveBuffers", "1024"),
|
||||
],
|
||||
"combo_lowlat": [
|
||||
("*InterruptModeration", "0"),
|
||||
("ITR", "0"),
|
||||
("EEELinkAdvertisement", "0"),
|
||||
("*FlowControl", "0"),
|
||||
("*TransmitBuffers", "2048"),
|
||||
("*ReceiveBuffers", "512"),
|
||||
],
|
||||
}
|
||||
|
||||
if args.list_exp:
|
||||
print(json.dumps({k: v for k, v in EXPS.items()}, indent=2))
|
||||
return 0
|
||||
|
||||
# always keep a fresh snapshot before mutating
|
||||
snap = nic_snapshot()
|
||||
snap_path = os.path.join(LAB, f"nic_before_{int(time.time())}.json")
|
||||
with open(snap_path, "w", encoding="utf-8") as f:
|
||||
json.dump(snap, f, ensure_ascii=False, indent=2)
|
||||
print(f"NIC snapshot → {snap_path}")
|
||||
|
||||
if args.restore:
|
||||
path = args.snap or snap_path
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
# stop, apply all, restart
|
||||
print("restore: stop bus")
|
||||
print(stop_bus())
|
||||
n = 0
|
||||
for row in data:
|
||||
kw = row.get("RegistryKeyword")
|
||||
val = row.get("RegistryValue")
|
||||
if not kw or val is None:
|
||||
continue
|
||||
if isinstance(val, list):
|
||||
val = val[0] if val else ""
|
||||
val = str(val)
|
||||
apply_batch([(kw, val)])
|
||||
n += 1
|
||||
print("restore applied", n, "keys; restarting NIC")
|
||||
print(nic_restart())
|
||||
print(wait_link())
|
||||
print("start bus")
|
||||
print(start_bus())
|
||||
ok, h = wait_ecat_healthy()
|
||||
print("healthy", ok, h)
|
||||
return 0
|
||||
|
||||
exp = args.exp or "itr_off"
|
||||
if exp not in EXPS:
|
||||
print("unknown exp", exp, "use --list-exp")
|
||||
return 2
|
||||
changes = EXPS[exp]
|
||||
|
||||
print(f"\n=== EXP {exp} {changes} ===")
|
||||
print("stop bus...")
|
||||
print(stop_bus())
|
||||
time.sleep(0.3)
|
||||
print("apply...")
|
||||
print(apply_batch(changes))
|
||||
print("restart nic...")
|
||||
print(nic_restart())
|
||||
ok, info = wait_link()
|
||||
print("link", ok, info)
|
||||
print("start bus...")
|
||||
print(start_bus())
|
||||
ok, h = wait_ecat_healthy(timeout=30)
|
||||
print("healthy", ok, h)
|
||||
if not ok:
|
||||
print("ABORT: bus not healthy after change")
|
||||
return 1
|
||||
|
||||
# settle longer: NIC restart + re-enumeration leaves ~2–5s of elevated skip
|
||||
time.sleep(8.0)
|
||||
data, out, err = meter(f"exp_{exp}", args.secs)
|
||||
print(out)
|
||||
if err:
|
||||
print("meter stderr", err[:500])
|
||||
if data:
|
||||
summary = {
|
||||
"exp": exp,
|
||||
"changes": changes,
|
||||
"late_per_s": data.get("late_per_s"),
|
||||
"skip_per_s": data.get("skip_per_s"),
|
||||
"late_frac_pct": data.get("late_frac_pct"),
|
||||
"send_stall_frac": data.get("send_stall_frac"),
|
||||
"rx_stall_frac": data.get("rx_stall_frac"),
|
||||
"achieved_hz": data.get("achieved_hz"),
|
||||
"n_late": data.get("n_late"),
|
||||
}
|
||||
sp = os.path.join(LAB, f"summary_{exp}_{int(time.time())}.json")
|
||||
with open(sp, "w", encoding="utf-8") as f:
|
||||
json.dump(summary, f, ensure_ascii=False, indent=2)
|
||||
print("SUMMARY", json.dumps(summary, ensure_ascii=False))
|
||||
print("WROTE", sp)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""High vs RealTime 多轮 A/B,输出对比。结束后设为 --final 指定档。"""
|
||||
import json, os, subprocess, sys, time
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
|
||||
def ecat_pid():
|
||||
out = subprocess.check_output(
|
||||
["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"],
|
||||
text=True).strip()
|
||||
return int(out.splitlines()[0])
|
||||
|
||||
def set_prio(level):
|
||||
p = ecat_pid()
|
||||
subprocess.check_output(
|
||||
["powershell","-NoProfile","-Command",
|
||||
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"],
|
||||
text=True)
|
||||
return level
|
||||
|
||||
def meter(tag, secs=12):
|
||||
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
|
||||
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
|
||||
def row(level, data, n):
|
||||
return {
|
||||
"round": n, "level": level,
|
||||
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
|
||||
"send_stall_frac": data["send_stall_frac"], "rx_stall_frac": data["rx_stall_frac"],
|
||||
"achieved_hz": data["achieved_hz"], "n_late": data["n_late"],
|
||||
"send_p90": (data.get("window_cyc_us") or {}).get("send",{}).get("p90"),
|
||||
"work_p90": (data.get("window_cyc_us") or {}).get("work",{}).get("p90"),
|
||||
}
|
||||
|
||||
def main():
|
||||
final = "RealTime"
|
||||
if len(sys.argv) > 1:
|
||||
final = sys.argv[1]
|
||||
rows = []
|
||||
# A-B-A-B
|
||||
seq = ["High","RealTime","High","RealTime"]
|
||||
for i, lv in enumerate(seq, 1):
|
||||
set_prio(lv)
|
||||
time.sleep(1.0)
|
||||
data = meter(f"ab_{lv}_{i}", 12)
|
||||
r = row(lv, data, i)
|
||||
rows.append(r)
|
||||
print(r)
|
||||
set_prio(final)
|
||||
print("FINAL", final, "pid", ecat_pid())
|
||||
# summary means
|
||||
by = {}
|
||||
for r in rows:
|
||||
by.setdefault(r["level"], []).append(r)
|
||||
means = {}
|
||||
for lv, rs in by.items():
|
||||
means[lv] = {
|
||||
"n": len(rs),
|
||||
"late_per_s_mean": round(sum(x["late_per_s"] for x in rs)/len(rs), 3),
|
||||
"skip_per_s_mean": round(sum(x["skip_per_s"] for x in rs)/len(rs), 3),
|
||||
"send_p90_mean": round(sum(x["send_p90"] or 0 for x in rs)/len(rs), 1),
|
||||
"work_p90_mean": round(sum(x["work_p90"] or 0 for x in rs)/len(rs), 1),
|
||||
}
|
||||
print("MEANS", json.dumps(means, ensure_ascii=False, indent=2))
|
||||
path = os.path.join(LAB, f"summary_ab_prio_{int(time.time())}.json")
|
||||
json.dump({"rows": rows, "means": means, "final": final}, open(path,"w",encoding="utf-8"),
|
||||
ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""进程优先级对照:不高/RealTime,各采 skip rate。不改网卡、不碰固件。"""
|
||||
import json, os, subprocess, sys, time, urllib.request
|
||||
|
||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
BASE = "http://127.0.0.1:8090"
|
||||
|
||||
def post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(BASE+path, data=data, headers={"Content-Type":"application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
def pid():
|
||||
return post("/api/native/health") # health has no pid; use process name
|
||||
# fallback below
|
||||
|
||||
def ecat_pid():
|
||||
out = subprocess.check_output(
|
||||
["powershell","-NoProfile","-Command",
|
||||
"(Get-Process ecat_native).Id"],
|
||||
text=True).strip()
|
||||
return int(out.splitlines()[0])
|
||||
|
||||
def set_prio(level):
|
||||
p = ecat_pid()
|
||||
cmd = f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"
|
||||
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd], text=True).strip()
|
||||
|
||||
def meter(tag, secs=16):
|
||||
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
|
||||
data = json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) if files else None
|
||||
return data, r.stdout
|
||||
|
||||
def main():
|
||||
p = ecat_pid()
|
||||
print("ecat pid", p)
|
||||
cur = subprocess.check_output(
|
||||
["powershell","-NoProfile","-Command", f"(Get-Process -Id {p}).PriorityClass"],
|
||||
text=True).strip()
|
||||
print("current PriorityClass", cur)
|
||||
|
||||
results = []
|
||||
for level in ("High", "RealTime"):
|
||||
print(f"\n=== set {level} ===")
|
||||
try:
|
||||
print(set_prio(level))
|
||||
except Exception as e:
|
||||
print("set fail", e)
|
||||
continue
|
||||
time.sleep(1.5)
|
||||
data, out = meter(f"prio_{level}", 16)
|
||||
if data:
|
||||
row = {"level": level, "late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
|
||||
"late_frac_pct": data["late_frac_pct"], "send_stall_frac": data["send_stall_frac"],
|
||||
"rx_stall_frac": data["rx_stall_frac"], "achieved_hz": data["achieved_hz"],
|
||||
"n_late": data["n_late"]}
|
||||
results.append(row)
|
||||
print("ROW", row)
|
||||
print(out[-800:] if out else "")
|
||||
|
||||
# restore High (safe default; pdo thread already TIME_CRITICAL)
|
||||
try:
|
||||
print("restore High")
|
||||
print(set_prio("High"))
|
||||
except Exception as e:
|
||||
print("restore fail", e)
|
||||
|
||||
path = os.path.join(LAB, f"summary_prio_{int(time.time())}.json")
|
||||
json.dump({"results": results, "original": cur}, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""静默多档标定:1ms / 2ms / 4ms,每档先停环再设周期再开环,避免混拍。"""
|
||||
import json, os, subprocess, sys, time, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
BASE = "http://127.0.0.1:8090"
|
||||
|
||||
def post(p, b=None):
|
||||
d = json.dumps(b or {}).encode()
|
||||
r = urllib.request.Request(BASE+p, data=d, headers={"Content-Type":"application/json"})
|
||||
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
|
||||
|
||||
def set_prio(level="RealTime"):
|
||||
p = int(subprocess.check_output(["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], text=True).strip())
|
||||
subprocess.check_output(["powershell","-NoProfile","-Command",
|
||||
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], text=True)
|
||||
return p
|
||||
|
||||
def set_period(ms):
|
||||
post("/api/motor/stop")
|
||||
time.sleep(0.2)
|
||||
r = post("/api/motor/period", {"period_ms": ms})
|
||||
time.sleep(0.2)
|
||||
post("/api/motor/start")
|
||||
time.sleep(0.8)
|
||||
return r
|
||||
|
||||
def meter(tag, secs=18):
|
||||
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
|
||||
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
|
||||
def main():
|
||||
print("set RealTime")
|
||||
print("pid", set_prio("RealTime"))
|
||||
rows = []
|
||||
for ms in (1.0, 2.0, 4.0):
|
||||
print(f"\n=== period {ms} ms ===")
|
||||
print(set_period(ms))
|
||||
time.sleep(2.0) # settle
|
||||
data = meter(f"quiet_{ms:g}ms", 18)
|
||||
row = {
|
||||
"period_ms": ms,
|
||||
"late_per_s": data["late_per_s"],
|
||||
"skip_per_s": data["skip_per_s"],
|
||||
"late_frac_pct": data["late_frac_pct"],
|
||||
"achieved_hz": data["achieved_hz"],
|
||||
"n_late": data["n_late"],
|
||||
"send_stall_frac": data["send_stall_frac"],
|
||||
"rx_stall_frac": data["rx_stall_frac"],
|
||||
"work_p50": data["window_cyc_us"]["work"]["p50"],
|
||||
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
|
||||
"send_p50": data["window_cyc_us"]["send"]["p50"],
|
||||
"work_p90": data["window_cyc_us"]["work"]["p90"],
|
||||
}
|
||||
rows.append(row)
|
||||
print(row)
|
||||
|
||||
# restore 1ms as user default
|
||||
print("\nrestore period 1.0")
|
||||
print(set_period(1.0))
|
||||
path = os.path.join(LAB, f"summary_quiet_periods_{int(time.time())}.json")
|
||||
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", path)
|
||||
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,136 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""跳拍率标定:固定时长采 /api/motor/dbg,输出可对比 JSON。
|
||||
|
||||
主判据: late_per_s, skip_per_s, send_stall_frac, rx_stall_frac
|
||||
用法:
|
||||
python scripts/skip_rate_meter.py --tag base --secs 20
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ.get("ECAT_BASE", "http://127.0.0.1:8090")
|
||||
OUTDIR = os.path.join(os.path.dirname(__file__), "..", "lab", "skip_meter")
|
||||
|
||||
|
||||
def post(path: str, body=None, timeout=5.0):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(
|
||||
BASE + path, data=data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return json.loads(r.read().decode())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tag", default="sample")
|
||||
ap.add_argument("--secs", type=float, default=20.0)
|
||||
ap.add_argument("--hz", type=float, default=5.0)
|
||||
ap.add_argument("--out", default=None)
|
||||
args = ap.parse_args()
|
||||
|
||||
st0 = post("/api/motor/state")
|
||||
period = float(st0.get("period_ms") or 1.0)
|
||||
t0 = time.time()
|
||||
pulses = []
|
||||
last_t = None
|
||||
n_poll = 0
|
||||
# dbg t0 uses now_s() (QPC-based), NOT unix time. Track by last-seen t0 only.
|
||||
while time.time() - t0 < args.secs:
|
||||
dbg = post("/api/motor/dbg")
|
||||
n_poll += 1
|
||||
cyc = dbg.get("cyc") or []
|
||||
if last_t is not None:
|
||||
new = [r for r in cyc if r[0] > last_t]
|
||||
else:
|
||||
# first poll: adopt the whole ring's last timestamp as cursor;
|
||||
# count only k>0 from now on via subsequent polls. Also count
|
||||
# k>0 already in ring as "pre-existing" (not in window rate).
|
||||
new = []
|
||||
if cyc:
|
||||
last_t = cyc[-1][0]
|
||||
elif last_t is None:
|
||||
last_t = None
|
||||
for r in new:
|
||||
if len(r) < 8:
|
||||
continue
|
||||
k = int(r[5])
|
||||
if k <= 0:
|
||||
continue
|
||||
send_us = (r[2] - r[1]) * 1e6
|
||||
rx_us = (r[3] - r[2]) * 1e6
|
||||
pulses.append(
|
||||
{
|
||||
"t0": r[0],
|
||||
"k": k,
|
||||
"send_us": send_us,
|
||||
"rx_us": rx_us,
|
||||
"work_us": (r[4] - r[0]) * 1e6,
|
||||
"sd": float(r[6]) if len(r) > 6 else None,
|
||||
"rxc": int(r[7]) if len(r) > 7 else None,
|
||||
"kind": (
|
||||
"send"
|
||||
if send_us >= 500
|
||||
else ("rx" if rx_us >= 500 else "other")
|
||||
),
|
||||
}
|
||||
)
|
||||
time.sleep(max(0.0, 1.0 / args.hz))
|
||||
|
||||
st1 = post("/api/motor/state")
|
||||
wall = time.time() - t0
|
||||
n_late = len(pulses)
|
||||
n_skip = sum(p["k"] for p in pulses)
|
||||
by = {"send": 0, "rx": 0, "other": 0}
|
||||
for p in pulses:
|
||||
by[p["kind"]] += 1
|
||||
|
||||
# approximate expected beats in window
|
||||
# use cycles delta when available
|
||||
cyc0 = st0.get("cycles") or 0
|
||||
cyc1 = st1.get("cycles") or 0
|
||||
dcyc = max(0, cyc1 - cyc0)
|
||||
|
||||
cu = st1.get("cyc_us") or {}
|
||||
result = {
|
||||
"tag": args.tag,
|
||||
"ts": time.time(),
|
||||
"wall_s": round(wall, 3),
|
||||
"period_ms": period,
|
||||
"pcore_mask": st1.get("pcore_mask"),
|
||||
"wkc": st1.get("wkc"),
|
||||
"spin": st1.get("spin"),
|
||||
"cycles_delta": dcyc,
|
||||
"achieved_hz": round(dcyc / wall, 2) if wall > 0 else None,
|
||||
"late_per_s": round(n_late / wall, 3) if wall else None,
|
||||
"skip_per_s": round(n_skip / wall, 3) if wall else None,
|
||||
"late_frac_pct": round(100.0 * n_late / dcyc, 4) if dcyc else None,
|
||||
"skip_frac_pct": round(100.0 * n_skip / dcyc, 4) if dcyc else None,
|
||||
"send_stall_frac": round(by["send"] / n_late, 4) if n_late else None,
|
||||
"rx_stall_frac": round(by["rx"] / n_late, 4) if n_late else None,
|
||||
"n_late": n_late,
|
||||
"n_skip": n_skip,
|
||||
"by_kind": by,
|
||||
"window_cyc_us": cu,
|
||||
"n_poll": n_poll,
|
||||
"pulses_sample": pulses[:40],
|
||||
}
|
||||
|
||||
os.makedirs(OUTDIR, exist_ok=True)
|
||||
out = args.out or os.path.join(OUTDIR, f"{args.tag}_{int(time.time())}.json")
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2)
|
||||
print(json.dumps({k: result[k] for k in result if k != "pulses_sample"},
|
||||
ensure_ascii=False, indent=2))
|
||||
print(f"WROTE {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""重启 ecat_native 进程(让 esp32_server/父进程重拉),测 rx 是否回到 ~90µs。"""
|
||||
import json, os, subprocess, sys, time, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
LAB = os.path.join(ROOT, "lab", "skip_meter")
|
||||
BASE = "http://127.0.0.1:8090"
|
||||
|
||||
def post(p, b=None, timeout=5):
|
||||
d = json.dumps(b or {}).encode()
|
||||
r = urllib.request.Request(BASE+p, data=d, headers={"Content-Type":"application/json"})
|
||||
return json.loads(urllib.request.urlopen(r, timeout=timeout).read().decode())
|
||||
|
||||
def ps(cmd):
|
||||
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
|
||||
text=True, encoding="utf-8", errors="replace").strip()
|
||||
|
||||
def wait_health(timeout=40):
|
||||
t0 = time.time()
|
||||
while time.time()-t0 < timeout:
|
||||
try:
|
||||
h = post("/api/native/health")
|
||||
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3:
|
||||
return True, h
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
return False, None
|
||||
|
||||
def meter(tag, secs=16):
|
||||
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
|
||||
"--tag", tag, "--secs", str(secs)],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
|
||||
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
|
||||
|
||||
def main():
|
||||
# re-enable IP (keep VMware off — it's harmful and was only “enabled” by coincidence in morning)
|
||||
for cid in ("ms_tcpip", "ms_tcpip6"):
|
||||
try:
|
||||
ps(f"Enable-NetAdapterBinding -Name '以太网 2' -ComponentID '{cid}' -Confirm:$false")
|
||||
print("enabled", cid)
|
||||
except Exception as e:
|
||||
print("enable fail", cid, e)
|
||||
|
||||
info = ps("Get-CimInstance Win32_Process -Filter \"Name='ecat_native.exe'\" | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress")
|
||||
print("before", info[:500])
|
||||
|
||||
print("kill ecat_native...")
|
||||
ps("Stop-Process -Name ecat_native -Force -ErrorAction SilentlyContinue")
|
||||
time.sleep(2)
|
||||
|
||||
# parent may be esp32_server — wait for auto-restart
|
||||
ok, h = wait_health(45)
|
||||
print("auto restart?", ok, h)
|
||||
if not ok:
|
||||
print("manual start")
|
||||
exe = r"G:\Robot_v2\DCU\tools\ecat\build_native\ecat_native.exe"
|
||||
cwd = r"G:\Robot_v2\DCU\tools\ecat"
|
||||
subprocess.Popen(
|
||||
[exe, "--substr", "I210", "--mask", "0x50", "--web", "src/web"],
|
||||
cwd=cwd,
|
||||
stdout=open(os.path.join(cwd,"build_native","native_stdout.log"),"a"),
|
||||
stderr=subprocess.STDOUT,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
time.sleep(3)
|
||||
ok, h = wait_health(30)
|
||||
print("manual health", ok, h)
|
||||
if ok:
|
||||
try:
|
||||
post("/api/op/start")
|
||||
post("/api/motor/start")
|
||||
except Exception as e:
|
||||
print("start fail", e)
|
||||
ok, h = wait_health(20)
|
||||
print("after start", ok, h)
|
||||
|
||||
# set RealTime
|
||||
try:
|
||||
p = int(ps("(Get-Process ecat_native).Id"))
|
||||
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
|
||||
print("RealTime", p)
|
||||
except Exception as e:
|
||||
print("prio fail", e)
|
||||
|
||||
print("settle 10s")
|
||||
time.sleep(10)
|
||||
st = post("/api/motor/state")
|
||||
print("cyc", st.get("cyc_us"))
|
||||
print("period", st.get("period_ms"), "wkc", st.get("wkc"), "pcore", st.get("pcore_mask"))
|
||||
|
||||
data = meter("restart_rt", 16)
|
||||
row = {
|
||||
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
|
||||
"achieved_hz": data["achieved_hz"], "n_late": data["n_late"],
|
||||
"work_p50": data["window_cyc_us"]["work"]["p50"],
|
||||
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
|
||||
"send_p50": data["window_cyc_us"]["send"]["p50"],
|
||||
"work_p90": data["window_cyc_us"]["work"]["p90"],
|
||||
"send_stall_frac": data["send_stall_frac"],
|
||||
"rx_stall_frac": data["rx_stall_frac"],
|
||||
}
|
||||
print(json.dumps(row, ensure_ascii=False, indent=2))
|
||||
out = os.path.join(LAB, f"summary_restart_{int(time.time())}.json")
|
||||
json.dump(row, open(out,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
|
||||
print("WROTE", out)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,335 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* 从 git worktree 启动 esp32_server 时,ECAT_DIR 必须指回主检出的
|
||||
* tools/ecat,而不是 .../ESP32/.worktrees/ecat。
|
||||
* 复刻 _resolve_ecat_dir 的纯路径逻辑(无 DOM/无网络)。
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** 与 esp32_server._resolve_ecat_dir 同构(JS 侧只测路径形状)。 */
|
||||
function resolveEcatDir(baseDir) {
|
||||
let d = path.resolve(baseDir);
|
||||
const parent = path.dirname(d);
|
||||
if (path.basename(parent) === ".worktrees") {
|
||||
d = path.dirname(parent);
|
||||
}
|
||||
return path.normalize(path.join(d, "..", "ecat"));
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
function check(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(" ✓", name);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(" ✗", name, "—", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[worktree ECAT_DIR]");
|
||||
|
||||
check("主检出 BASE_DIR → 兄弟 tools/ecat", () => {
|
||||
const base = path.resolve("G:/Robot_v2/DCU/tools/ESP32");
|
||||
const got = resolveEcatDir(base);
|
||||
assert.equal(got, path.resolve("G:/Robot_v2/DCU/tools/ecat"), got);
|
||||
});
|
||||
|
||||
check("worktree BASE_DIR → 仍指主检出的 tools/ecat", () => {
|
||||
const base = path.resolve("G:/Robot_v2/DCU/tools/ESP32/.worktrees/device-link");
|
||||
const got = resolveEcatDir(base);
|
||||
assert.equal(got, path.resolve("G:/Robot_v2/DCU/tools/ecat"), got);
|
||||
});
|
||||
|
||||
check("嵌套 slug 也走 .worktrees 父级", () => {
|
||||
const base = path.resolve("G:/x/tools/ESP32/.worktrees/foo-bar");
|
||||
const got = resolveEcatDir(base);
|
||||
assert.equal(got, path.resolve("G:/x/tools/ecat"), got);
|
||||
});
|
||||
|
||||
/* BASE_DIR 恒为 esp32_server.py 所在目录;普通主检出已在第一项覆盖。 */
|
||||
|
||||
if (failed) {
|
||||
console.log(`失败 ${failed} 项`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("通过 3 项,失败 0 项");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,238 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""test_pram_batch_sim.py - 主机侧仿真:验证 pramRead/pramWrite 批量化(1.6.0)
|
||||
与旧实现(逐 DWORD 轮询)逐字节等价。
|
||||
|
||||
模型依据 LAN9252 DS00001909A §12.11.3/12.11.4:
|
||||
- PRAM FIFO 16 deep x 32bit;AVAIL_CNT(CMD bits12:8)=可连续读/写的 DWORD 数
|
||||
- 读引擎按 DWORD 对齐向下取源:第 i 个 FIFO DWORD = 源地址 aligned(addr)+4i 起 4 字节;
|
||||
线性有效窗口 = [0, len),即 DWORD i 的字节 k 有效 iff 0 <= 4i+k-lead < len
|
||||
- 写引擎按同一线性窗口落盘,主站推满一个 DWORD 计一次
|
||||
- 引擎以有限速率搬运(rate),主站 respect AVAIL_CNT 就不会溢 FIFO/读空洞
|
||||
|
||||
对全部 (addr 对齐 x len 1..70/96/384/640 x 引擎节奏) 断言:
|
||||
新旧算法产出逐字节一致,且主站从不超出 AVAIL_CNT(引擎内 assert)。
|
||||
"""
|
||||
import random
|
||||
|
||||
FIFO_DW = 16 # FIFO 深度 16 DWORD
|
||||
WINDOW_DW = 8 # 别名窗口一程 8 DWORD (0x00-0x1C / 0x20-0x3C)
|
||||
PRAM_BASE = 0x1000
|
||||
PRAM_SIZE = 0x1000
|
||||
|
||||
|
||||
def byte_valid(i, k, lead, ln):
|
||||
"""DWORD i 的字节 k 是否落在线性有效窗口 [0, ln) 内。"""
|
||||
return 0 <= 4 * i + k - lead < ln
|
||||
|
||||
|
||||
class ReadEngine:
|
||||
def __init__(self, mem, addr, ln, rate):
|
||||
self.mem = mem
|
||||
self.lead = addr & 3
|
||||
self.ln = ln
|
||||
self.rate = max(1, rate)
|
||||
self.total_dw = (ln + self.lead + 3) // 4
|
||||
self.start = (addr & ~3) - PRAM_BASE # mem 内偏移
|
||||
self.pushed = 0
|
||||
self.popped = 0
|
||||
self.data = []
|
||||
|
||||
def pump(self):
|
||||
n = 0
|
||||
while (self.pushed - self.popped < FIFO_DW and self.pushed < self.total_dw
|
||||
and n < self.rate):
|
||||
i = self.pushed
|
||||
dw = bytearray(4)
|
||||
for k in range(4):
|
||||
if byte_valid(i, k, self.lead, self.ln):
|
||||
dw[k] = self.mem[self.start + 4 * i + k]
|
||||
self.data.append(bytes(dw))
|
||||
self.pushed += 1
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def avail_cnt(self):
|
||||
return self.pushed - self.popped
|
||||
|
||||
def pop(self):
|
||||
assert self.popped < self.pushed, "主站弹出超出 AVAIL_CNT!"
|
||||
dw = self.data[self.popped]
|
||||
self.popped += 1
|
||||
return dw
|
||||
|
||||
|
||||
def pram_read_old(eng, addr, ln, poll_budget=2000):
|
||||
out = bytearray()
|
||||
done = 0
|
||||
polls = 0
|
||||
while done < ln:
|
||||
while eng.avail_cnt() == 0:
|
||||
polls += 1
|
||||
if polls > poll_budget:
|
||||
return bytes(out), False
|
||||
eng.pump()
|
||||
dw = eng.pop()
|
||||
lead = (addr & 3) if done == 0 else 0
|
||||
take = min(ln - done, 4 - lead)
|
||||
out += dw[lead:lead + take]
|
||||
done += take
|
||||
eng.pump()
|
||||
return bytes(out), True
|
||||
|
||||
|
||||
def pram_read_new(eng, addr, ln, poll_budget=2000):
|
||||
out = bytearray()
|
||||
done = 0
|
||||
polls = 0
|
||||
while done < ln:
|
||||
while eng.avail_cnt() == 0:
|
||||
polls += 1
|
||||
if polls > poll_budget:
|
||||
return bytes(out), False
|
||||
eng.pump()
|
||||
batch = eng.avail_cnt()
|
||||
if batch == 0 or batch > WINDOW_DW:
|
||||
batch = 1
|
||||
lead0 = (addr & 3) if done == 0 else 0
|
||||
need_dw = (ln - done + lead0 + 3) // 4
|
||||
batch = min(batch, need_dw)
|
||||
for _ in range(batch):
|
||||
dw = eng.pop()
|
||||
lead = (addr & 3) if done == 0 else 0
|
||||
take = min(ln - done, 4 - lead)
|
||||
out += dw[lead:lead + take]
|
||||
done += take
|
||||
eng.pump()
|
||||
return bytes(out), True
|
||||
|
||||
|
||||
class WriteEngine:
|
||||
def __init__(self, addr, ln, rate):
|
||||
self.lead = addr & 3
|
||||
self.ln = ln
|
||||
self.rate = max(1, rate)
|
||||
self.total_dw = (ln + self.lead + 3) // 4
|
||||
self.start = (addr & ~3) - PRAM_BASE
|
||||
self.fifo = []
|
||||
self.consumed_dw = 0
|
||||
self.mem = bytearray(PRAM_SIZE)
|
||||
|
||||
def pump(self):
|
||||
n = 0
|
||||
while self.fifo and n < self.rate:
|
||||
dw = self.fifo.pop(0)
|
||||
i = self.consumed_dw
|
||||
for k in range(4):
|
||||
if byte_valid(i, k, self.lead, self.ln):
|
||||
self.mem[self.start + 4 * i + k] = dw[k]
|
||||
self.consumed_dw += 1
|
||||
n += 1
|
||||
return n
|
||||
|
||||
def space_cnt(self):
|
||||
return FIFO_DW - len(self.fifo)
|
||||
|
||||
def push(self, dw):
|
||||
assert len(self.fifo) < FIFO_DW, "主站写溢出 FIFO!"
|
||||
self.fifo.append(bytes(dw))
|
||||
|
||||
|
||||
def _write_common(eng, addr, buf, poll_budget, batched):
|
||||
ln = len(buf)
|
||||
done = 0
|
||||
polls = 0
|
||||
while done < ln:
|
||||
while eng.space_cnt() == 0:
|
||||
polls += 1
|
||||
if polls > poll_budget:
|
||||
return False
|
||||
eng.pump()
|
||||
n_iter = 1
|
||||
if batched:
|
||||
batch = eng.space_cnt()
|
||||
if batch == 0 or batch > WINDOW_DW:
|
||||
batch = 1
|
||||
lead0 = (addr & 3) if done == 0 else 0
|
||||
need_dw = (ln - done + lead0 + 3) // 4
|
||||
n_iter = min(batch, need_dw)
|
||||
for _ in range(n_iter):
|
||||
lead = (addr & 3) if done == 0 else 0
|
||||
take = min(ln - done, 4 - lead)
|
||||
dw = bytearray(4)
|
||||
for i in range(take):
|
||||
dw[lead + i] = buf[done + i]
|
||||
eng.push(dw)
|
||||
done += take
|
||||
eng.pump()
|
||||
budget = poll_budget
|
||||
while eng.consumed_dw < eng.total_dw and budget:
|
||||
eng.pump()
|
||||
budget -= 1
|
||||
return eng.consumed_dw == eng.total_dw
|
||||
|
||||
|
||||
def pram_write_old(eng, addr, buf, poll_budget=2000):
|
||||
return _write_common(eng, addr, buf, poll_budget, batched=False)
|
||||
|
||||
|
||||
def pram_write_new(eng, addr, buf, poll_budget=2000):
|
||||
return _write_common(eng, addr, buf, poll_budget, batched=True)
|
||||
|
||||
|
||||
def run():
|
||||
random.seed(20260905)
|
||||
cases = 0
|
||||
lens = list(range(1, 71)) + [96, 384, 640] # 覆盖 SM2(384B)/SM3(640B) 实际长度
|
||||
for align in range(4):
|
||||
for ln in lens:
|
||||
for rate in (1, 3, 16, 64):
|
||||
src = bytes(random.randrange(1, 256) for _ in range(ln))
|
||||
base = random.randrange(PRAM_SIZE - ln - 8)
|
||||
addr = PRAM_BASE + base + align
|
||||
mem = bytearray(PRAM_SIZE)
|
||||
mem[base + align: base + align + ln] = src
|
||||
# 读等价:新旧都须完整取回 src
|
||||
e1 = ReadEngine(mem, addr, ln, rate); e1.pump()
|
||||
r_old, ok1 = pram_read_old(e1, addr, ln)
|
||||
e2 = ReadEngine(mem, addr, ln, rate); e2.pump()
|
||||
r_new, ok2 = pram_read_new(e2, addr, ln)
|
||||
assert ok1 and ok2, f"read 未完成 align={align} len={ln} rate={rate}"
|
||||
assert r_old == src, f"read 旧实现不符 align={align} len={ln} rate={rate}"
|
||||
assert r_new == src, f"read 新实现不符 align={align} len={ln} rate={rate}"
|
||||
# 写等价:新旧都须把 src 落到同一片 PRAM
|
||||
w1 = WriteEngine(addr, ln, rate); w1.pump()
|
||||
assert pram_write_old(w1, addr, src), f"write 未完成 {align}/{ln}/{rate}"
|
||||
w2 = WriteEngine(addr, ln, rate); w2.pump()
|
||||
assert pram_write_new(w2, addr, src), f"write 未完成 {align}/{ln}/{rate}"
|
||||
assert bytes(w1.mem[base + align: base + align + ln]) == src, \
|
||||
f"write 旧不符 {align}/{ln}/{rate}"
|
||||
assert bytes(w2.mem[base + align: base + align + ln]) == src, \
|
||||
f"write 新不符 {align}/{ln}/{rate}"
|
||||
cases += 1
|
||||
print(f"OK: {cases} 组 (对齐x长度x引擎节奏) 新旧算法逐字节等价")
|
||||
# 统计新实现的轮询次数收益(代表性场景:SM3 640B DWORD 对齐,引擎速率 16)
|
||||
for rate in (1, 3, 16):
|
||||
src = bytes(random.randrange(1, 256) for _ in range(640))
|
||||
e = ReadEngine(bytearray(PRAM_SIZE), PRAM_BASE, 640, rate); e.pump()
|
||||
_, p1 = _count_polls(e, 640)
|
||||
print(f" 640B@rate{rate}: 新算法主站外圈轮询 {p1} 次")
|
||||
|
||||
|
||||
def _count_polls(eng, ln):
|
||||
done = 0
|
||||
polls = 0
|
||||
while done < ln:
|
||||
while eng.avail_cnt() == 0:
|
||||
polls += 1
|
||||
eng.pump()
|
||||
batch = min(eng.avail_cnt(), WINDOW_DW)
|
||||
lead0 = 0 if done == 0 else 0
|
||||
batch = min(batch, (ln - done + lead0 + 3) // 4)
|
||||
for _ in range(batch):
|
||||
eng.pop()
|
||||
done += 4
|
||||
return None, polls
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in New Issue
Block a user