Files
DCU_DesktopController/scripts/anal_ecat_jumps.py
T

189 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()