Files
Robot_Arm/ml/sim/closed_loop.py
T

345 lines
22 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.
"""闭环仿真(验证 / 挖错 / 门禁共用,PyTorch 批量,无梯度)——按部署节奏运行:
50 Hz 执行;队列 ≤ 3 步时推理一次(已提交前缀 r = 队列长度),提交接下来 3 步;
冷启动用价值头对候选末态打分(短前瞻),之后按与上一条**真正提交**的计划的一致性选候选;
接近目标 < 5 mm 时用局部 DLS 精修;子目标头在新目标时给出途经点,按「越过顶点」规则切下一个(与标签同口径,见 sub_advance)。
safety=False 时不做任何碰撞拦截(暴露真实错误,挖错用);safety=True 时近似部署安全层(候选提交步有干涉则换下一条,全不过则停一步)。
动作反归一化:inc = clamp(act, −1, 1) · act_scale · STEP;act_scale 来自 checkpoint(旧模型缺省 1.0,见 ml/common/ckpt.load_policy)。
碰撞与到达只在每题自己的时限内计(短题与长题同批时不把超时后的部分算进去)。
python -m ml.sim.closed_loop --ckpt ml/runs/base/best.pt --samples ml/data/samples --split val --n 1000 [--safety]"""
import argparse, json, math, os
import torch
from ml.common.kin_torch import Kin
from ml.common.features import features, STEP, VMAX, H, GAP_CAP, D2R, wrap180
DT = 0.02
FIRST_DEG = 5.0 # R7 首次方向判定阈值(度):小于它的抖动不算选边
SUB_LEAD_S = 0.02 # 子目标「越过顶点」提前量(s):标签在老师 τ > τ_顶点 − 0.02 时就换到下一个顶点(build_dataset.cjs remain 过滤)
SUB_REACH_DEG, SUB_TIMEOUT_S = 5.0, 1.5 # 旧规则(sub_rule='timeout'):到达 < 5° 或 1.5 s 超时
def tasks_to_tensors(tasks, device):
q0 = torch.tensor([t['q0'] for t in tasks], dtype=torch.float32, device=device)
T = torch.tensor([t['T'] for t in tasks], dtype=torch.float32, device=device)
R = torch.tensor([t['R'] if t.get('R') else [0.] * 9 for t in tasks], dtype=torch.float32, device=device)
# 无老师参考(挖错/门禁题)时 exec_s/travel 为 None → NaN;调用方据此区分口径
ex = torch.tensor([t['exec_s'] if t.get('exec_s') else float('nan') for t in tasks], dtype=torch.float32, device=device)
tr = torch.tensor([t['travel'] if t.get('travel') else float('nan') for t in tasks], dtype=torch.float32, device=device)
return q0, T, R, ex, tr
NOREF_SEC = 6.0 # 无老师参考题的定长时限(s)
def time_limit(ex, max_sec=None):
"""每题时限(s):有老师 = 老师时长 × 2(至少 3 s);无老师 = NOREF_SEC;max_sec 覆盖全部。"""
if max_sec is not None: return torch.full_like(ex, max_sec)
return torch.where(torch.isfinite(ex), torch.clamp(2 * ex, min=3.0), torch.full_like(ex, NOREF_SEC))
def goal_err(kin, q, T, R):
xf = kin.cum_xf(q)
p = kin.fk(q, xf); Rq = kin.orient(q, xf).reshape(-1, 9)
e = (T - p).norm(dim=-1)
pose = R.abs().sum(-1) > 0
tr = (Rq * R).sum(-1)
oe = torch.where(pose, torch.acos(((tr - 1) / 2).clamp(-1, 1)) / D2R, torch.zeros_like(e))
return e, oe
def dls_steps(kin, q, T, R, n=3, scale=0.3, lam=0.5):
"""局部 DLS 精修:n 步增量 [B,n,6]"""
step = torch.tensor(STEP, device=q.device)
pose = (R.abs().sum(-1) > 0).float()
Rt = R.view(-1, 3, 3)
def err(x):
xf = kin.cum_xf(x); p = kin.fk(x, xf); Rq = kin.orient(x, xf)
M = Rt @ Rq.transpose(1, 2)
rv = 0.5 * torch.stack([M[:, 2, 1] - M[:, 1, 2], M[:, 0, 2] - M[:, 2, 0], M[:, 1, 0] - M[:, 0, 1]], -1) * 100 * pose[:, None]
return torch.cat([T - p, rv], -1)
out = []; x = q.clone()
for _ in range(n):
e0 = err(x); J = []
for j in range(6):
dx = torch.zeros_like(x); dx[:, j] = 1e-3
J.append(-(err(x + dx) - e0) / 1e-3)
J = torch.stack(J, -1) # [B,6(err),6(q)]
A = J @ J.transpose(1, 2) + lam ** 2 * torch.eye(6, device=q.device)
y = torch.linalg.solve(A, e0.unsqueeze(-1))
dq = (J.transpose(1, 2) @ y).squeeze(-1)
sc = (dq.abs() / (step * scale)).max(-1, keepdim=True).values.clamp(min=1)
dq = dq / sc
out.append(dq); x = x + dq
return torch.stack(out, 1)
def jdiff(kin, a, b):
"""a − b(度),连续轴取最短弧。"""
d = a - b
return torch.where(kin.cont, wrap180(d), d)
def sub_advance(kin, subs, nsub, sub_prev, sub_t, qe, tnow, rule='cross'):
"""子目标推进(每次规划前调用,每次最多推进一个)。subs [B,M,6] 待走途经点(绝对角),nsub [B] 剩余个数,
sub_prev [B,6] 上一个已越过的点(冷启动时 = 起点),sub_t [B] 当前子目标开始时刻。返回 (subs, nsub, sub_prev, sub_t)。
rule='cross'(默认):qe 沿拐角平分面法向越过当前顶点(提前 SUB_LEAD_S 秒)即切到下一个。
2026-10-19(审查 review_data_actions C5 / review_model_train P0-1):标签里 sub = 老师「下一个尚未经过的顶点」,
老师 τ 一过顶点(−0.02 s)立刻换;旧规则要等最大关节差 < 5° 或 1.5 s 超时,策略在顶点附近 5–10° 进不去时
sub 一直指着身后的点,这种「sub 已在身后」的特征序列训练里从未出现。运行时没有老师 τ,用几何等价物:
进入方向 a = 顶点 − 上一点、离开方向 b = 下一顶点 − 顶点(最后一个顶点取 a),各轴按 VMAX 折成秒(与 τ 同量纲),
法向 n = â + b̂;(qe − 顶点)·n̂ ≥ −SUB_LEAD_S 判为越过。没有超时(标签也没有)。
rule='timeout':旧部署口径,到达 < 5° 或 1.5 s 超时(诊断对照用)。
ik-policy.js Runner(subRule)与 apg_finetune.py 用同一规则,三处同步改。"""
has = nsub > 0
if not bool(has.any()): return subs, nsub, sub_prev, sub_t
cur = subs[:, 0]
if rule == 'timeout':
dd = jdiff(kin, cur, qe).abs().max(-1).values
adv = has & ((dd < SUB_REACH_DEG) | (tnow - sub_t > SUB_TIMEOUT_S))
elif rule == 'cross':
vmax = torch.tensor(VMAX, device=qe.device, dtype=qe.dtype)
un = lambda v: v / v.norm(dim=-1, keepdim=True).clamp(min=1e-9)
a = jdiff(kin, cur, sub_prev) / vmax
nxt = subs[:, 1] if subs.shape[1] > 1 else cur
b = torch.where((nsub > 1)[:, None], jdiff(kin, nxt, cur) / vmax, a)
n = un(un(a) + un(b))
proj = (jdiff(kin, qe, cur) / vmax * n).sum(-1)
adv = has & (proj >= -SUB_LEAD_S)
else:
raise ValueError('sub_rule: ' + str(rule))
subs = torch.where(adv[:, None, None], torch.cat([subs[:, 1:], subs[:, -1:]], 1), subs)
sub_prev = torch.where(adv[:, None], cur, sub_prev)
nsub = torch.where(adv, nsub - 1, nsub); sub_t = torch.where(adv, torch.full_like(sub_t, tnow), sub_t)
return subs, nsub, sub_prev, sub_t
class TeacherOracle:
"""老师当策略(2026-10-19,审查 review_closedloop D1 / review_data_actions §4.1):
规划时从当前时间下标 it 取老师 50 Hz 轨迹的 16 步增量,按与 build_dataset.cjs 相同的标签口径
act = clamp(Δ / (act_scale·STEP), −1, 1) 交给 rollout(),其余(队列、前缀、提交、qe、DLS、done、碰撞、时限)一行不改。
traj [B,Tmax,6]:每题老师轨迹,末帧重复补齐(按 id 从 ml/data/shard_*.jsonl 取)。
sigma > 0:每次规划给 act 加 N(0, σ²)(σ 以 STEP 为单位,各候选独立),测闭环对残差噪声的敏感度。
跟随时第 it 步的 q 应等于 traj[:, it](DLS 接管或到达冻结之后除外)。"""
head = 'oracle'
def __init__(self, traj, act_scale=1.0, sigma=0.0):
self.traj, self.act_scale, self.sigma = traj, float(act_scale), float(sigma)
def plan(self, it, K, g):
B, Tm, _ = self.traj.shape
dev = self.traj.device
idx = (it + torch.arange(H + 1, device=dev)).clamp(max=Tm - 1)
seg = self.traj[:, idx]
astep = torch.tensor(STEP, device=dev) * self.act_scale
act = ((seg[:, 1:] - seg[:, :-1]) / astep).clamp(-1, 1)
act = act[:, None].expand(B, K, H, 6).contiguous()
if self.sigma > 0:
act = act + torch.randn(B, K, H, 6, device=dev, generator=g) * (self.sigma / self.act_scale)
return act
@torch.no_grad()
def rollout(model, kin, tasks, device, K=8, safety=False, seed=0, max_sec=None, return_traj=False, use_sub=True, sub_override=None,
qp0=None, steps=1, z_scale=1.0, select='consist', oracle=None, replan='r3', sub_rule='cross'):
"""use_sub=False:不用子目标(sf=0);sub_override=(subs [B,M,6] 绝对关节角, n [B] 个数 或 need [B] bool):冷启动时用给定途经点代替子目标头;
qp0 [B,6]:起点的上一帧(默认 = q0,即静止起步)。steps:MeanFlow 采样段数;z_scale:噪声缩放;
select:consist(部署口径:冷启动价值前瞻、之后与上一条已提交计划的一致性)/ value(每次都价值前瞻)/ first(不选,取第 0 条)。
oracle:TeacherOracle,代替 model 出动作(model 可为 None,须配 select='first'、use_sub=False)。
replan:'r3'(部署:队列 ≤ 3 步就规划,稳态 r=3)/ 'empty'(队列空了才规划,每次 r=0,前缀消融用)。
sub_rule:子目标推进规则,见 sub_advance。除默认值外均为诊断用。"""
g = torch.Generator(device=device); g.manual_seed(seed)
q0, T, R, ex, tr = tasks_to_tensors(tasks, device)
B = q0.shape[0]
step = torch.tensor(STEP, device=device)
act_scale = oracle.act_scale if oracle is not None else float(getattr(model, 'act_scale', 1.0))
astep = step * act_scale # 动作反归一化:每单位 act 对应的度数
has_ref = torch.isfinite(ex)
limit = time_limit(ex, max_sec)
nmax = int(math.ceil(limit.max().item() / DT))
q, qp = q0.clone(), (qp0.to(device) if qp0 is not None else q0.clone())
queue = torch.zeros(B, 0, 6, device=device)
last = None; last_ok = torch.zeros(B, dtype=torch.bool, device=device)
subs = torch.zeros(B, 2, 6, device=device); nsub = torch.zeros(B, dtype=torch.long, device=device)
sub_t = torch.zeros(B, device=device); sub_prev = q0.clone()
done = torch.zeros(B, dtype=torch.bool, device=device); t_done = torch.full((B,), float('nan'), device=device)
col = torch.zeros(B, device=device); travel = torch.zeros(B, device=device); brakes = torch.zeros(B, device=device)
first_hit = torch.full((B,), float('nan'), device=device); dls_it = torch.full((B,), -1, dtype=torch.long, device=device)
traj = [q.clone()] if return_traj else None
n_start = int(round(0.5 / DT)); moved_start = None # R2 起步失败:0.5 s 内关节总行程 < 1°
k2s = int(round(2.0 / DT)); e2 = None
e0, _ = goal_err(kin, q0, T, R)
j1_acc = torch.zeros(B, device=device); j1_max = torch.zeros(B, device=device); j1_min = torch.zeros(B, device=device)
j1_first = torch.zeros(B, device=device) # 2026-10-18 第五轮(审查 N-3):J1 累计偏离首次超过 FIRST_DEG 时的方向(真正的"首次方向")
cold = True
ar = torch.arange(B, device=device)
for it in range(nmax):
tnow = it * DT
if (queue.shape[1] <= 3) if replan == 'r3' else (queue.shape[1] == 0):
r = queue.shape[1]
pre = torch.zeros(B, 3, 6, device=device); pre[:, :r] = queue[:, :r]
qe = q + queue.sum(1)
e, oe = goal_err(kin, qe, T, R)
near = (e < 5) & ((R.abs().sum(-1) == 0) | (oe < 5))
subs, nsub, sub_prev, sub_t = sub_advance(kin, subs, nsub, sub_prev, sub_t, qe, tnow, sub_rule)
sf = (nsub > 0).float()
feat = features(kin, q, qp, T, R, pre, torch.full((B,), r, device=device), subs[:, 0], sf)
if oracle is not None:
act = oracle.plan(it, K, g); sub = torch.zeros(B, 13, device=device)
else:
z = torch.randn(B * K, H, 6, device=device, generator=g) * z_scale
act, val, sub = model.sample_k(feat, K, z, steps=steps)
inc = act.float().clamp(-1, 1) * astep
if cold:
need = sub[:, 12] > 0.5
subs = q[:, None] + sub[:, :12].float().view(B, 2, 6) * 180
if sub_override is not None:
subs = sub_override[0].to(device).float()
n_ov = sub_override[1].to(device)
nsub_ov = n_ov.long() if n_ov.dtype != torch.bool else n_ov.long() * subs.shape[1]
need = nsub_ov > 0
nsub = nsub_ov
else:
nsub = torch.where(need, torch.full_like(nsub, 2), torch.zeros_like(nsub))
if not use_sub: nsub = torch.zeros_like(nsub)
sub_t.fill_(tnow); sub_prev = q.clone()
fut = qe[:, None, None] + torch.cumsum(inc[:, :, r:], 2) # [B,K,H-r,6]
if select == 'first':
score = torch.zeros(B, K, device=device); score[:, 0] = 1
elif cold or select == 'value':
# 短前瞻:候选末态的价值
qa = fut[:, :, -1].reshape(B * K, 6); qb = fut[:, :, -2].reshape(B * K, 6)
f2 = features(kin, qa, qb, T.repeat_interleave(K, 0), R.repeat_interleave(K, 0), torch.zeros(B * K, 3, 6, device=device),
torch.zeros(B * K, device=device), qa, torch.zeros(B * K, device=device))
v2, _ = model.heads(model.encode(f2))
score = -v2.view(B, K)
else:
# 2026-10-19 修错位:last[i] = 上一计划从 qe_old 走 i 步后的位置;提交 3 步后 qe_new = last[3],
# 新候选 fut[0] 是 qe_new 再走 1 步 = 对应 last[4]。JS 部署(ik-policy.js)同一处同样修正。
n = min(fut.shape[2], last.shape[1] - 4)
w = 0.85 ** torch.arange(n, device=device, dtype=torch.float32)
d2 = (((fut[:, :, :n] - last[:, None, 4:4 + n]) / step) ** 2).sum(-1)
# 2026-10-19(审查 review_closedloop F5):上一次是刹停或 DLS 接管的行没有「已提交计划」,
# 与 JS(lastPlan = null → 一致性项缺席、各候选同分 → 取第 0 条)一致:同分,稳定排序取第 0 条
score = torch.where(last_ok[:, None], -(d2 * w).sum(-1), torch.zeros_like(d2[..., 0]))
order = torch.sort(score, dim=-1, descending=True, stable=True).indices
pick = order[:, 0]
commit = inc[ar, pick, r:r + 3]
committed = torch.ones(B, dtype=torch.bool, device=device)
if safety:
ok_any = torch.zeros(B, dtype=torch.bool, device=device)
chosen = torch.zeros(B, 3, 6, device=device); chosen_k = pick.clone()
for kk in range(K):
ck = inc[ar, order[:, kk], r:r + 3]
pts = qe[:, None] + torch.cumsum(ck, 1)
mids = torch.cat([qe[:, None], pts[:, :-1]], 1) + 0.5 * ck
allq = torch.cat([pts, mids], 1).reshape(-1, 6)
okk = (kin.gaps(allq, 0.5)['gm'].min(-1).values > 0).view(B, 6).all(-1)
take = okk & ~ok_any
chosen = torch.where(take[:, None, None], ck, chosen); chosen_k = torch.where(take, order[:, kk], chosen_k); ok_any |= okk
commit = torch.where(ok_any[:, None, None], chosen, torch.zeros_like(chosen))
brakes += (~ok_any).float()
pick = chosen_k; committed = ok_any
if near.any():
ref = dls_steps(kin, qe, T, R)
commit = torch.where(near[:, None, None], ref, commit)
dls_it = torch.where(near & (dls_it < 0), torch.full_like(dls_it, it), dls_it)
committed = committed & ~near
# 2026-10-19(F5):last 记真正提交的那条候选(安全层换了候选时是换后的那条);刹停 / DLS 接管的行无效
last = torch.cat([qe[:, None], fut[ar, pick]], 1); last_ok = committed
queue = torch.cat([queue, commit], 1)
cold = False
d = queue[:, 0]; queue = queue[:, 1:]
d = torch.where(done[:, None], torch.zeros_like(d), d)
qp = q; q = kin.clamp_box(q + d)
travel += d.abs().sum(-1)
j1_acc = j1_acc + d[:, 0]; j1_max = torch.maximum(j1_max, j1_acc); j1_min = torch.minimum(j1_min, j1_acc)
j1_first = torch.where((j1_first == 0) & (j1_acc.abs() >= FIRST_DEG), torch.sign(j1_acc), j1_first)
if it + 1 == n_start: moved_start = travel.clone()
if return_traj: traj.append(q.clone())
mid = qp + 0.5 * (q - qp)
gm = kin.gaps(torch.cat([q, mid], 0), 0.5)['gm'].min(-1).values
tn = (it + 1) * DT
# 2026-10-19(审查 review_closedloop F4):只计每题自己时限内的碰撞(同批长题会把短题多仿真几秒)
hit = ((gm[:B] <= 0) | (gm[B:] <= 0)) & ~done & (tn <= limit + 1e-6)
col += hit.float()
first_hit = torch.where(hit & torch.isnan(first_hit), torch.full_like(first_hit, tn), first_hit)
e, oe = goal_err(kin, q, T, R)
if it + 1 == k2s: e2 = e.clone()
newly = (~done) & (e < 1) & (oe < 0.5)
t_done = torch.where(newly, torch.full_like(t_done, tn), t_done); done |= newly
if done.all(): break
ok = done & (t_done <= limit + 1e-6)
if moved_start is None: moved_start = travel.clone()
e_end, _ = goal_err(kin, q, T, R)
if e2 is None: e2 = e_end
ratio2 = e2 / e0.clamp(min=1e-3)
start_fail = (moved_start < 1.0) & ~(done & (t_done <= 0.5))
okr = ok & has_ref & torch.isfinite(tr)
res = {'n': B, 'n_ref': int(has_ref.sum()), 'success': ok.float().mean().item(),
'time_ok_1p3': (ok & has_ref & (t_done <= 1.3 * ex)).float().sum().item() / max(1, int(has_ref.sum())) if has_ref.any() else None,
'collision_rate': (col > 0).float().mean().item(), 'collision_steps_mean': col.mean().item(),
'travel_ratio_median': (travel[okr] / tr[okr].clamp(min=1)).median().item() if okr.any() else None,
'time_mean_s': t_done[ok].mean().item() if ok.any() else None, 'brakes_mean': brakes.mean().item(),
'start_fail': start_fail.float().mean().item(), 'goal_ratio_2s': ratio2.median().item(),
'time_budget': 'teacher×2(≥3 s)' if bool(has_ref.all()) else ('fixed %.0f s' % (max_sec or NOREF_SEC) if not has_ref.any() else 'mixed')}
# 绕行侧:J1 累计位移的正向/负向极值哪个更大(+1 / −1),供 R7 左右比例门禁
# 首次方向;全程没偏离到 FIRST_DEG 的(基本不绕 J1)退回净方向
side = torch.where(j1_first != 0, j1_first, torch.where(j1_max >= -j1_min, torch.ones_like(j1_max), -torch.ones_like(j1_max)))
per = {'done': done.cpu(), 'ok': ok.cpu(), 't_done': t_done.cpu(), 'limit': limit.cpu(), 'col': col.cpu(), 'travel': travel.cpu(), 'err': e_end.cpu(),
'side': side.cpu(), 'start_fail': start_fail.cpu(), 'brakes': brakes.cpu(), 'first_hit': first_hit.cpu(), 'dls_it': dls_it.cpu(),
'goal_ratio_2s': ratio2.cpu()}
if return_traj: per['traj'] = torch.stack(traj, 1).cpu()
return res, per
def evaluate(model, kin, tasks, device, K=8, safety=False, bs=256, seed=0):
if not tasks: # 小样本摸底时 val 可能为空
return {'n': 0, 'success': float('nan'), 'collision_rate': float('nan'), 'start_fail': float('nan'), 'brakes_mean': float('nan'),
'goal_ratio_2s': float('nan')}, []
agg, pers = {}, []
for i in range(0, len(tasks), bs):
r, p = rollout(model, kin, tasks[i:i + bs], device, K=K, safety=safety, seed=seed + i)
w = r['n']; pers.append((r, p))
for k, v in r.items():
if k == 'n' or v is None or isinstance(v, str): continue
agg[k] = agg.get(k, 0.0) + v * w
n = sum(r['n'] for r, _ in pers)
out = {k: v / n for k, v in agg.items()}; out['n'] = n
out['n_ref'] = sum(r['n_ref'] for r, _ in pers)
# 中位数不能按批加权平均:全体任务重算(2026-10-19,选模分数用它)
out['goal_ratio_2s'] = torch.cat([p['goal_ratio_2s'] for _, p in pers]).median().item()
out['time_budget'] = pers[0][0]['time_budget'] if len({r['time_budget'] for r, _ in pers}) == 1 else 'mixed'
return out, pers
def main():
from ml.common.ckpt import load_policy
from ml.data import load_tasks
ap = argparse.ArgumentParser()
ap.add_argument('--ckpt', required=True); ap.add_argument('--samples', default='ml/data/samples')
ap.add_argument('--tasks', default=None, help='任务 jsonl(覆盖 --samples/--split)')
ap.add_argument('--split', default='val'); ap.add_argument('--n', type=int, default=1000)
ap.add_argument('--safety', action='store_true'); ap.add_argument('--K', type=int, default=8)
ap.add_argument('--device', default='cuda' if torch.cuda.is_available() else 'cpu'); ap.add_argument('--out', default=None)
a = ap.parse_args()
model, ck = load_policy(a.ckpt, a.device)
kin = Kin(device=a.device)
assert ck.get('fp') in (None, kin.fp), '碰撞模型指纹与 checkpoint 不符'
if a.tasks: tasks = [json.loads(l) for l in open(a.tasks, encoding='utf-8') if l.strip()]
else: tasks = [t for t in load_tasks([a.samples]) if t['split'] == a.split]
tasks = tasks[:a.n]
res, pers = evaluate(model, kin, tasks, a.device, K=a.K, safety=a.safety)
by = {}
flat_ok = torch.cat([p['ok'] for _, p in pers]); flat_col = torch.cat([p['col'] for _, p in pers])
for t, d, c in zip(tasks, flat_ok.tolist(), flat_col.tolist()):
k = t.get('cat', '?') + '/' + t.get('mode', '?'); b = by.setdefault(k, [0, 0, 0]); b[0] += 1; b[1] += int(d); b[2] += int(c > 0)
res['by_cat_mode'] = {k: {'n': v[0], 'success': v[1] / v[0], 'collision': v[2] / v[0]} for k, v in by.items()}
s = json.dumps(res, indent=1); print(s)
if a.out: # 先写 .tmp 再改名:中断不会留下半截结果被当成已完成(M5 续跑按文件存在跳过)
open(a.out + '.tmp', 'w').write(s); os.replace(a.out + '.tmp', a.out)
if __name__ == '__main__':
main()