Xarm-DataCollection/tests/test_manual_mode.py
2026-08-17 11:24:35 +08:00

523 lines
17 KiB
Python

import sys
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot_robot_ufactory.robots.uf_robot.uf_robot_config import UFRobotConfig
from lerobot_robot_ufactory.scripts import uf_lerobot_record as record_module
from lerobot_robot_ufactory.scripts.uf_lerobot_record import (
_manual_action_from_observation,
_update_manual_gripper_key_state,
_update_manual_gripper_target,
_prepare_dataset_root,
_prepare_recording_episode,
get_cfg,
)
class FakeXArm:
def __init__(self, robot_ip):
self.robot_ip = robot_ip
self.connected = True
self.axis = 6
self.error_code = 0
self.mode = 0
self.initial_point = [0.0, -30.0, 0.0, 0.0, 0.0, 30.0]
self._arm = type("FakeArmTransport", (), {"_baud_checkset": False})()
self.gripper_position = 800
self.calls = []
def motion_enable(self, **kwargs):
self.calls.append(("motion_enable", kwargs))
def clean_error(self):
self.calls.append(("clean_error",))
def set_teach_sensitivity(self, sensitivity):
self.calls.append(("set_teach_sensitivity", sensitivity))
return 0
def set_mode(self, mode):
self.calls.append(("set_mode", mode))
self.mode = mode
return 0
def set_state(self, state):
self.calls.append(("set_state", state))
return 0
def get_initial_point(self):
self.calls.append(("get_initial_point",))
return 0, self.initial_point
def set_servo_angle(self, **kwargs):
self.calls.append(("set_servo_angle", kwargs))
return 0
def get_err_warn_code(self):
return 0, [0, 0]
def set_linear_spd_limit_factor(self, factor):
self.calls.append(("set_linear_spd_limit_factor", factor))
return 0
def set_gripper_enable(self, enable):
self.calls.append(("set_gripper_enable", enable))
return 0
def set_gripper_mode(self, mode):
self.calls.append(("set_gripper_mode", mode))
return 0
def set_gripper_speed(self, speed):
self.calls.append(("set_gripper_speed", speed))
return 0
def set_gripper_position(self, position, **kwargs):
self.calls.append(("set_gripper_position", position, kwargs))
self.gripper_position = position
return 0
def get_gripper_position(self):
self.calls.append(("get_gripper_position",))
return 0, self.gripper_position
def getset_tgpio_modbus_data(self, data):
self.calls.append(("getset_tgpio_modbus_data", data))
return 0, []
def get_joint_states(self, is_radian=True, num=3):
positions = np.arange(6, dtype=np.float64)
velocities = np.zeros(6, dtype=np.float64)
return 0, [positions, velocities, velocities]
def disconnect(self):
self.calls.append(("disconnect",))
self.connected = False
def test_manual_mode_robot_enters_teaching_mode_without_sending_actions(monkeypatch, tmp_path):
from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module
arm = FakeXArm("192.168.1.245")
monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)
config = UFRobotConfig(
id="test_manual_robot",
calibration_dir=tmp_path,
robot_ip=arm.robot_ip,
robot_dof=6,
control_space="joint",
gripper_type=0,
manual_mode=True,
teach_sensitivity=4,
)
robot = uf_robot_module.UFRobot(config)
robot.connect()
assert robot.is_connected
assert arm.mode == 2
assert ("set_teach_sensitivity", 4) in arm.calls
assert robot._initial_point == arm.initial_point
assert not any(call[0] == "set_servo_angle" for call in arm.calls)
robot.reset_to_initial()
reset_calls = [call for call in arm.calls if call[0] == "set_servo_angle"]
assert reset_calls == [
(
"set_servo_angle",
{
"angle": arm.initial_point,
"speed": 20,
"is_radian": False,
"wait": True,
},
)
]
assert arm.mode == 2
action = {"J1.pos": 1.0}
assert robot.send_action(action) is action
observation = robot.get_observation()
assert observation["J1.pos"] == 0.0
assert observation["J6.pos"] == 5.0
robot.disconnect()
assert not robot.is_connected
assert arm.mode == 0
assert ("disconnect",) in arm.calls
call_count = len(arm.calls)
robot.disconnect()
assert len(arm.calls) == call_count
def test_robot_reset_uses_sdk_initial_point_in_normal_mode(monkeypatch, tmp_path):
from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module
arm = FakeXArm("192.168.1.245")
monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)
config = UFRobotConfig(
id="test_normal_robot",
calibration_dir=tmp_path,
robot_ip=arm.robot_ip,
robot_dof=6,
control_space="joint",
gripper_type=0,
)
robot = uf_robot_module.UFRobot(config)
assert not hasattr(config, "start_joints")
assert not hasattr(config, "start_tcp_pose")
robot.connect()
reset_calls = [call for call in arm.calls if call[0] == "set_servo_angle"]
assert reset_calls == [
(
"set_servo_angle",
{
"angle": arm.initial_point,
"speed": 20,
"is_radian": False,
"wait": True,
},
)
]
robot.disconnect()
def test_normal_mode_waits_for_gripper_to_open_before_control(monkeypatch, tmp_path):
from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module
arm = FakeXArm("192.168.1.245")
arm.gripper_position = 400
monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)
config = UFRobotConfig(
id="test_wait_for_gripper",
calibration_dir=tmp_path,
robot_ip=arm.robot_ip,
robot_dof=6,
control_space="joint",
gripper_type=1,
)
robot = uf_robot_module.UFRobot(config)
robot.connect()
open_calls = [call for call in arm.calls if call[0] == "set_gripper_position"]
assert open_calls == [("set_gripper_position", 800, {"wait": True})]
assert robot._last_gripper_command == 0.0
before_writes = len(
[call for call in arm.calls if call[0] == "getset_tgpio_modbus_data"]
)
robot._send_gripper_action(0.0)
after_writes = len(
[call for call in arm.calls if call[0] == "getset_tgpio_modbus_data"]
)
assert after_writes == before_writes
robot.disconnect()
def test_manual_mode_config_rejects_cartesian_control(tmp_path):
with pytest.raises(ValueError, match="control_space='joint'"):
UFRobotConfig(
id="test_manual_robot",
calibration_dir=tmp_path,
robot_dof=6,
control_space="cartesian",
manual_mode=True,
)
def test_manual_gripper_speed_is_configurable_and_non_negative(tmp_path):
config = UFRobotConfig(
id="test_manual_robot",
calibration_dir=tmp_path,
robot_dof=6,
manual_mode=True,
manual_gripper_speed=0.25,
)
assert config.manual_gripper_speed == 0.25
with pytest.raises(ValueError, match="manual_gripper_speed"):
UFRobotConfig(
id="test_manual_robot",
calibration_dir=tmp_path,
robot_dof=6,
manual_mode=True,
manual_gripper_speed=-0.1,
)
def test_manual_action_filters_non_action_observation_fields():
observation = {
"J1.pos": 1.0,
"J1.vel": 2.0,
"gripper.pos": 0.5,
"camera": np.zeros((2, 2, 3), dtype=np.uint8),
}
action_features = {"J1.pos": float, "gripper.pos": float}
assert _manual_action_from_observation(observation, action_features) == {
"J1.pos": 1.0,
"gripper.pos": 0.5,
}
def test_manual_gripper_keys_update_target_in_expected_direction_and_bounds():
key_state = {"close": False, "open": False}
_update_manual_gripper_key_state(type("Key", (), {"char": "C"})(), True, key_state)
assert key_state == {"close": True, "open": False}
assert _update_manual_gripper_target(0.5, key_state, speed=1.0, fps=10) == pytest.approx(0.6)
_update_manual_gripper_key_state(type("Key", (), {"char": "C"})(), False, key_state)
_update_manual_gripper_key_state(type("Key", (), {"char": "o"})(), True, key_state)
assert _update_manual_gripper_target(0.05, key_state, speed=1.0, fps=10) == 0.0
_update_manual_gripper_key_state(type("Key", (), {"char": "c"})(), True, key_state)
assert _update_manual_gripper_target(0.99, key_state, speed=1.0, fps=10) == 0.99
def test_manual_mode_initializes_gripper_without_opening_and_sends_only_gripper(monkeypatch, tmp_path):
from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module
arm = FakeXArm("192.168.1.245")
monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)
config = UFRobotConfig(
id="test_manual_gripper_robot",
calibration_dir=tmp_path,
robot_ip=arm.robot_ip,
robot_dof=6,
control_space="joint",
gripper_type=1,
manual_mode=True,
)
robot = uf_robot_module.UFRobot(config)
robot.connect()
assert ("set_gripper_enable", True) in arm.calls
assert ("set_gripper_mode", 0) in arm.calls
assert ("set_gripper_speed", 5000) in arm.calls
assert not any(call[0] == "set_gripper_position" for call in arm.calls)
robot.send_action({"J1.pos": 1.0, "gripper.pos": 0.5})
assert any(call[0] == "getset_tgpio_modbus_data" for call in arm.calls)
assert not any(call[0] == "set_servo_angle" for call in arm.calls)
robot.disconnect()
def test_gripper_command_is_only_sent_after_target_changes(monkeypatch, tmp_path):
from lerobot_robot_ufactory.robots.uf_robot import uf_robot as uf_robot_module
arm = FakeXArm("192.168.1.245")
monkeypatch.setattr(uf_robot_module, "XArmAPI", lambda robot_ip: arm)
monkeypatch.setattr(uf_robot_module.time, "sleep", lambda _: None)
config = UFRobotConfig(
id="test_gripper_command_threshold",
calibration_dir=tmp_path,
robot_ip=arm.robot_ip,
robot_dof=6,
control_space="joint",
gripper_type=1,
manual_mode=True,
gripper_command_threshold=0.01,
)
robot = uf_robot_module.UFRobot(config)
robot.connect()
robot.send_action({"gripper.pos": 0.5})
robot.send_action({"gripper.pos": 0.505})
robot.send_action({"gripper.pos": 0.52})
writes = [call for call in arm.calls if call[0] == "getset_tgpio_modbus_data"]
assert len(writes) == 2
robot.disconnect()
def test_manual_record_config_has_no_teleop(monkeypatch):
config_path = Path("config/manual_mode/xarm7_manual_record_config.yaml").resolve()
monkeypatch.setattr(
sys,
"argv",
["uf-lerobot-record", "--config_path", str(config_path)],
)
config = get_cfg()
assert config.robot.manual_mode is True
assert config.robot.robot_dof == 7
assert config.robot.manual_gripper_speed == 0.5
assert config.teleop is None
assert config.dataset.fps == 30
def test_prepare_dataset_root_leaves_new_root_for_lerobot_create(tmp_path):
root = tmp_path / "nested" / "dataset"
cfg = SimpleNamespace(dataset=SimpleNamespace(root=root), resume=False)
_prepare_dataset_root(cfg)
assert not root.exists()
def test_prepare_dataset_root_rejects_incomplete_resume(tmp_path):
root = tmp_path / "dataset"
(root / "meta").mkdir(parents=True)
(root / "meta" / "info.json").write_text("{}")
cfg = SimpleNamespace(dataset=SimpleNamespace(root=root), resume=True)
with pytest.raises(RuntimeError, match="meta/tasks.parquet"):
_prepare_dataset_root(cfg)
def test_prepare_dataset_root_rejects_resume_when_root_is_missing(tmp_path):
cfg = SimpleNamespace(dataset=SimpleNamespace(root=tmp_path / "missing"), resume=True)
with pytest.raises(RuntimeError, match="does not exist"):
_prepare_dataset_root(cfg)
def test_prepare_dataset_root_resumes_complete_dataset_without_prompt(tmp_path, monkeypatch):
root = tmp_path / "dataset"
(root / "meta" / "episodes" / "chunk-000").mkdir(parents=True)
(root / "data" / "chunk-000").mkdir(parents=True)
(root / "meta" / "info.json").write_text("{}")
(root / "meta" / "tasks.parquet").write_bytes(b"tasks")
(root / "meta" / "episodes" / "chunk-000" / "file-000.parquet").write_bytes(b"episodes")
(root / "data" / "chunk-000" / "file-000.parquet").write_bytes(b"data")
cfg = SimpleNamespace(dataset=SimpleNamespace(root=root), resume=True)
monkeypatch.setattr(record_module.sys.stdin, "isatty", lambda: True)
_prepare_dataset_root(cfg)
def test_manual_record_loop_writes_actual_state_as_action(tmp_path):
class FakeRobot:
name = "fake_manual_robot"
robot_type = name
action_features = {"J1.pos": float, "J2.pos": float}
observation_features = action_features
def __init__(self):
self.observation_count = 0
self.sent_actions = []
def get_observation(self):
self.observation_count += 1
value = float(self.observation_count)
return {"J1.pos": value, "J2.pos": value + 1}
def send_action(self, action):
self.sent_actions.append(action.copy())
return action
robot = FakeRobot()
action_pipeline, robot_pipeline, observation_pipeline = record_module.make_default_processors()
features = record_module.combine_feature_dicts(
record_module.aggregate_pipeline_dataset_features(
pipeline=action_pipeline,
initial_features=record_module.create_initial_features(action=robot.action_features),
use_videos=False,
),
record_module.aggregate_pipeline_dataset_features(
pipeline=observation_pipeline,
initial_features=record_module.create_initial_features(
observation=robot.observation_features
),
use_videos=False,
),
)
dataset = LeRobotDataset.create(
"test/manual-record",
fps=30,
features=features,
root=tmp_path / "dataset",
robot_type=robot.robot_type,
use_videos=False,
)
record_module.record_loop(
robot=robot,
events={"exit_early": False},
fps=30,
teleop_action_processor=action_pipeline,
robot_action_processor=robot_pipeline,
robot_observation_processor=observation_pipeline,
dataset=dataset,
control_time_s=0.001,
single_task="test task",
manual_mode=True,
)
assert dataset.episode_buffer["size"] == 1
assert robot.sent_actions == [{"J1.pos": 2.0, "J2.pos": 3.0}]
assert dataset.episode_buffer["action"][0].tolist() == [2.0, 3.0]
dataset.save_episode()
dataset.finalize()
def test_manual_recording_episode_resets_before_recording():
class FakeRobot:
def __init__(self):
self.calls = []
def reset_to_initial(self):
self.calls.append("reset_to_initial")
robot = FakeRobot()
_prepare_recording_episode(robot, teleop=None, is_uf_teleop=False, manual_mode=True)
assert robot.calls == ["reset_to_initial"]
def test_manual_record_loop_applies_keyboard_gripper_target():
class FakeRobot:
name = "fake_manual_robot"
robot_type = name
action_features = {"J1.pos": float, "gripper.pos": float}
def __init__(self):
self.sent_actions = []
def get_observation(self):
return {"J1.pos": 1.0, "gripper.pos": 0.5}
def send_action(self, action):
self.sent_actions.append(action.copy())
return action
robot = FakeRobot()
action_pipeline, robot_pipeline, observation_pipeline = record_module.make_default_processors()
record_module.record_loop(
robot=robot,
events={"exit_early": False},
fps=10,
teleop_action_processor=action_pipeline,
robot_action_processor=robot_pipeline,
robot_observation_processor=observation_pipeline,
control_time_s=0.001,
manual_mode=True,
manual_gripper_keys={"close": True, "open": False},
manual_gripper_speed=1.0,
)
assert robot.sent_actions == [{"J1.pos": 1.0, "gripper.pos": 0.6}]