feat: 校验ACT episode数据质量

This commit is contained in:
2026-08-10 18:14:10 +08:00
parent 40be5560ee
commit c1ea1a2816
2 changed files with 331 additions and 0 deletions
@@ -14,6 +14,7 @@ from xr_rm_teleop.act_episode_recorder import (
EpisodeMetadata,
EpisodeStore,
QualityError,
QualityLimits,
RecordingSession,
RecordingState,
TaskDirectoryLock,
@@ -23,6 +24,7 @@ from xr_rm_teleop.act_episode_recorder import (
recover_partial_files,
select_camera_pair,
select_frame,
validate_episode,
)
@@ -477,3 +479,122 @@ def test_task_directory_lock_rejects_second_recorder(tmp_path):
second.acquire()
finally:
first.release()
def _quality_limits():
return QualityLimits(min_samples=3, max_samples=100)
def _valid_episode(tmp_path):
path = tmp_path / "episode_0.partial.hdf5"
store = EpisodeStore.create(path, _metadata())
for index, seq in enumerate((100, 103, 106)):
sample = _episode_sample(seq)
sample["debug/cameras/cam_high_frame_number"] = 200 + index
sample["debug/cameras/cam_wrist_frame_number"] = 300 + index
store.append(sample)
store.close()
with h5py.File(path, "r+") as root:
root.attrs["camera_high_fps"] = 30.0
root.attrs["camera_right_wrist_fps"] = 30.0
root.attrs["camera_high_drop_ratio"] = 0.0
root.attrs["camera_right_wrist_drop_ratio"] = 0.0
return path
@requires_h5py
def test_validate_episode_accepts_valid_file(tmp_path):
report = validate_episode(_valid_episode(tmp_path), _quality_limits())
assert report.accepted
assert report.reason is None
assert report.metrics["control_hz"] == pytest.approx(30.0, rel=1e-5)
def _mutate_episode(path, mutation):
with h5py.File(path, "r+") as root:
if mutation == "short_episode":
root.visititems(
lambda _name, item: item.resize(2, axis=0)
if isinstance(item, h5py.Dataset)
else None
)
elif mutation == "control_seq_gap":
root["debug/control/control_seq"][1] = 104
elif mutation == "nonfinite_qpos":
root["observations/qpos"][1, 0] = np.nan
elif mutation == "joint_limit":
root["action"][1, 0] = 4.0
elif mutation == "invalid_gripper":
root["action"][1, 7] = 0.5
elif mutation == "feedback_age":
control_ns = root["debug/timestamps/control_monotonic_ns"][1]
root["debug/timestamps/feedback_monotonic_ns"][1] = (
control_ns - 60_000_000
)
elif mutation == "action_invalid":
root["debug/control/action_valid"][1] = 0
elif mutation == "control_fault":
root["debug/control/control_fault"][1] = 1
elif mutation == "camera_fps":
root.attrs["camera_high_fps"] = 20.0
elif mutation == "camera_drop":
root.attrs["camera_right_wrist_drop_ratio"] = 0.02
elif mutation == "camera_age":
root["debug/timestamps/cam_high_age_ms"][1] = 60.0
elif mutation == "camera_skew":
root["debug/timestamps/inter_camera_skew_ms"][1] = 60.0
elif mutation == "final_gripper_closed":
root["observations/qpos"][-1, 7] = 0.0
else:
raise AssertionError(f"unknown mutation: {mutation}")
@requires_h5py
@pytest.mark.parametrize(
("mutation", "reason"),
[
("short_episode", "too_few_samples"),
("control_seq_gap", "control_sequence_gap"),
("nonfinite_qpos", "nonfinite_qpos"),
("joint_limit", "joint_limit_violation"),
("invalid_gripper", "invalid_gripper_state"),
("feedback_age", "feedback_too_old"),
("action_invalid", "invalid_action"),
("control_fault", "control_fault"),
("camera_fps", "camera_fps"),
("camera_drop", "camera_drop_ratio"),
("camera_age", "camera_frame_too_old"),
("camera_skew", "camera_skew"),
("final_gripper_closed", "final_gripper_not_open"),
],
)
def test_validate_episode_reports_stable_reason(
tmp_path,
mutation,
reason,
):
path = _valid_episode(tmp_path)
_mutate_episode(path, mutation)
report = validate_episode(path, _quality_limits())
assert not report.accepted
assert report.reason == reason
@requires_h5py
def test_validate_episode_reports_qp_failures_without_rejecting(tmp_path):
path = _valid_episode(tmp_path)
with h5py.File(path, "r+") as root:
root["debug/qp/attempted"][:] = (1, 1, 1)
root["debug/qp/success"][:] = (0, 0, 1)
root["debug/control/target_clamped"][:] = (1, 0, 1)
report = validate_episode(path, _quality_limits())
assert report.accepted
assert report.metrics["qp_failure_count"] == 2
assert report.metrics["qp_failure_ratio"] == pytest.approx(2.0 / 3.0)
assert report.metrics["qp_longest_failure_streak"] == 2
assert report.metrics["target_clamped_count"] == 2