#!/usr/bin/env python """闭环碰撞定位:第一次碰撞发生在第几秒、碰撞时最近的是哪两条连杆(J几-J几)。 用法(仓库根目录): PYTHONUTF8=1 python -m ml.eval.diag_collision --ckpt ml/runs/base/last.pt --n 512 --out ml/runs/diag_collision.json 口径与 closed_loop.rollout 一致:每步同时检查当前状态 q 与相邻状态中点 mid 的间隙(cap=0.5 mm); 间隙组对索引 = gA*7+gB(gA 0).nonzero().flatten().tolist(): it = int(round(float(per['first_hit'][b]) / DT)) prev, q = per['traj'][b, it - 1:it + 1] mid = prev + 0.5 * (q - prev) points = torch.stack([per['traj'][b, 0], prev, mid, q]) gm32 = k32.gaps(points, 0.5)['gm'] gm64 = k64.gaps(points.double(), 0.5)['gm'] gap32, gp32 = gm32.min(-1) gap64, gp64 = gm64.min(-1) nseg = max(1, math.ceil(float((q - prev).abs().max()) / 0.01)) dense = prev.double() + torch.linspace(0, 1, nseg + 1, dtype=torch.float64)[:, None] * (q.double() - prev.double()) dense_gap = torch.cat([k64.gaps(chunk, 0.5)['gm'].min(-1).values for chunk in dense.split(32)]) boundary = (~k32.cont) & ((q - k32.lo).abs().lt(1e-5) | (q - k32.hi).abs().lt(1e-5)) row = { 'i': offset + b, 'task': saved['tasks'][offset + b], 'first_hit_s': float(per['first_hit'][b]), 'dls_it': int(per['dls_it'][b]), 'point_order': ['q0', 'previous', 'midpoint', 'endpoint'], 'gap32_mm': gap32.tolist(), 'gap64_mm': gap64.tolist(), 'pair32': gp32.tolist(), 'pair64': gp64.tolist(), 'boundary_joints': (boundary.nonzero().flatten() + 1).tolist(), 'previous': prev.tolist(), 'endpoint': q.tolist(), 'dense_nsegments': nseg, 'dense_gap64_min_mm': float(dense_gap.min()), } rows.append(row) print('task %d first_hit %.2fs gap32 %s gap64 %s boundary %s' % (row['i'], row['first_hit_s'], row['gap32_mm'], row['gap64_mm'], row['boundary_joints']), flush=True) offset += count result = { 'trace': path, 'n': offset, 'reported_collision_tasks': len(rows), 'replayed_first_hits32': sum(min(r['gap32_mm'][2:]) <= 0 for r in rows), 'replayed_first_hits64': sum(min(r['gap64_mm'][2:]) <= 0 for r in rows), 'scope': 'Replay reported first hits only; same geometry kernel at float32/float64, not a full safety audit.', 'rows': rows, } if out: with open(out, 'w', encoding='utf-8') as f: json.dump(result, f, indent=2, allow_nan=False) print('replayed first hits: %d/%d float32, %d/%d float64' % (result['replayed_first_hits32'], len(rows), result['replayed_first_hits64'], len(rows)), flush=True) return result def main(): ap = argparse.ArgumentParser() ap.add_argument('--ckpt') ap.add_argument('--trace') ap.add_argument('--samples', default='ml/data/samples') ap.add_argument('--split', default='val') ap.add_argument('--n', type=int, default=512) ap.add_argument('--bs', type=int, default=64) ap.add_argument('--chunk', type=int, default=64, help='回放轨迹分块步数(显存小就调小)') ap.add_argument('--K', type=int, default=8) ap.add_argument('--cap', type=float, default=0.5) ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu') ap.add_argument('--out', default=None) a = ap.parse_args() if a.trace: replay_first_hits(a.trace, a.out) return if not a.ckpt: ap.error('--ckpt or --trace is required') ck = torch.load(a.ckpt, map_location=a.device) model = build(ck['cfg']).to(a.device) model.load_state_dict(ck['model']) model.eval() kin = Kin(device=a.device) if ck.get('fp') and ck['fp'] != kin.fp: sys.exit(f'checkpoint 指纹 {ck["fp"]} 与 kin.json {kin.fp} 不符') tasks = [t for t in load_tasks([a.samples]) if t['split'] == a.split][:a.n] PN = pair_names() rows = [] tau = [0, 0.5, 1.0, 2.0, 3.0, 5.0, 1e9] hist = Counter() pair_first = Counter(); pair_steps = Counter() with torch.no_grad(): for i in range(0, len(tasks), a.bs): chunk = tasks[i:i + a.bs] res, per = rollout(model, kin, chunk, a.device, K=a.K, safety=False, seed=i, return_traj=True) traj = per['traj'].to(a.device) # [B,T,6] B, Tn, _ = traj.shape first_t = torch.full((B,), float('nan'), device=a.device) first_pair = torch.full((B,), -1, dtype=torch.long, device=a.device) first_gap = torch.full((B,), float('nan'), device=a.device) min_gap = torch.full((B,), float('inf'), device=a.device) col_steps = torch.zeros(B, device=a.device) for k in range(Tn): qk = traj[:, k] mid = 0.5 * (traj[:, max(0, k - 1)] + qk) pts = torch.stack([qk, mid], 1).reshape(2 * B, 6) # 每帧只喂 2B 个点(与 rollout 同量级;一次 2048 点会爆显存) gm = kin.gaps(pts, a.cap)['gm'].view(B, 2, 49) gmin, gpair = gm.min(-1) # [B,2] g1 = gmin.min(1).values min_gap = torch.minimum(min_gap, g1) newly = (g1 <= 0) & torch.isnan(first_t) if bool(newly.any()): for b in newly.nonzero().flatten().tolist(): pos = 0 if float(gmin[b, 0]) <= 0 else 1 first_t[b] = k * DT first_pair[b] = gpair[b, pos] first_gap[b] = gmin[b, pos] if not bool(torch.isnan(first_t).any()): break for b, t in enumerate(chunk): ft = float(first_t[b]) fp = int(first_pair[b]) name = PN.get(fp, '—') if not math.isnan(ft): pair_first[name] += 1 for j in range(len(tau) - 1): if tau[j] <= ft < tau[j + 1]: hist[f'{tau[j]}–{tau[j + 1]} s' if tau[j + 1] < 1e8 else '> 5 s'] += 1 break rows.append({ 'tid': t.get('tid'), 'cat': t.get('cat'), 'mode': t.get('mode'), 'collided': bool(per['col'][b] > 0), 'first_collision_s': None if math.isnan(ft) else round(ft, 2), 'first_pair': None if name == '—' else name, 'first_pair_idx': None if fp < 0 else fp, 'first_gap_mm': None if math.isnan(float(first_gap[b])) else round(float(first_gap[b]), 3), 'min_gap_mm': round(float(min_gap[b]), 3), 'collision_steps': int(per['col'][b]), }) print(f'[diag] {min(i + a.bs, len(tasks))}/{len(tasks)} 已回放', flush=True) collided = [r for r in rows if r['collided']] def pct(v, p): v = sorted(v); return v[min(len(v) - 1, int(len(v) * p))] if v else None times = [r['first_collision_s'] for r in collided] out = { 'ckpt': a.ckpt, 'step': ck.get('step'), 'n': len(rows), 'n_collided': len(collided), 'collision_rate': len(collided) / max(1, len(rows)), 'first_time_s': {'p10': pct(times, .1), 'p50': pct(times, .5), 'p90': pct(times, .9)}, 'first_time_hist': dict(hist), 'first_pair_top': pair_first.most_common(), 'min_gap_mm_p10/p50': [pct([r['min_gap_mm'] for r in rows], .1), pct([r['min_gap_mm'] for r in rows], .5)], 'by_cat': {}, 'tasks': rows, } for k in sorted({f"{r['cat']}/{r['mode']}" for r in rows}): g = [r for r in rows if f"{r['cat']}/{r['mode']}" == k] out['by_cat'][k] = {'n': len(g), 'first_t_p50': pct([r['first_collision_s'] for r in g if r['collided']], .5), 'first_pairs': Counter([r['first_pair'] for r in g if r['first_pair']]).most_common(3)} s = json.dumps(out, indent=1, ensure_ascii=False) if a.out: open(a.out + '.tmp', 'w', encoding='utf-8').write(s); os.replace(a.out + '.tmp', a.out) print('== 汇总 ==') print('题数 %d,撞过 %d(%.1f%%)' % (len(rows), len(collided), 100 * out['collision_rate'])) print('首次碰撞时间 p10/p50/p90 = %s / %s / %s s' % ( out['first_time_s']['p10'], out['first_time_s']['p50'], out['first_time_s']['p90'])) print('首次碰撞时间分布:', out['first_time_hist']) print('首次碰撞的连杆对 Top:', pair_first.most_common(8)) print('全程最小间隙 p10/p50 = %s / %s mm' % tuple(out['min_gap_mm_p10/p50'])) if a.out: print('已写', a.out) if __name__ == '__main__': main()