Files
Robot_Arm/tests/test_eval_cl.py
T
cbb ee7fe829ac fix+exp: 轮次35(补)——gaps/gm_groups 拒绝行数不符的 xf;撤回两次不存在的"合并调用"收益
- 探测脚本把认证点当成与特征无关的已知量,据此测出的 共享cum_xf −4.3ms / 合并一次调用 −8.1ms
  都不成立:真实顺序是 特征(q)→推理→候选→认证点,同一次规划内既不能共享 xf 也不能合并 gaps
  只有轮次34 已落地的 gm_groups 复用同姿态 xf(−3.4ms,22项指标逐位不变)是真的
- 守卫:xf 行数与 q 不符时抛错(之前会静默按别的行做宽相位,实测触发 CUDA device-side assert)
- 测试 34→35 项;check_parity 仍 PASS(2000样本,间隙差 2.52e-13 mm、碰撞布尔差 0、特征差 5.96e-08)
2026-10-20 21:06:35 +08:00

718 lines
38 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.
import contextlib
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import torch
from ml.eval.eval_cl import json_finite, run
from ml.eval.diag_collision import replay_certificates, replay_dense, replay_first_hits
from ml.sim.closed_loop import TeacherOracle, rollout
from ml.common.kin_torch import Kin, seg_seg_dist
class EvalMetricsTest(unittest.TestCase):
def setUp(self):
self.tasks = [
{'q0': [0] * 6, 'T': [0] * 3, 'exec_s': 1, 'travel': 5},
{'q0': [0] * 6, 'T': [0] * 3, 'exec_s': None, 'travel': None},
{'q0': [0] * 6, 'T': [0] * 3, 'exec_s': 2, 'travel': 6},
]
self.per = {k: torch.tensor(v) for k, v in {
'ok': [True, False, True], 'done': [True, False, True],
't_done': [0.5, float('nan'), 2.0], 'col': [1.0, 0.0, 0.0],
'travel': [6.0, 0.0, 8.0], 'brakes': [2.0, 4.0, 6.0],
'start_fail': [False, True, False], 'goal_ratio_2s': [0.3, 0.9, 0.1],
'err': [0.2, 200.0, 0.8], 'dls_it': [4, -1, 20],
'speed_ratio': [1.66, 0.9, 1.2], 'accel_ratio': [0.3, 0.2, 0.5],
}.items()}
def evaluate(self, tasks, bs=2, **kw):
offset = 0
def fake_rollout(model, kin, batch, device, **kwargs):
nonlocal offset
p = {k: v[offset:offset + len(batch)] for k, v in self.per.items()}
offset += len(batch)
return {'time_budget': 'mixed'}, p
with patch('ml.eval.eval_cl.rollout', side_effect=fake_rollout), contextlib.redirect_stdout(io.StringIO()):
return run(None, None, tasks, 'cpu', bs=bs, **kw)[0]
def test_global_denominators_and_collision_free_arrival(self):
result = self.evaluate(self.tasks)
self.assertEqual(result['n_ref'], 2)
self.assertAlmostEqual(result['success'], 2 / 3, places=6)
self.assertAlmostEqual(result['collision_free_success'], 1 / 3, places=6)
self.assertAlmostEqual(result['collision_rate'], 1 / 3, places=6)
self.assertEqual(result['time_ok_1p3'], 1.0)
self.assertEqual(result['time_mean_s'], 1.25)
self.assertAlmostEqual(result['travel_ratio_median'], 1.2, places=6)
self.assertAlmostEqual(result['goal_ratio_2s'], 0.3, places=6)
self.assertEqual(result['err_fail_med'], 200.0)
self.assertEqual(result['brakes_mean'], 4.0)
# 极值/分位按逐题值跨批汇总,不能进按 n 加权求均值的批聚合
self.assertAlmostEqual(result['speed_ratio_p50'], 1.2, places=6)
self.assertAlmostEqual(result['speed_ratio_p95'], 1.614, places=6)
self.assertAlmostEqual(result['speed_ratio_max'], 1.66, places=5)
self.assertAlmostEqual(result['over_vmax_rate'], 2 / 3, places=6) # 1.66 与 1.2 都算超 VMAX,0.9 不算
self.assertAlmostEqual(result['accel_ratio_max'], 0.5, places=6)
def test_empty_conditional_metrics_and_explicit_limit(self):
task = {**self.tasks[0], 'exec_s': None, 'travel': None}
result = self.evaluate([task], max_sec=0.04)
self.assertIsNone(result['err_fail_med'])
self.assertIsNone(result['time_ok_1p3'])
self.assertIsNone(result['travel_ratio_median'])
self.assertEqual(result['time_budget'], 'fixed 0.040 s')
self.assertEqual(result['collision_free_success'], 0.0)
def test_json_replaces_nonfinite_without_changing_zero(self):
value = {'items': [float('nan'), float('inf'), -float('inf'), 0.0]}
text = json.dumps(json_finite(value), allow_nan=False)
self.assertEqual(json.loads(text), {'items': [None, None, None, 0.0]})
def test_first_hit_probe_detects_collision(self):
class PlaneKin:
def __init__(self, **kwargs):
self.cont = torch.zeros(6, dtype=torch.bool)
self.lo, self.hi = torch.full((6,), -10.0), torch.full((6,), 10.0)
def gaps(self, q, cap):
return {'gm': q[:, :1].expand(-1, 49).clamp(max=cap)}
traj = torch.zeros(1, 3, 6)
traj[0, :, 0] = torch.tensor([1.0, 1.0, -1.0])
per = {'col': torch.tensor([1.0]), 'first_hit': torch.tensor([0.04]),
'dls_it': torch.tensor([-1]), 'traj': traj}
with tempfile.TemporaryDirectory() as folder:
trace = Path(folder) / 'trace.pt'
torch.save({'tasks': [self.tasks[0]], 'batches': [({}, per, 1)]}, trace)
with patch('ml.eval.diag_collision.Kin', PlaneKin), contextlib.redirect_stdout(io.StringIO()):
result = replay_first_hits(trace)
self.assertEqual(result['reported_collision_tasks'], 1)
self.assertEqual(result['replayed_first_hits32'], 1)
self.assertEqual(result['replayed_first_hits64'], 1)
self.assertEqual(result['rows'][0]['gap64_mm'], [0.5, 0.5, 0.0, -1.0])
self.assertEqual(result['rows'][0]['dense_gap64_min_mm'], -1.0)
def test_dense_probe_detects_between_checkpoints_and_respects_deadline(self):
class ThinObstacle:
fp = 'probe'
def __init__(self, **kwargs):
pass
def gaps(self, q, cap):
return {'gm': ((q[:, :1] - 0.25).abs() - 0.05).clamp(max=cap)}
traj = torch.zeros(4, 3, 6)
traj[:, :, 0] = torch.tensor([[0, 1, 1], [0, -0.5, -1], [0, 0, 0.25], [0, 0, 0.25]])
per = {'traj': traj, 'col': torch.zeros(4), 'limit': torch.tensor([0.04, 0.04, 0.02, 0.04]),
't_done': torch.tensor([float('nan')] * 3 + [0.02])}
with tempfile.TemporaryDirectory() as folder:
trace = Path(folder) / 'trace.pt'
output = Path(folder) / 'audit.json'
torch.save({'kin_fp': 'probe', 'tasks': [{}] * 4, 'batches': [({}, per, 4)]}, trace)
with patch('ml.eval.diag_collision.Kin', ThinObstacle), contextlib.redirect_stdout(io.StringIO()):
result = replay_dense(trace, [0, 1, 2, 3], 0.01, out=output)
with self.assertRaisesRegex(ValueError, 'valid task indices'):
replay_dense(trace, [4])
with self.assertRaisesRegex(ValueError, 'positive max_step_deg'):
replay_dense(trace, [0], 0.0)
self.assertEqual(json.loads(output.read_text())['collision_tasks'], 1)
self.assertEqual(result['collision_tasks'], 1)
self.assertEqual(result['rows'][0]['collision_segments'], 1)
self.assertAlmostEqual(result['rows'][0]['min_gap64_mm'], -0.05)
self.assertGreater(result['rows'][1]['min_gap64_mm'], 0)
for row in result['rows'][2:]:
self.assertGreater(row['min_gap64_mm'], 0)
self.assertEqual(row['steps'], 1)
def test_certificate_replay_samples_actual_movement_before_deadlines(self):
kin = SegmentCertificateTest.ThinObstacle()
kin.fp = 'probe'
traj = torch.zeros(3, 4, 6)
traj[:, :, 0] = torch.tensor([[0, 1, 1, 1], [0, -0.5, -1, -1], [0, 0, 1, 1]])
per = {'traj': traj, 'limit': torch.tensor([0.06, 0.02, 0.02]),
't_done': torch.tensor([float('nan'), 0.02, float('nan')]),
'plans': {'it': torch.arange(3), 'commit': torch.zeros(3, 3, 1, 6),
'certified': torch.ones(3, 3, 1, dtype=torch.bool), 'gaps': torch.full((3, 3, 2), 0.5)}}
with tempfile.TemporaryDirectory() as folder:
trace, output = Path(folder) / 'trace.pt', Path(folder) / 'audit.json'
torch.save({'kin_fp': 'probe', 'tasks': [{}] * 3, 'batches': [({}, per, 3)]}, trace)
with patch('ml.eval.diag_collision.Kin', return_value=kin), contextlib.redirect_stdout(io.StringIO()):
result = replay_certificates(trace, [0, 1, 2], out=output, repeats=1, extra_segments=[(0, 0)])
with self.assertRaisesRegex(ValueError, 'extra segment'):
replay_certificates(trace, [1], repeats=1, extra_segments=[(1, 1)])
self.assertEqual(json.loads(output.read_text())['dense_collision_segments'], 1)
self.assertEqual([(r['i'], r['start_it']) for r in result['rows']], [(0, 0), (1, 0)])
self.assertEqual(result['budgets']['32']['safe'], 1)
self.assertEqual(result['budgets']['32']['collision'], 1)
self.assertEqual(result['accepted_dense_collision_segments'], 0)
self.assertTrue(result['batch_single_match'])
self.assertEqual(result['timing']['calls'], 2)
class DlsSafetyTest(unittest.TestCase):
def test_rejected_refinement_retains_safe_candidate(self):
class ObstacleKin:
def clamp_box(self, q):
return q
def gaps(self, q, cap):
return {'gm': torch.where(q[:, :1] > 0.3, -torch.ones_like(q[:, :1]), torch.ones_like(q[:, :1]))}
task = {'q0': [0] * 6, 'T': [2, 0, 0]}
traj = torch.zeros(1, 2, 6)
traj[0, 1, 0] = 0.1
ref = torch.zeros(1, 1, 6)
ref[0, 0, 0] = 1.0
patches = {
'features': lambda *args: torch.zeros(1, 83),
'goal_err': lambda kin, q, T, R: (torch.full((q.shape[0],), 2.0), torch.zeros(q.shape[0])),
'dls_steps': lambda *args, **kwargs: ref,
}
results = []
with patch.multiple('ml.sim.closed_loop', **patches):
for gate in (0, 1):
_, per = rollout(None, ObstacleKin(), [task], 'cpu', K=1, safety=True,
max_sec=0.02, return_traj=True, use_sub=False, select='first',
oracle=TeacherOracle(traj), replan='empty', commit_n=1,
safety_partial=1, dls_safety=gate)
results.append(per)
self.assertEqual(results[0]['col'].item(), 1.0)
self.assertEqual(results[0]['dls_it'].item(), 0)
self.assertEqual(results[1]['col'].item(), 0.0)
self.assertEqual(results[1]['dls_it'].item(), -1)
self.assertAlmostEqual(results[1]['traj'][0, 1, 0].item(), 0.1)
def test_feedback_is_reduced_only_after_full_rejection(self):
class ObstacleKin:
def clamp_box(self, q):
return q
def gaps(self, q, cap):
return {'gm': torch.where(q[:, :1] > 0.3, -torch.ones_like(q[:, :1]), torch.ones_like(q[:, :1]))}
task = {'q0': [0] * 6, 'T': [10, 0, 0]}
traj = torch.zeros(1, 17, 6)
traj[0, :, 0] = -0.2 * torch.arange(17)
ref = torch.zeros(1, 1, 6)
ref[0, 0, 0] = 1.0
patches = {
'features': lambda *args: torch.zeros(1, 83),
'goal_err': lambda kin, q, T, R: (torch.full((q.shape[0],), 10.0), torch.zeros(q.shape[0])),
'dls_steps': lambda *args, **kwargs: ref,
}
results = []
with patch.multiple('ml.sim.closed_loop', **patches):
for gain in (1.0, 0.5):
_, per = rollout(None, ObstacleKin(), [task], 'cpu', K=1, safety=True,
max_sec=0.04, return_traj=True, use_sub=False, select='first',
oracle=TeacherOracle(traj), replan='empty', commit_n=1,
safety_partial=1, fb_gain=0.6, fb_block_gain=gain)
results.append(per)
self.assertEqual(results[0]['brakes'].item(), 2.0)
self.assertEqual(results[1]['brakes'].item(), 1.0)
self.assertEqual(results[1]['traj'][0, 1, 0].item(), 0.0)
self.assertAlmostEqual(results[1]['traj'][0, 2, 0].item(), 0.1)
self.assertEqual(results[1]['col'].item(), 0.0)
class ClampedPathSafetyTest(unittest.TestCase):
def rollout_candidate(self, increments, safety_partial, goal_error=10.0, **kwargs):
class ObstacleKin:
def clamp_box(self, q):
clamped = q.clone()
clamped[..., 4] = clamped[..., 4].clamp(max=0.0)
return clamped
def gaps(self, q, cap):
collision = (q[:, :1] > 0.1) & (q[:, 4:5] < 0.2)
return {'gm': torch.where(collision, -torch.ones_like(q[:, :1]), torch.ones_like(q[:, :1]))}
task = {'q0': [0] * 6, 'T': [goal_error, 0, 0]}
commit_n = len(increments)
traj = torch.zeros(1, commit_n + 1, 6)
traj[0, 1:] = torch.tensor(increments).cumsum(0)
patches = {
'features': lambda *args: torch.zeros(1, 83),
'goal_err': lambda kin, q, T, R: (torch.full((q.shape[0],), goal_error), torch.zeros(q.shape[0])),
}
with patch.multiple('ml.sim.closed_loop', **patches):
_, per = rollout(None, ObstacleKin(), [task], 'cpu', K=1, safety=True,
max_sec=0.02 * commit_n, return_traj=True, use_sub=False, select='first',
oracle=TeacherOracle(traj), replan='empty', commit_n=commit_n,
safety_partial=safety_partial, **kwargs)
return per
def test_full_safety_rejects_collision_after_joint_clamp(self):
# Raw endpoint/midpoint are safe, but the J5 upper limit puts the endpoint at (0.2, 0).
per = self.rollout_candidate([[0.2, 0, 0, 0, 0.6, 0]], safety_partial=0)
self.assertEqual(per['col'].item(), 0.0)
torch.testing.assert_close(per['traj'], torch.zeros(1, 2, 6), rtol=0, atol=0)
def test_partial_safety_rejects_collision_after_joint_clamp(self):
per = self.rollout_candidate([[0.2, 0, 0, 0, 0.6, 0]], safety_partial=1)
self.assertEqual(per['col'].item(), 0.0)
torch.testing.assert_close(per['traj'], torch.zeros(1, 2, 6), rtol=0, atol=0)
def test_partial_safety_keeps_only_clamped_safe_prefix(self):
per = self.rollout_candidate([
[0.05, 0, 0, 0, 0.6, 0],
[0.2, 0, 0, 0, 0.6, 0],
], safety_partial=1)
self.assertAlmostEqual(per['traj'][0, -1, 0].item(), 0.05)
self.assertEqual(per['traj'][0, -1, 4].item(), 0.0)
self.assertEqual(per['col'].item(), 0.0)
expected = torch.zeros(1, 3, 6)
expected[0, 1:, 0] = 0.05
torch.testing.assert_close(per['traj'], expected)
torch.testing.assert_close(per['plans']['commit'][0, 0, 1], torch.zeros(6), rtol=0, atol=0)
def test_near_dls_rejects_clamped_collision_and_keeps_candidate(self):
candidate = [[0.05, 0, 0, 0, 0.6, 0]]
ref = torch.tensor([[[0.2, 0, 0, 0, 0.6, 0]]])
for safety_partial in (0, 1):
with self.subTest(safety_partial=safety_partial):
with patch('ml.sim.closed_loop.dls_steps', return_value=ref) as dls:
per = self.rollout_candidate(candidate, safety_partial, goal_error=2.0, dls_safety=1)
dls.assert_called_once()
self.assertEqual(per['col'].item(), 0.0)
self.assertEqual(per['dls_it'].item(), -1)
self.assertEqual(per['brakes'].item(), 0.0)
expected = torch.zeros(1, 2, 6)
expected[0, 1, 0] = 0.05
torch.testing.assert_close(per['traj'], expected)
torch.testing.assert_close(per['plans']['commit'], torch.tensor(candidate)[None, None])
self.assertTrue(per['plans']['certified'].all().item())
def test_r3_clamped_queue_origin_prefix_and_certificates_match_execution(self):
class SafeKin:
def clamp_box(self, q):
clamped = q.clone()
clamped[..., 4] = clamped[..., 4].clamp(max=0.0)
return clamped
def gaps(self, q, cap):
return {'gm': torch.ones_like(q[:, :1])}
prefixes = []
def capture_features(kin, q, qp, T, R, pre, r, sub, sf):
prefixes.append((pre.clone(), int(r.item())))
return torch.zeros(1, 83)
teacher = torch.zeros(1, 25, 6)
teacher[0, :, 0] = 0.02 * torch.arange(25)
teacher[0, :, 4] = 0.6 * torch.arange(25)
task = {'q0': [0] * 6, 'T': [10, 0, 0]}
patches = {
'features': capture_features,
'goal_err': lambda kin, q, T, R: (torch.full((q.shape[0],), 10.0), torch.zeros(q.shape[0])),
}
with patch.multiple('ml.sim.closed_loop', **patches):
_, per = rollout(None, SafeKin(), [task], 'cpu', K=1, safety=True,
max_sec=0.16, return_traj=True, use_sub=False, select='first',
oracle=TeacherOracle(teacher), replan='r3', commit_n=3, safety_partial=1)
self.assertEqual([r for _, r in prefixes], [0, 2, 3, 3])
for pre, r in prefixes[1:]:
torch.testing.assert_close(pre[..., 4], torch.zeros(1, 3), rtol=0, atol=0)
torch.testing.assert_close(pre[:, :r, 0], torch.full((1, r), 0.02))
self.assertEqual(per['col'].item(), 0.0)
expected = torch.zeros(1, 9, 6)
expected[0, :, 0] = 0.02 * torch.arange(9)
torch.testing.assert_close(per['traj'], expected)
plans = per['plans']
self.assertEqual(plans['it'].tolist(), [0, 3, 6, 9])
torch.testing.assert_close(plans['commit'][..., 4], torch.full((1, 4, 3), 0.6))
self.assertTrue(plans['certified'].all().item())
self.assertTrue((plans['gaps'] > 0).all().item())
checked = 0
for i, start in enumerate(plans['it'].tolist()):
n = min(3, 8 - start)
if n <= 0:
continue
torch.testing.assert_close(plans['qe'][:, i], per['traj'][:, start], rtol=0, atol=0)
before = per['traj'][:, start:start + n]
after = per['traj'][:, start + 1:start + n + 1]
torch.testing.assert_close(plans['points'][:, i, :n], after, rtol=0, atol=0)
torch.testing.assert_close(plans['midpoints'][:, i, :n], before + 0.5 * (after - before),
rtol=0, atol=0)
checked += n
self.assertEqual(checked, 8)
def rollout_fallback(self, safety_partial, midpoint_collision=False):
class ObstacleKin:
def clamp_box(self, q):
return q
def gaps(self, q, cap):
collision = q[:, :1] > 0.3
if midpoint_collision:
collision |= (q[:, :1] > 0.09) & (q[:, :1] < 0.11)
return {'gm': torch.where(collision, -torch.ones_like(q[:, :1]), torch.ones_like(q[:, :1]))}
teacher = torch.zeros(1, 4, 6)
teacher[0, :, 0] = torch.arange(4)
ref = torch.zeros(1, 1, 6)
ref[0, 0, 0] = 0.2
task = {'q0': [0] * 6, 'T': [10, 0, 0]}
patches = {
'features': lambda *args: torch.zeros(1, 83),
'goal_err': lambda kin, q, T, R: (torch.full((q.shape[0],), 10.0), torch.zeros(q.shape[0])),
'dls_steps': lambda *args, **kwargs: ref,
}
with patch.multiple('ml.sim.closed_loop', **patches):
_, per = rollout(None, ObstacleKin(), [task], 'cpu', K=1, safety=True,
max_sec=0.06, return_traj=True, use_sub=False, select='first',
oracle=TeacherOracle(teacher), replan='empty', commit_n=3,
safety_partial=safety_partial, fb_gain=1.0, fb_fallback=1.0)
return per
def test_fallback_executes_one_safe_dls_step_then_certified_zeros(self):
for safety_partial in (0, 1):
with self.subTest(safety_partial=safety_partial):
per = self.rollout_fallback(safety_partial)
self.assertEqual(per['col'].item(), 0.0)
self.assertEqual(per['brakes'].item(), 1.0)
expected = torch.zeros(1, 4, 6)
expected[0, 1:, 0] = 0.2
torch.testing.assert_close(per['traj'], expected)
commit = torch.zeros(1, 1, 3, 6)
commit[0, 0, 0, 0] = 0.2
plans = per['plans']
self.assertEqual(plans['it'].tolist(), [0])
torch.testing.assert_close(plans['commit'], commit, rtol=0, atol=0)
self.assertTrue(plans['certified'].all().item())
self.assertTrue((plans['gaps'] > 0).all().item())
torch.testing.assert_close(plans['points'][:, 0], expected[:, 1:])
torch.testing.assert_close(plans['midpoints'][:, 0],
expected[:, :-1] + 0.5 * (expected[:, 1:] - expected[:, :-1]))
def test_fallback_rejects_safe_endpoint_with_colliding_midpoint(self):
for safety_partial in (0, 1):
with self.subTest(safety_partial=safety_partial):
per = self.rollout_fallback(safety_partial, midpoint_collision=True)
self.assertEqual(per['col'].item(), 0.0)
self.assertEqual(per['brakes'].item(), 1.0)
torch.testing.assert_close(per['traj'], torch.zeros(1, 4, 6), rtol=0, atol=0)
torch.testing.assert_close(per['plans']['commit'], torch.zeros(1, 1, 3, 6), rtol=0, atol=0)
self.assertFalse(per['plans']['certified'].any().item())
class SegmentCertificateTest(unittest.TestCase):
class ThinObstacle(Kin):
def __init__(self):
self.dtype = torch.float64
self.lo, self.hi = torch.full((6,), -5.0), torch.full((6,), 5.0)
self.cont = torch.zeros(6, dtype=torch.bool)
self.cert_groups = torch.tensor([0])
self.cert_lever = torch.tensor([[1.0, 0, 0, 0, 0, 0]], dtype=self.dtype)
def gaps(self, q, cap):
return {'gm': ((q[:, :1] - 0.25).abs() - 0.05).clamp(max=cap)}
def test_bound_detects_hidden_collision_and_accepts_clear_segment(self):
a, b = torch.zeros(3, 6, dtype=torch.float64), torch.zeros(3, 6, dtype=torch.float64)
a[:, 0] = torch.tensor([0.0, -1.0, 0.25])
b[:, 0] = torch.tensor([1.0, 0.0, 0.25])
result = self.ThinObstacle().certify_segments(a, b)
self.assertEqual(result['safe'].tolist(), [False, True, False])
self.assertEqual(result['collision'].tolist(), [True, False, True])
self.assertEqual(result['queries'].tolist(), [4, 2, 2])
def test_budget_and_depth_exhaustion_do_not_accept_unknown_segment(self):
a, b = torch.zeros(1, 6, dtype=torch.float64), torch.zeros(1, 6, dtype=torch.float64)
b[:, 0] = 1.0
kin = self.ThinObstacle()
for kwargs in ({'max_queries': 2}, {'max_depth': 0}):
with self.subTest(**kwargs):
result = kin.certify_segments(a, b, **kwargs)
self.assertFalse(result['safe'].item())
self.assertTrue(result['unresolved'].item())
self.assertEqual(result['queries'].item(), 2)
def test_shape_mismatch_between_endpoints_is_rejected(self):
a = torch.zeros(1, 6, dtype=torch.float64)
b = torch.zeros(2, 6, dtype=torch.float64)
kin = self.ThinObstacle()
for method in (kin.certify_segments, kin.certify_uniform):
with self.subTest(method=method.__name__):
with self.assertRaisesRegex(ValueError, 'identical shape'):
method(a, b)
def test_budget_exhaustion_scales_with_gap_and_bound(self):
class ConstantGap(self.ThinObstacle):
def gaps(self, q, cap):
return {'gm': torch.full((q.shape[0], 1), 0.1, dtype=q.dtype, device=q.device)}
# 界取 1 mm/度:1° 段要证明无碰撞,必须细分到位移界 < 两端间隙之和(0.2 mm)。
kin = ConstantGap()
kin.cert_lever = torch.ones(1, 6, dtype=torch.float64)
a, b = torch.zeros(1, 6, dtype=torch.float64), torch.zeros(1, 6, dtype=torch.float64)
b[:, 0] = 1.0
for budget, safe, queries in ((4, False, 3), (8, False, 5), (9, True, 9)):
with self.subTest(budget=budget):
result = kin.certify_segments(a, b, max_queries=budget)
self.assertEqual(bool(result['safe'].item()), safe)
self.assertEqual(int(result['queries'].item()), queries)
def test_tensor_budget_cannot_wrap_depth(self):
a, b = torch.zeros(1, 6, dtype=torch.float64), torch.zeros(1, 6, dtype=torch.float64)
a[:, 0] = b[:, 0] = 0.25
for tensor in (torch.tensor(255, dtype=torch.uint8), torch.tensor(255)):
with self.subTest(dtype=tensor.dtype):
with self.assertRaisesRegex(ValueError, 'plain ints'):
self.ThinObstacle().certify_segments(a, b, max_depth=tensor)
def test_batch_matches_per_root_and_ignores_order(self):
kin = self.ThinObstacle()
a = torch.tensor([[0.0, 0, 0, 0, 0, 0], [0.25, 0, 0, 0, 0, 0], [-1.0, 0, 0, 0, 0, 0]], dtype=torch.float64)
b = torch.tensor([[1.0, 0, 0, 0, 0, 0], [0.25, 0, 0, 0, 0, 0], [0.0, 0, 0, 0, 0, 0]], dtype=torch.float64)
batch = kin.certify_segments(a, b, max_queries=16)
self.assertEqual(batch['collision'].tolist(), [True, True, False])
self.assertEqual(batch['safe'].tolist(), [False, False, True])
for perm in ([0, 1, 2], [2, 1, 0], [1, 0, 2]):
index = torch.tensor(perm)
moved = kin.certify_segments(a[index], b[index], max_queries=16)
for key in ('safe', 'collision', 'unresolved', 'queries'):
torch.testing.assert_close(moved[key], batch[key][index], rtol=0, atol=0)
def test_empty_batch_and_all_rejected_frontier(self):
kin = self.ThinObstacle()
empty = kin.certify_segments(torch.zeros(0, 6, dtype=torch.float64), torch.zeros(0, 6, dtype=torch.float64))
self.assertEqual(empty['safe'].shape, (0,))
a = torch.tensor([[0.25, 0, 0, 0, 0, 0], [6.0, 0, 0, 0, 0, 0]], dtype=torch.float64)
result = kin.certify_segments(a, a.clone(), max_queries=2)
self.assertEqual(result['safe'].tolist(), [False, False])
self.assertEqual(result['collision'].tolist(), [True, False])
self.assertEqual(result['outside_box'].tolist(), [False, True])
def test_uniform_matches_bound_formula_and_needs_no_round_trips(self):
class ConstantGap(self.ThinObstacle):
def gaps(self, q, cap):
return {'gm': torch.full((q.shape[0], 1), 0.1, dtype=q.dtype, device=q.device)}
# 界 1 mm/度、间隙 0.1 mm:子段判据 0.2 > 1/n 需要 n ≥ 6,n=5 必须落 unproven。
kin = ConstantGap()
kin.cert_lever = torch.ones(1, 6, dtype=torch.float64)
a, b = torch.zeros(1, 6, dtype=torch.float64), torch.zeros(1, 6, dtype=torch.float64)
b[:, 0] = 1.0
for n, safe in ((5, False), (6, True)):
with self.subTest(n=n):
result = kin.certify_uniform(a, b, n=n)
self.assertEqual(bool(result['safe'].item()), safe)
self.assertEqual(result['points'], n + 1)
self.assertFalse(bool(result['collision'].item()))
self.assertEqual(bool(result['unproven'].item()), not safe)
self.assertAlmostEqual(result['cap_required_mm'], 1.0 / n + 2e-4, places=9)
def test_uniform_catches_hidden_collision_and_rejects_short_grids(self):
a = torch.tensor([[0.0, 0, 0, 0, 0, 0], [-1.0, 0, 0, 0, 0, 0], [6.0, 0, 0, 0, 0, 0]], dtype=torch.float64)
b = torch.tensor([[1.0, 0, 0, 0, 0, 0], [0.0, 0, 0, 0, 0, 0], [6.0, 0, 0, 0, 0, 0]], dtype=torch.float64)
kin = self.ThinObstacle()
short = kin.certify_uniform(a, b, n=2)
self.assertEqual(short['collision'].tolist(), [False, False, False])
self.assertEqual(short['unproven'].tolist(), [True, False, False])
self.assertEqual(short['safe'].tolist(), [False, True, False])
for n in (4, 8, 64):
with self.subTest(n=n):
result = kin.certify_uniform(a, b, n=n)
self.assertEqual(result['collision'].tolist(), [True, False, False])
self.assertEqual(result['safe'].tolist(), [False, True, False])
self.assertEqual(result['outside_box'].tolist(), [False, False, True])
def test_uniform_requires_plain_int_count(self):
a = torch.zeros(1, 6, dtype=torch.float64)
for tensor in (torch.tensor(8, dtype=torch.uint8), torch.tensor(8)):
with self.subTest(dtype=tensor.dtype):
with self.assertRaisesRegex(ValueError, 'subdivision count'):
self.ThinObstacle().certify_uniform(a, a.clone(), n=tensor)
def test_nonfinite_and_outside_box_are_rejected(self):
a, b = torch.zeros(1, 6, dtype=torch.float64), torch.zeros(1, 6, dtype=torch.float64)
kin = self.ThinObstacle()
with patch.object(kin, 'gaps', side_effect=lambda q, cap: {'gm': torch.full((q.shape[0], 1), float('nan'))}):
result = kin.certify_segments(a, b)
self.assertFalse(result['safe'].item())
self.assertTrue(result['unresolved'].item())
for value in (float('inf'), -float('inf')):
with self.subTest(value=value):
with patch.object(kin, 'gaps',
side_effect=lambda q, cap, v=value: {'gm': torch.full((q.shape[0], 1), v)}):
result = kin.certify_segments(a, b)
self.assertFalse(result['safe'].item())
a[:, 0] = b[:, 0] = 6.0
result = kin.certify_segments(a, b)
self.assertFalse(result['safe'].item())
self.assertTrue(result['outside_box'].item())
def test_point_segment_distance_is_symmetric_and_differentiable(self):
p = torch.tensor([[0.0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0]], dtype=torch.float64, requires_grad=True)
q = torch.tensor([[2.0, 0, 0]] * 4, dtype=torch.float64)
point = torch.tensor([[1.0, 0, 0], [1, 3, 0], [4, 0, 0], [-1, 0, 0]], dtype=torch.float64)
forward = seg_seg_dist(p, q, point, point)
reverse = seg_seg_dist(point, point, p, q)
torch.testing.assert_close(forward, torch.tensor([0.0, 3, 2, 1], dtype=torch.float64), atol=1e-8, rtol=0)
torch.testing.assert_close(forward, reverse, atol=1e-8, rtol=0)
forward.sum().backward()
self.assertTrue(torch.isfinite(p.grad).all())
torch.testing.assert_close(seg_seg_dist(p.detach(), p.detach(), point, point),
(p.detach() - point).norm(dim=-1), atol=1e-8, rtol=0)
class SpeedAndMarginTest(unittest.TestCase):
"""轮次33:反馈叠加后的每轴速度限幅,以及安全层的正余量门限(含 cap 截顶坑)。"""
STEP = [1.2, 1.2, 1.8, 1.8, 2.4, 3.6]
def rollout_one(self, teacher_step, gap_value, fb=None, **kwargs):
class FlatKin:
def clamp_box(self, q):
return q
def gaps(self, q, cap):
return {'gm': torch.full((q.shape[0], 49), float(gap_value))}
commit_n = 2
traj = torch.zeros(1, commit_n + 1, 6)
traj[0, 1:] = torch.tensor(teacher_step)[None].repeat(commit_n, 1).cumsum(0) # teacher_step = 每步各轴的度数增量
patches = {
'features': lambda *args: torch.zeros(1, 83),
'goal_err': lambda kin, q, T, R: (torch.full((q.shape[0],), 10.0), torch.zeros(q.shape[0])),
}
with patch.multiple('ml.sim.closed_loop', **patches):
with patch('ml.sim.closed_loop.dls_steps', return_value=torch.tensor([[[1.0, 0, 0, 0, 0, 0]]])):
_, per = rollout(None, FlatKin(), [{'q0': [0] * 6, 'T': [10, 0, 0]}], 'cpu', K=1, safety=True,
max_sec=0.02 * commit_n, return_traj=True, use_sub=False, select='first',
oracle=TeacherOracle(traj), replan='empty', commit_n=commit_n,
**kwargs)
return per
def test_feedback_over_speed_is_measured_not_assumed(self):
# 策略给满 STEP(J1 +1.2°)+ 2.2×1.0° 反馈 = 3.4° > VMAX·DT
per = self.rollout_one([1.2, 0, 0, 0, 0, 0], 5.0, fb_gain=2.2)
self.assertAlmostEqual(per['traj'][0, 1, 0].item(), 3.4, places=5)
self.assertAlmostEqual(per['speed_ratio'][0].item(), 3.4 / 1.2, places=5)
def test_inc_cap_limits_actual_step_to_vmax(self):
per = self.rollout_one([1.2, 0, 0, 0, 0, 0], 5.0, fb_gain=2.2, inc_cap=1)
self.assertAlmostEqual(per['traj'][0, 1, 0].item(), 1.2, places=5)
self.assertLessEqual(per['speed_ratio'][0].item(), 1.0 + 1e-6)
def test_margin_rejects_near_obstacle_step_that_passes_at_zero(self):
moved = self.rollout_one([0.5, 0, 0, 0, 0, 0], 0.3, safety_partial=1)
self.assertAlmostEqual(moved['traj'][0, -1, 0].item(), 1.0, places=5)
self.assertEqual(moved['brakes'].item(), 0.0)
braked = self.rollout_one([0.5, 0, 0, 0, 0, 0], 0.3, gap_margin=0.5, safety_partial=1)
self.assertEqual(braked['brakes'].item(), 1.0)
torch.testing.assert_close(braked['traj'], torch.zeros(1, 3, 6), rtol=0, atol=0)
def test_margin_below_cap_still_accepts_clear_step(self):
# 余量 0.5 必须仍能通过 5 mm 间隙:若宽相位 cap 没抬到 margin+0.5,截顶值 0.5 会让门限恒假
per = self.rollout_one([0.5, 0, 0, 0, 0, 0], 5.0, gap_margin=0.5, safety_partial=1)
self.assertEqual(per['brakes'].item(), 0.0)
self.assertAlmostEqual(per['traj'][0, -1, 0].item(), 1.0, places=5)
self.assertTrue((per['plans']['gaps'] > 0.5).all().item())
class FeatureGeometryReuseTest(unittest.TestCase):
"""#16 第①条:gm_groups 复用调用者已算好的 cum_xf,省一次 FK,但结果必须与原口径逐位一致。"""
def test_gm_groups_with_supplied_xf_is_bitwise_identical(self):
from ml.common.features import features, GP, GAP_CAP
kin = Kin()
q = torch.tensor([[-129.9, 46.68, 43.49, -97.46, -85.1, -139.7],
[10.0, -20.0, 30.0, -40.0, 50.0, -60.0]])
xf = kin.cum_xf(q)
torch.testing.assert_close(kin.gm_groups(q, GP, GAP_CAP, xf=xf), kin.gm_groups(q, GP, GAP_CAP),
rtol=0, atol=0)
idx = [a * 7 + b for a, b in GP]
table = kin.gaps(q, GAP_CAP, xf)['gm'][:, idx]
torch.testing.assert_close(kin.gm_groups(q, GP, GAP_CAP), table, rtol=0, atol=0)
z = torch.zeros(len(q), 6)
f = features(kin, q, z, q[:, :3], torch.zeros(len(q), 9), z[:, None, :].expand(len(q), 3, 6),
torch.zeros(len(q)), z, torch.zeros(len(q)))
cols = 12 + 3 + 6 + 3 + 6 + 3 + 6 + 3 + 3 + 2 + 18 + 3 + 6 + 1 # 间隙块之前的特征数 = 75
self.assertEqual(cols + 8, 83)
torch.testing.assert_close(f[:, cols:cols + 8], table / GAP_CAP, rtol=0, atol=0)
class CertBatchEquivalenceTest(unittest.TestCase):
"""#16 第②条:把 K 条候选的认证并成一次 gaps 调用,选择结果必须与串行循环逐位一致。"""
class StubModel:
act_scale = 1.0
def __init__(self, act):
self.act = act
def sample_k(self, feat, K, z, steps=1):
B = feat.shape[0]
return (self.act.expand(B, K, 16, 6).clone(), torch.zeros(B, device=feat.device),
torch.zeros(B, 13, device=feat.device))
def run_both(self, acts, reject_first):
class StepKin:
def clamp_box(self, q):
return q
def gaps(self, q, cap):
bad = (q[:, 0] > 0.5) if reject_first else torch.zeros(len(q), dtype=torch.bool, device=q.device)
return {'gm': torch.where(bad[:, None], -torch.ones(len(q), 49, device=q.device),
torch.full((len(q), 49), 1.0, device=q.device))}
acts = torch.tensor(acts, dtype=torch.float32) # [K,16,6] 动作(±1 归一化)
model = self.StubModel(acts)
tasks = [{'q0': [0] * 6, 'T': [10, 0, 0]}] * 2 # B=2:批量索引形状必须在多行下也成立
out = []
for batched in (0, 1):
with patch('ml.sim.closed_loop.features', return_value=torch.zeros(len(tasks), 83)), \
patch('ml.sim.closed_loop.goal_err',
side_effect=lambda kin, q, T, R: (torch.full((q.shape[0],), 10.0), torch.zeros(q.shape[0]))):
_, per = rollout(model, StepKin(), tasks, 'cpu', K=acts.shape[0], safety=True, max_sec=0.04,
return_traj=True, use_sub=False, select='first', replan='empty', commit_n=2,
safety_partial=1, cert_batch=batched)
out.append(per)
return out
def test_batched_certification_selects_the_same_candidate(self):
# 候选 0 第 2 步撞上 q0>0.5 的障碍 ⇒ 只有前缀 1 步;候选 1 全程安全 ⇒ 应被选中
acts = [[[0.2, 0, 0, 0, 0, 0], [0.5, 0, 0, 0, 0, 0]] + [[0, 0, 0, 0, 0, 0]] * 14,
[[0.1, 0, 0, 0, 0, 0], [0.1, 0, 0, 0, 0, 0]] + [[0, 0, 0, 0, 0, 0]] * 14]
seq, batched = self.run_both(acts, reject_first=True)
torch.testing.assert_close(batched['traj'], seq['traj'], rtol=0, atol=0)
torch.testing.assert_close(batched['plans']['commit'], seq['plans']['commit'], rtol=0, atol=0)
torch.testing.assert_close(batched['plans']['certified'], seq['plans']['certified'], rtol=0, atol=0)
self.assertEqual(batched['brakes'].tolist(), seq['brakes'].tolist())
self.assertAlmostEqual(seq['traj'][0, -1, 0].item(), 0.24, places=5) # 候选 1 的两步 0.1×STEP=0.12°
def test_batched_certification_keeps_partial_prefix_choice(self):
# 所有候选都只走得了第 1 步 ⇒ 两条路径都取第 1 条候选、前缀长度 1
acts = [[[0.2, 0, 0, 0, 0, 0], [0.5, 0, 0, 0, 0, 0]] + [[0, 0, 0, 0, 0, 0]] * 14,
[[0.3, 0, 0, 0, 0, 0], [0.6, 0, 0, 0, 0, 0]] + [[0, 0, 0, 0, 0, 0]] * 14]
seq, batched = self.run_both(acts, reject_first=True)
torch.testing.assert_close(batched['traj'], seq['traj'], rtol=0, atol=0)
torch.testing.assert_close(batched['plans']['commit'], seq['plans']['commit'], rtol=0, atol=0)
self.assertAlmostEqual(seq['traj'][0, 1, 0].item(), 0.24, places=5)
self.assertAlmostEqual(seq['traj'][0, 2, 0].item(), 0.24, places=5) # 第 2 步被拒后停在原地
def test_mismatched_xf_row_count_is_rejected(self):
kin = Kin()
q1 = torch.zeros(1, 6); q5 = torch.zeros(5, 6)
xf5 = kin.cum_xf(q5)
for name, fn in (('gaps', lambda: kin.gaps(q1, 40.0, xf5)),
('gm_groups', lambda: kin.gm_groups(q1, [(0, 2)], 40.0, xf=xf5))):
with self.subTest(fn=name):
with self.assertRaisesRegex(ValueError, '行数'):
fn()
self.assertEqual(kin.gaps(q5, 40.0, xf5)['gm'].shape, (5, 49))
self.assertEqual(kin.gm_groups(q5, [(0, 2)], 40.0, xf=xf5).shape, (5, 1))
if __name__ == '__main__':
unittest.main()