Files
DCU_DesktopController/scripts/anal_ecat_jumps.py
T

189 lines
7.9 KiB
Python
Raw Normal View History

#!/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()