"""Analyse an EPS bench session: when did the unit come online, and did it stay? Reads captures/eps_bench_*.csv (t, dir, arbitration_id, dlc, data_hex - both directions) plus the matching dio_bench_*.csv, and reports: - when the 12V enable was applied vs. when the EPS first answered - which EPS IDs appeared, and in what order - dropout detection: gaps where the EPS stopped transmitting, which is what "it becomes intermittent" looks like in the data - transmit health: whether our own frames kept to their intended period, since a bench that stalls looks identical to an EPS that drops out Usage: python tools/analyze_bench_session.py captures/eps_bench_20260829_183208.csv """ import argparse import csv from collections import defaultdict from pathlib import Path EPS_IDS = {0x1FB, 0x4B0, 0x5B0} def load(path: Path): tx, rx = [], [] wall0 = None with open(path, newline="") as f: for row in csv.DictReader(f): wall = float(row["wall"]) if row.get("wall") else None if wall is not None and wall0 is None: wall0 = wall rec = (float(row["t"]), wall, int(row["arbitration_id"], 16), bytes.fromhex(row["data_hex"])) (tx if row["dir"] == "tx" else rx).append(rec) return tx, rx, wall0 def load_dio(path: Path, wall0): """Returns transitions on the bench log's time base. The two logs start their relative clocks at different moments (the IO monitor at Connect, the bench at Start TX), so a shared wall clock is the only honest way to line them up. Older logs without a 'wall' column can't be aligned - flagged rather than silently mis-reported. """ events, aligned = [], False if not path.exists(): return events, aligned prev = None with open(path, newline="") as f: for row in csv.DictReader(f): outs = int(row["out1"]) | (int(row["out2"]) << 1) ins = sum(int(row[f"in{i+1}"]) << i for i in range(4)) if row.get("wall") and wall0 is not None: t = float(row["wall"]) - wall0 aligned = True else: t = float(row["t"]) if (ins, outs) != prev: events.append((t, ins, outs)) prev = (ins, outs) return events, aligned def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("bench_log") ap.add_argument("--gap", type=float, default=1.0, help="Silence longer than this counts as a dropout") a = ap.parse_args() path = Path(a.bench_log) tx, rx, wall0 = load(path) dio, aligned = load_dio(path.parent / path.name.replace("eps_bench_", "dio_bench_"), wall0) if not tx and not rx: print("Empty log.") return 1 duration = max((tx[-1][0] if tx else 0), (rx[-1][0] if rx else 0)) print(f"session {duration:.1f}s · {len(tx)} frames sent · {len(rx)} received\n") if dio: note = "" if aligned else " (NOT time-aligned - log predates the shared wall clock)" print(f"Digital IO transitions (OUT1 = 12V enable to EPS){note}:") for t, ins, outs in dio: print(f" t={t:7.2f}s IN1={ins & 1} OUT1={outs & 1}") print() eps_rx = [(t, aid, d) for t, _, aid, d in rx if aid in EPS_IDS] if not eps_rx: print("The EPS never transmitted in this session.") return 0 enable_t = next((t for t, _, outs in dio if outs & 1), None) first = eps_rx[0][0] print(f"EPS first answered at t={first:.2f}s") if enable_t is not None and aligned: print(f" 12V enable applied at t={enable_t:.2f}s -> {first - enable_t:.2f}s to come online") elif enable_t is not None: print(f" 12V enable at t={enable_t:.2f}s on its own clock - not comparable, see note above") print() print("EPS IDs, in order of first appearance:") seen = {} for t, aid, _ in eps_rx: seen.setdefault(aid, t) for aid, t in sorted(seen.items(), key=lambda kv: kv[1]): n = sum(1 for _, a, _ in eps_rx if a == aid) print(f" t={t:7.2f}s 0x{aid:03X} ({n} frames)") print() # Dropouts: any window where nothing came back from the EPS at all. print(f"EPS dropouts (silence > {a.gap}s after it was already online):") gaps, prev = [], first for t, _, _ in eps_rx[1:]: if t - prev > a.gap: gaps.append((prev, t)) prev = t if duration - prev > a.gap: gaps.append((prev, duration)) if gaps: for s, e in gaps: print(f" t={s:7.2f}s -> {e:7.2f}s ({e - s:.2f}s silent)") else: print(" none - it stayed online for the whole session") print() # Our own transmit health over the same windows: if we stalled, that's # our bug, not the EPS dropping out. print("Our transmit rate per 5s window (spot bench stalls vs. EPS faults):") buckets = defaultdict(int) for t, _, _, _ in tx: buckets[int(t // 5)] += 1 rx_buckets = defaultdict(int) for t, aid, _ in eps_rx: rx_buckets[int(t // 5)] += 1 for b in sorted(buckets): bar = "#" * min(40, buckets[b] // 25) print(f" t={b*5:4d}-{b*5+5:<4d}s tx={buckets[b]:6d} eps_rx={rx_buckets.get(b, 0):5d} {bar}") return 0 if __name__ == "__main__": raise SystemExit(main())