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