255漂移问题稳定时间改30秒
This commit is contained in:
@@ -205,8 +205,9 @@ SDK 自身的初始化检查和设备保护保持原样。标定仍要求 SDK
|
||||
先按实际时间戳估计四角点的移动趋势,再用去除趋势后的 MAD 和相邻帧差分估计随机抖动。
|
||||
全窗口及前、后半窗口分别检查趋势,避免缓慢往返运动在整个窗口中互相抵消;每个半窗口至少4帧。
|
||||
持续漂移取各段 `max(0, 趋势累计位移 - 抖动裕量)` 的最大值,裕量由随机抖动、帧数和时间分布计算。
|
||||
两项门槛独立生效:随机抖动允许到0.5像素,低噪声条件下的持续漂移仍按0.1像素检查。任一项超限就保持当前指令继续等待,超过 `point_timeout` 仍不能稳定则暂停。
|
||||
`point_timeout` 默认6秒,给换向后的短时振动留出衰减时间;这是最长等待时间,达到稳定条件就立即采样推进。持续振动仍会暂停,不通过放宽噪声或漂移门槛获得结果。
|
||||
两项门槛独立生效:随机抖动允许到0.5像素,低噪声条件下的持续漂移仍按0.1像素检查。任一项超限就保持当前指令继续等待,到达该阶段的等待时限仍不能稳定则暂停。
|
||||
端点参考使用独立的 `reference_timeout`,默认30秒,给大幅定位后的缓慢位移留出稳定时间;普通逐点采样、准备和回位仍使用 `point_timeout`,默认6秒。等待从软件轨迹到达目标时开始,达到原有稳定条件就立即推进,不固定等满时限。端点参考超时会明确提示“端点参考未稳定”、保持目标的时长以及具体 Tag 的原因,日志同时保存 `elapsed_seconds` 和 `timeout_seconds`。扫描过程中丢失 Tag 导致轨迹暂停仍使用6秒时限。
|
||||
延长参考等待不会放宽噪声或漂移门槛;255的参考观测通过稳定检查后,才开始发送254。若30秒后仍不满足稳定条件,程序继续按超时暂停处理;需结合实机排查手指、Tag 粘贴和相机/手掌固定处。
|
||||
这是对有限图像的估计,并非已完全静止的保证。高噪声会降低细小运动的可辨别性,实机准确性仍需复测;不根据四指一起变化自动扣除整体位移,以免消除四指真实的同步运动。
|
||||
旧工位文件的 `stability_px` 仍可加载,仅作为随机抖动上限;新增配置优先,漂移上限默认0.1。建议自定义工位改用上述三个明确字段。
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ scan:
|
||||
stable_seconds: 0.5 # 新图像必须覆盖连续静止时间,不能只凭几帧局部平稳。
|
||||
reference_seconds: 1.5 # 每端开始前额外核对前、中、后三段参考位置一致。
|
||||
motion_frames: 2 # 瞬时运动须超过参考和当前点的抖动包络;不看方向。
|
||||
point_timeout: 6 # 最长等待振动衰减时间;稳定后立即推进,不固定等满6秒。
|
||||
point_timeout: 6 # 普通逐点采样的最长等待;稳定后立即推进。
|
||||
reference_timeout: 30 # 到达端点后允许更长时间稳定;达标立即开始,不固定等满30秒。
|
||||
freshness: 0.5
|
||||
feedback_tolerance: 2
|
||||
repeat_tolerance: 2
|
||||
|
||||
@@ -232,8 +232,20 @@ class Engine:
|
||||
'reason': reason})
|
||||
return issues
|
||||
|
||||
@property
|
||||
def collecting_reference(self):
|
||||
return self.state == 'SCANNING' and any(
|
||||
isinstance(window, ReferenceWindow) for window in self.windows.values())
|
||||
|
||||
@property
|
||||
def waiting_timeout(self):
|
||||
return (self.settings.reference_timeout if self.collecting_reference
|
||||
else self.settings.point_timeout)
|
||||
|
||||
def _pause_sampling_timeout(self, now, moving=False):
|
||||
restoring = self.state == 'RESTORING'
|
||||
limit = self.settings.point_timeout if moving else self.waiting_timeout
|
||||
elapsed = now - (self.blocked_since if moving else self.arrived)
|
||||
issues = [] if restoring else self._visibility_issues(now)
|
||||
if not moving:
|
||||
indices = self.active_indices if self.state == 'SCANNING' else None
|
||||
@@ -248,8 +260,14 @@ class Engine:
|
||||
view, tag_id = self.keys[name]
|
||||
issues.append({'source': 'tag', 'joint': name, 'view': view,
|
||||
'tag_id': tag_id, 'window_samples': window.snapshot(), **problem})
|
||||
prefix = ('目标 Tag 持续不可见或观测过期' if moving else
|
||||
'测后回位稳定超时' if restoring else '单点稳定采样超时')
|
||||
if moving:
|
||||
prefix = '目标 Tag 持续不可见或观测过期'
|
||||
elif restoring:
|
||||
prefix = '测后回位稳定超时'
|
||||
elif self.collecting_reference:
|
||||
prefix = f'端点参考未稳定:等待超时(已保持目标 {elapsed:.1f} 秒,上限 {limit:g} 秒)'
|
||||
else:
|
||||
prefix = '单点稳定采样超时'
|
||||
details = '\n'.join(describe_issue(issue) for issue in issues)
|
||||
shared_motion = shared_translation(issues)
|
||||
if shared_motion:
|
||||
@@ -258,9 +276,10 @@ class Engine:
|
||||
# Conditions can recover on the tick that crosses the deadline.
|
||||
context = self.restoring_joint if restoring else '、'.join(
|
||||
tag_label(view, tag_id, name) for name, (view, tag_id) in self.keys.items())
|
||||
details = f'{context}:条件刚恢复,但已超过 {self.settings.point_timeout:g} 秒等待时限'
|
||||
details = f'{context}:条件刚恢复,但已超过 {limit:g} 秒等待时限'
|
||||
self._record('sampling_timeout', time=now, task=self.task.name, stage=self.stage,
|
||||
direction=self.direction, repeat=self.repeat, target=list(self.command),
|
||||
elapsed_seconds=elapsed, timeout_seconds=limit,
|
||||
issues=issues, shared_motion=shared_motion, reason=details)
|
||||
self.pause(prefix + '\n' + details)
|
||||
|
||||
@@ -308,7 +327,7 @@ class Engine:
|
||||
for window in self.windows.values():
|
||||
window.clear()
|
||||
return
|
||||
if now-self.arrived > self.settings.point_timeout:
|
||||
if now-self.arrived > self.waiting_timeout:
|
||||
self._pause_sampling_timeout(now)
|
||||
return
|
||||
if self.state == 'RESTORING':
|
||||
|
||||
@@ -97,6 +97,7 @@ class Settings:
|
||||
reference_seconds: float = 1.5 # Verify the endpoint across independent time blocks.
|
||||
motion_frames: int = 2 # Consecutive valid frames at this command, independent of direction.
|
||||
point_timeout: float = 6
|
||||
reference_timeout: float = 30 # Endpoint positioning can settle slower than a one-unit step.
|
||||
freshness: float = 0.5
|
||||
feedback_tolerance: float = 2
|
||||
non_target_tolerance: float = 3 # Legacy configuration only; unrelated motion does not gate scans.
|
||||
@@ -141,8 +142,8 @@ class Settings:
|
||||
raise ValueError('单点超时必须大于最短等待与稳定观察时长之和')
|
||||
if result.reference_seconds < result.stable_seconds:
|
||||
raise ValueError('端点参考观察时长不能短于普通稳定观察时长')
|
||||
if result.point_timeout <= result.settle_seconds + result.reference_seconds:
|
||||
raise ValueError('单点超时必须大于最短等待与端点参考观察时长之和')
|
||||
if result.reference_timeout <= result.settle_seconds + result.reference_seconds:
|
||||
raise ValueError('端点参考超时必须大于最短等待与端点参考观察时长之和')
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+1667
File diff suppressed because it is too large
Load Diff
@@ -618,7 +618,8 @@ def test_pinky_pip_uses_its_own_tag_and_feedback_despite_coupled_motion(scenario
|
||||
for event in commands)
|
||||
if scenario in ('missing_side', 'moving_active_feedback'):
|
||||
assert engine.state == 'PAUSED'
|
||||
assert ('Tag' if scenario == 'missing_side' else '稳定采样超时') in engine.reason
|
||||
assert ('侧面 Tag ID5' if scenario == 'missing_side'
|
||||
else '关节 pinky_pip:反馈未稳定') in engine.reason
|
||||
assert not engine.samples
|
||||
else:
|
||||
assert engine.state == 'COMPLETED', engine.reason
|
||||
|
||||
@@ -208,6 +208,8 @@ def test_one_pass_four_fingers_with_late_settling_noise_and_replay(tmp_path, see
|
||||
|
||||
|
||||
def test_reference_duration_and_timeout_configuration_are_validated():
|
||||
for change in ({'reference_seconds': .1}, {'point_timeout': 1}):
|
||||
for change in ({'reference_seconds': .1}, {'reference_timeout': 1}):
|
||||
with pytest.raises(ValueError, match='参考观察时长'):
|
||||
Settings.from_dict(change)
|
||||
# Each deadline validates only the observation window it actually governs.
|
||||
assert Settings.from_dict({'point_timeout': 1}).reference_timeout == 30
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Timeout explanations must identify actual blockers before windows are cleared."""
|
||||
from dataclasses import replace
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -14,10 +16,10 @@ from linkerhand_range_calibration.vision.reference import ReferenceWindow
|
||||
|
||||
|
||||
class SamplingRun:
|
||||
def __init__(self, task='thumb_cmc_roll', phase='waiting'):
|
||||
def __init__(self, task='thumb_cmc_roll', phase='waiting', settings=None):
|
||||
profile = load_profile()
|
||||
settings = Settings(rate=1000, stable_frames=4, stable_seconds=.15, reference_seconds=.45,
|
||||
settle_seconds=.05, point_timeout=.8)
|
||||
settings = settings or Settings(rate=1000, stable_frames=4, stable_seconds=.15, reference_seconds=.45,
|
||||
settle_seconds=.05, point_timeout=.8, reference_timeout=.8)
|
||||
self.adapter = FakeAdapter(profile)
|
||||
self.events, self.now = [], 1.0
|
||||
self.engine = Engine(profile, settings, self.adapter, self.events.append)
|
||||
@@ -106,7 +108,7 @@ def test_four_finger_sampling_advances_with_random_jitter_above_old_limit():
|
||||
@pytest.mark.parametrize('deadline,settles_at', [(3, 4), (6, 4), (6, 0), (6, None)])
|
||||
def test_waits_for_transient_vibration_without_advancing_or_relaxing_limits(deadline, settles_at):
|
||||
run = SamplingRun('four_finger_roll')
|
||||
run.engine.settings = replace(run.engine.settings, point_timeout=deadline)
|
||||
run.engine.settings = replace(run.engine.settings, reference_timeout=deadline)
|
||||
started, sent = run.now, len(run.adapter.sent)
|
||||
|
||||
def shake(frames, rejected):
|
||||
@@ -136,6 +138,95 @@ def test_waits_for_transient_vibration_without_advancing_or_relaxing_limits(dead
|
||||
assert observation['jitter_px'] <= .5 and observation['drift_px'] <= .1
|
||||
|
||||
|
||||
def test_recorded_upper_reference_drift_is_still_rejected():
|
||||
path = Path(__file__).parent / 'fixtures' / 'group_upper_reference_drift.json'
|
||||
recorded = json.loads(path.read_text())
|
||||
settings = Settings()
|
||||
assert recorded['target'] == [255] * 4
|
||||
assert settings.point_timeout < recorded['held_seconds'] < settings.reference_timeout
|
||||
assert {issue['tag_id'] for issue in recorded['issues']} == {2, 3}
|
||||
for issue in recorded['issues']:
|
||||
window = ReferenceWindow(settings)
|
||||
for sample in issue['window_samples']:
|
||||
window.add(Observation(sample['stamp_ns'], 0, sample['corners']))
|
||||
problem = window.problem(settings.stability_noise_px, settings.stability_drift_px)
|
||||
assert problem['drift_px'] == pytest.approx(issue['drift_px'])
|
||||
assert problem['jitter_px'] < .02
|
||||
assert '持续漂移' in problem['reason']
|
||||
# The real recording ends at the timeout. Recovery below is synthetic;
|
||||
# it tests scheduling without claiming the real hardware settled later.
|
||||
|
||||
|
||||
@pytest.mark.parametrize('direction', ['up', 'down'])
|
||||
@pytest.mark.parametrize('settles_at', [0, 8, 18, None])
|
||||
def test_endpoint_holds_until_stable_or_reference_deadline(direction, settles_at):
|
||||
settings = Settings(rate=10000, stable_frames=4, settle_seconds=.05)
|
||||
run = SamplingRun('four_finger_roll', settings=settings)
|
||||
for _ in range(2000):
|
||||
if (run.engine.direction == direction and run.engine.collecting_reference
|
||||
and run.engine.phase == 'waiting'):
|
||||
break
|
||||
run.step()
|
||||
else:
|
||||
pytest.fail('未到达端点参考采样阶段')
|
||||
started = run.engine.arrived
|
||||
sent, events_before = len(run.adapter.sent), len(run.events)
|
||||
target = run.engine.command
|
||||
|
||||
def slow_settling(frames, rejected):
|
||||
elapsed = run.now - started
|
||||
# Middle/ring drift independently of stable encoder feedback.
|
||||
travel = elapsed if settles_at is None else min(elapsed, settles_at)
|
||||
for tag, rate in [(3, .3), (2, .6)]:
|
||||
frames['front'][tag] = (np.asarray(frames['front'][tag]) + [travel * rate, 0]).tolist()
|
||||
|
||||
for _ in range(round((settings.reference_timeout + 1) / .05)):
|
||||
run.step(slow_settling)
|
||||
samples = [event for event in run.events[events_before:] if event['kind'] == 'sample']
|
||||
assert len(run.adapter.sent) == sent and run.engine.command == target
|
||||
if samples or run.engine.state == 'PAUSED':
|
||||
break
|
||||
if settles_at is None:
|
||||
assert not samples and run.engine.state == 'PAUSED'
|
||||
assert '端点参考未稳定' in run.engine.reason
|
||||
timeout = run.events[-2]
|
||||
assert timeout['kind'] == 'sampling_timeout'
|
||||
assert timeout['timeout_seconds'] == settings.reference_timeout
|
||||
assert settings.reference_timeout < timeout['elapsed_seconds'] <= settings.reference_timeout + .1
|
||||
assert {issue['tag_id'] for issue in timeout['issues']} == {2, 3}
|
||||
else:
|
||||
assert len(samples) == 1 and run.engine.state == 'SCANNING'
|
||||
assert settles_at <= run.now - started < settles_at + 2
|
||||
for observation in samples[0]['observations'].values():
|
||||
assert observation['drift_px'] <= .1 and observation['jitter_px'] <= .5
|
||||
if settles_at:
|
||||
assert run.now - started > settings.point_timeout
|
||||
assert all(search.boundary is None for search in run.engine.searches.values())
|
||||
|
||||
|
||||
def test_ordinary_command_does_not_inherit_reference_deadline():
|
||||
run = SamplingRun('four_finger_roll', settings=Settings(rate=10000, stable_frames=4, settle_seconds=.05))
|
||||
for _ in range(300):
|
||||
run.step()
|
||||
if run.engine.stage == 'search' and run.engine.phase == 'waiting':
|
||||
break
|
||||
assert run.engine.stage == 'search' and not run.engine.collecting_reference
|
||||
started, sent = run.engine.arrived, len(run.adapter.sent)
|
||||
|
||||
def creep(frames, rejected):
|
||||
frames['front'][2] = (np.asarray(frames['front'][2]) + [run.now - started, 0]).tolist()
|
||||
|
||||
for _ in range(130):
|
||||
run.step(creep)
|
||||
if run.engine.state == 'PAUSED':
|
||||
break
|
||||
assert run.engine.state == 'PAUSED' and '单点稳定采样超时' in run.engine.reason
|
||||
assert len(run.adapter.sent) == sent
|
||||
timeout = run.events[-2]
|
||||
assert timeout['timeout_seconds'] == 6
|
||||
assert 6 < timeout['elapsed_seconds'] <= 6.1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('phase', ['moving', 'waiting'])
|
||||
@pytest.mark.parametrize('reason', [None, 'Tag 像素尺寸不足'])
|
||||
def test_missing_or_rejected_tag_does_not_blame_other_group_tags(phase, reason):
|
||||
|
||||
Reference in New Issue
Block a user