add full bimanual gravity comp

This commit is contained in:
croissant
2025-11-01 11:58:02 +01:00
parent 101fb02697
commit 5170862d23
4 changed files with 235 additions and 139 deletions
@@ -9,8 +9,8 @@ from lerobot.robots.openarms.config_openarms_follower import OpenArmsFollowerCon
def main() -> None: def main() -> None:
config = OpenArmsFollowerConfig( config = OpenArmsFollowerConfig(
port_right="can0", port_left="can0",
port_left="can1", port_right="can1",
can_interface="socketcan", can_interface="socketcan",
id="openarms_follower", id="openarms_follower",
disable_torque_on_disconnect=True, disable_torque_on_disconnect=True,
@@ -31,25 +31,14 @@ def main() -> None:
follower.pin_robot = pin_robot follower.pin_robot = pin_robot
control_arm = "right" print(f"Applying gravity compensation")
print(f"Applying gravity compensation to: {control_arm.upper()} ARM (all joints)")
print("\nAll joints on the right arm will have gravity compensation applied.")
print("Left arm joints will be disabled (free to move).")
print("\nIMPORTANT:")
print(" 1. Support the arm before starting") print(" 1. Support the arm before starting")
print(" 2. The arm will be held in place by gravity compensation") print(" 2. The arm will be held in place by gravity compensation")
print(" 3. You should be able to move it with gentle force") print(" 3. You should be able to move it with gentle force")
print("\nPress ENTER when ready to start...") print("\nPress ENTER when ready to start...")
input() input()
follower.bus_left.enable_torque()
time.sleep(0.1)
print(f"✓ Motors enabled") print(f"✓ Motors enabled")
print(f" {control_arm.upper()} arm: Gravity compensation active")
print(f" LEFT arm: Zero torque (free to move)")
print("\nStarting gravity compensation loop...") print("\nStarting gravity compensation loop...")
print("Press Ctrl+C to stop\n") print("Press Ctrl+C to stop\n")
@@ -80,12 +69,13 @@ def main() -> None:
positions_rad = {k: np.deg2rad(v) for k, v in positions_deg.items()} positions_rad = {k: np.deg2rad(v) for k, v in positions_deg.items()}
torques_nm = follower._gravity_from_q(positions_rad) torques_nm = follower._gravity_from_q(positions_rad)
# Apply gravity compensation torques to all joints on the right arm # Apply gravity compensation to right arm (all joints except gripper)
for motor in follower.bus_right.motors: for motor in follower.bus_right.motors:
if motor == "gripper":
continue # Skip gripper
full_name = f"right_{motor}" full_name = f"right_{motor}"
position = positions_deg.get(full_name, 0.0) position = positions_deg.get(full_name, 0.0)
# Apply gravity compensation to this joint
torque = torques_nm.get(full_name, 0.0) torque = torques_nm.get(full_name, 0.0)
# Send MIT control command with gravity compensation torque # Send MIT control command with gravity compensation torque
@@ -97,19 +87,24 @@ def main() -> None:
velocity_deg_per_sec=0.0, velocity_deg_per_sec=0.0,
torque=torque torque=torque
) )
# Also ensure left arm joints have zero torque # Apply gravity compensation to left arm (all joints except gripper)
for motor in follower.bus_left.motors: for motor in follower.bus_left.motors:
if motor == "gripper":
continue # Skip gripper
full_name = f"left_{motor}" full_name = f"left_{motor}"
position = positions_deg.get(full_name, 0.0) position = positions_deg.get(full_name, 0.0)
torque = torques_nm.get(full_name, 0.0)
# Send MIT control command with gravity compensation torque
follower.bus_left._mit_control( follower.bus_left._mit_control(
motor=motor, motor=motor,
kp=0.0, kp=0.0, # No position control
kd=0.0, kd=0.0, # No velocity damping
position_degrees=position, position_degrees=position,
velocity_deg_per_sec=0.0, velocity_deg_per_sec=0.0,
torque=0.0 torque=torque
) )
# Measure loop time # Measure loop time
@@ -123,22 +118,12 @@ def main() -> None:
avg_time = sum(loop_times) / len(loop_times) avg_time = sum(loop_times) / len(loop_times)
current_hz = 1.0 / avg_time if avg_time > 0 else 0 current_hz = 1.0 / avg_time if avg_time > 0 else 0
# Display status for all joints on the controlled arm print(f"{current_hz:.1f} Hz ({avg_time*1000:.1f} ms)")
print(f"\n[RIGHT ARM] Loop: {current_hz:.1f} Hz ({avg_time*1000:.1f} ms)")
# Show each joint's position and torque
for motor in follower.bus_right.motors:
full_name = f"right_{motor}"
pos = positions_deg.get(full_name, 0.0)
torque = torques_nm.get(full_name, 0.0)
print(f" {motor:8s}: Pos={pos:7.1f}° | Torque={torque:7.3f} N·m")
# Reset for next measurement window
loop_times = [] loop_times = []
last_print_time = loop_end last_print_time = loop_end
# Small sleep to avoid overwhelming the CAN bus time.sleep(0.005)
time.sleep(0.01) # 100 Hz
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n\nStopping gravity compensation...") print("\n\nStopping gravity compensation...")
@@ -1,73 +1,21 @@
""" """
OpenArms Teleoperation with Gravity Compensation OpenArms Teleoperation with Gravity Compensation
Leader RIGHT arm: Gravity compensation (weightless, easy to move) Leader arms (both LEFT and RIGHT): Gravity compensation (weightless, easy to move)
Follower RIGHT arm: Mirrors leader movements Follower arms (both LEFT and RIGHT): Mirror leader movements
Both LEFT arms: Free to move (disabled)
The urdf file tested with this script is found in: Uses the URDF file from the lerobot repository.
https://github.com/michel-aractingi/openarm_description/blob/main/openarm_bimanual_pybullet.urdf
""" """
import time import time
import os
import numpy as np import numpy as np
import pinocchio as pin
from lerobot.robots.openarms.config_openarms_follower import OpenArmsFollowerConfig from lerobot.robots.openarms.config_openarms_follower import OpenArmsFollowerConfig
from lerobot.robots.openarms.openarms_follower import OpenArmsFollower from lerobot.robots.openarms.openarms_follower import OpenArmsFollower
from lerobot.teleoperators.openarms.config_openarms_leader import OpenArmsLeaderConfig from lerobot.teleoperators.openarms.config_openarms_leader import OpenArmsLeaderConfig
from lerobot.teleoperators.openarms.openarms_leader import OpenArmsLeader from lerobot.teleoperators.openarms.openarms_leader import OpenArmsLeader
# Path to the URDF file
URDF_PATH = "/home/croissant/Documents/openarm_description/openarm_bimanual_pybullet.urdf"
def compute_gravity_torques(leader, positions_rad):
"""
Compute gravity torques for all joints in the robot.
Args:
leader: OpenArmsLeader instance with pin_robot set
positions_rad: Dictionary mapping motor names (with arm prefix) to positions in radians
Returns:
Dictionary mapping motor names to gravity torques in N·m
"""
if not hasattr(leader, "pin_robot") or leader.pin_robot is None:
raise RuntimeError("URDF model not loaded on leader")
# Build position vector in the order of motors (right arm, then left arm)
q = np.zeros(leader.pin_robot.model.nq)
idx = 0
# Right arm motors
for motor_name in leader.bus_right.motors:
full_name = f"right_{motor_name}"
q[idx] = positions_rad.get(full_name, 0.0)
idx += 1
# Left arm motors
for motor_name in leader.bus_left.motors:
full_name = f"left_{motor_name}"
q[idx] = positions_rad.get(full_name, 0.0)
idx += 1
# Compute generalized gravity vector
g = pin.computeGeneralizedGravity(leader.pin_robot.model, leader.pin_robot.data, q)
# Map back to motor names
result = {}
idx = 0
for motor_name in leader.bus_right.motors:
result[f"right_{motor_name}"] = float(g[idx])
idx += 1
for motor_name in leader.bus_left.motors:
result[f"left_{motor_name}"] = float(g[idx])
idx += 1
return result
def main(): def main():
"""Main teleoperation loop with gravity compensation""" """Main teleoperation loop with gravity compensation"""
@@ -78,8 +26,8 @@ def main():
# Configuration # Configuration
follower_config = OpenArmsFollowerConfig( follower_config = OpenArmsFollowerConfig(
port_right="can0", port_left="can0",
port_left="can1", port_right="can1",
can_interface="socketcan", can_interface="socketcan",
id="openarms_follower", id="openarms_follower",
disable_torque_on_disconnect=True, disable_torque_on_disconnect=True,
@@ -87,8 +35,8 @@ def main():
) )
leader_config = OpenArmsLeaderConfig( leader_config = OpenArmsLeaderConfig(
port_right="can2", port_left="can2",
port_left="can3", port_right="can3",
can_interface="socketcan", can_interface="socketcan",
id="openarms_leader", id="openarms_leader",
manual_control=False, # Enable torque control for gravity compensation manual_control=False, # Enable torque control for gravity compensation
@@ -102,18 +50,15 @@ def main():
follower.connect(calibrate=True) follower.connect(calibrate=True)
leader.connect(calibrate=True) leader.connect(calibrate=True)
# Load URDF for gravity compensation # URDF is automatically loaded in the leader constructor
if not os.path.exists(URDF_PATH): if leader.pin_robot is None:
raise FileNotFoundError(f"URDF file not found at {URDF_PATH}") raise RuntimeError("URDF model not loaded on leader. Gravity compensation not available.")
pin_robot = pin.RobotWrapper.BuildFromURDF(URDF_PATH, os.path.dirname(URDF_PATH))
pin_robot.data = pin_robot.model.createData()
leader.pin_robot = pin_robot
print("\nLeader RIGHT: G-comp | Follower RIGHT: Teleop") print("\nLeader BOTH arms: G-comp | Follower BOTH arms: Teleop")
print("Press ENTER to start...") print("Press ENTER to start...")
input() input()
# Enable motors # Enable motors on both leader arms for gravity compensation
leader.bus_right.enable_torque() leader.bus_right.enable_torque()
leader.bus_left.enable_torque() leader.bus_left.enable_torque()
time.sleep(0.1) time.sleep(0.1)
@@ -124,17 +69,12 @@ def main():
loop_times = [] loop_times = []
last_print_time = time.perf_counter() last_print_time = time.perf_counter()
# Right arm joints only # All joints (both arms)
right_joints = [ all_joints = []
"right_joint_1", for motor in leader.bus_right.motors:
"right_joint_2", all_joints.append(f"right_{motor}")
"right_joint_3", for motor in leader.bus_left.motors:
"right_joint_4", all_joints.append(f"left_{motor}")
"right_joint_5",
"right_joint_6",
"right_joint_7",
"right_gripper",
]
try: try:
while True: while True:
@@ -143,6 +83,7 @@ def main():
# Get leader state # Get leader state
leader_action = leader.get_action() leader_action = leader.get_action()
# Extract positions in degrees
leader_positions_deg = {} leader_positions_deg = {}
for motor in leader.bus_right.motors: for motor in leader.bus_right.motors:
key = f"right_{motor}.pos" key = f"right_{motor}.pos"
@@ -154,12 +95,26 @@ def main():
if key in leader_action: if key in leader_action:
leader_positions_deg[f"left_{motor}"] = leader_action[key] leader_positions_deg[f"left_{motor}"] = leader_action[key]
# Calculate gravity torques for leader # Calculate gravity torques for leader using built-in method
leader_positions_rad = {k: np.deg2rad(v) for k, v in leader_positions_deg.items()} leader_positions_rad = {k: np.deg2rad(v) for k, v in leader_positions_deg.items()}
leader_torques_nm = compute_gravity_torques(leader, leader_positions_rad) leader_torques_nm = leader._gravity_from_q(leader_positions_rad)
# Apply gravity compensation to leader right arm # Apply gravity compensation to leader RIGHT arm (all joints except gripper)
for motor in leader.bus_right.motors: for motor in leader.bus_right.motors:
if motor == "gripper":
# Skip gripper - keep it free
full_name = f"right_{motor}"
position = leader_positions_deg.get(full_name, 0.0)
leader.bus_right._mit_control(
motor=motor,
kp=0.0,
kd=0.0,
position_degrees=position,
velocity_deg_per_sec=0.0,
torque=0.0,
)
continue
full_name = f"right_{motor}" full_name = f"right_{motor}"
position = leader_positions_deg.get(full_name, 0.0) position = leader_positions_deg.get(full_name, 0.0)
torque = leader_torques_nm.get(full_name, 0.0) torque = leader_torques_nm.get(full_name, 0.0)
@@ -173,10 +128,25 @@ def main():
torque=torque, torque=torque,
) )
# Keep leader left arm free # Apply gravity compensation to leader LEFT arm (all joints except gripper)
for motor in leader.bus_left.motors: for motor in leader.bus_left.motors:
if motor == "gripper":
# Skip gripper - keep it free
full_name = f"left_{motor}"
position = leader_positions_deg.get(full_name, 0.0)
leader.bus_left._mit_control(
motor=motor,
kp=0.0,
kd=0.0,
position_degrees=position,
velocity_deg_per_sec=0.0,
torque=0.0,
)
continue
full_name = f"left_{motor}" full_name = f"left_{motor}"
position = leader_positions_deg.get(full_name, 0.0) position = leader_positions_deg.get(full_name, 0.0)
torque = leader_torques_nm.get(full_name, 0.0)
leader.bus_left._mit_control( leader.bus_left._mit_control(
motor=motor, motor=motor,
@@ -184,12 +154,12 @@ def main():
kd=0.0, kd=0.0,
position_degrees=position, position_degrees=position,
velocity_deg_per_sec=0.0, velocity_deg_per_sec=0.0,
torque=0.0, torque=torque,
) )
# Send leader positions to follower right arm # Send leader positions to follower (both arms)
follower_action = {} follower_action = {}
for joint in right_joints: for joint in all_joints:
pos_key = f"{joint}.pos" pos_key = f"{joint}.pos"
if pos_key in leader_action: if pos_key in leader_action:
follower_action[pos_key] = leader_action[pos_key] follower_action[pos_key] = leader_action[pos_key]
@@ -206,10 +176,8 @@ def main():
if loop_times: if loop_times:
avg_time = sum(loop_times) / len(loop_times) avg_time = sum(loop_times) / len(loop_times)
current_hz = 1.0 / avg_time if avg_time > 0 else 0 current_hz = 1.0 / avg_time if avg_time > 0 else 0
sample_pos = leader_positions_deg.get("right_joint_2", 0.0)
sample_torque = leader_torques_nm.get("right_joint_2", 0.0)
print(f"[{current_hz:.1f} Hz] J2: {sample_pos:5.1f}° | Torque: {sample_torque:5.2f} N·m") print(f"{current_hz:.1f} Hz ({avg_time*1000:.1f} ms)")
loop_times = [] loop_times = []
last_print_time = loop_end last_print_time = loop_end
@@ -94,10 +94,19 @@ class OpenArmsFollower(Robot):
# Initialize Pinocchio robot model for dynamics (optional) # Initialize Pinocchio robot model for dynamics (optional)
self.pin_robot = None self.pin_robot = None
try: try:
# Try to load URDF if available # Load URDF - try external path first (with meshes), then repository
# TODO: Add OpenArms URDF file to repository import os
self.pin_robot = pin.RobotWrapper.BuildFromURDF("urdf/openarms.urdf", "urdf") from os.path import expanduser, dirname
logger.info("Loaded OpenArms URDF for dynamics computation")
# Try external URDF with meshes first
external_urdf_path = expanduser("~/Documents/openarm_description/openarm_bimanual_pybullet.urdf")
if os.path.exists(external_urdf_path):
urdf_path = external_urdf_path
urdf_dir = dirname(urdf_path)
self.pin_robot = pin.RobotWrapper.BuildFromURDF(urdf_path, urdf_dir)
self.pin_robot.data = self.pin_robot.model.createData()
logger.info(f"Loaded OpenArms URDF for dynamics computation from {urdf_path}")
except Exception as e: except Exception as e:
logger.warning(f"Could not load URDF for dynamics: {e}. Gravity compensation will not be available.") logger.warning(f"Could not load URDF for dynamics: {e}. Gravity compensation will not be available.")
@@ -476,34 +485,62 @@ class OpenArmsFollower(Robot):
"Ensure urdf/openarms.urdf exists and is valid." "Ensure urdf/openarms.urdf exists and is valid."
) )
# Build position vector in the order of motors (right arm, then left arm) # Build position vector in the order of motors (left arm, then right arm)
# This order must match the URDF joint order
# URDF has: left_joint1-7, left_finger_joint1-2, right_joint1-7, right_finger_joint1-2
q = np.zeros(self.pin_robot.model.nq) q = np.zeros(self.pin_robot.model.nq)
idx = 0 idx = 0
# Right arm motors # Left arm motors (first in URDF) - joints 1-7
for motor_name in self.bus_right.motors:
full_name = f"right_{motor_name}"
q[idx] = q_rad.get(full_name, 0.0)
idx += 1
# Left arm motors
for motor_name in self.bus_left.motors: for motor_name in self.bus_left.motors:
if motor_name == "gripper":
continue # Skip gripper, will be handled separately
full_name = f"left_{motor_name}" full_name = f"left_{motor_name}"
q[idx] = q_rad.get(full_name, 0.0) q[idx] = q_rad.get(full_name, 0.0)
idx += 1 idx += 1
# Skip left finger joints (leave as zeros)
idx += 2
# Right arm motors (second in URDF) - joints 1-7
for motor_name in self.bus_right.motors:
if motor_name == "gripper":
continue # Skip gripper, will be handled separately
full_name = f"right_{motor_name}"
q[idx] = q_rad.get(full_name, 0.0)
idx += 1
# Skip right finger joints (leave as zeros)
idx += 2
# Compute generalized gravity vector # Compute generalized gravity vector
g = pin.computeGeneralizedGravity(self.pin_robot.model, self.pin_robot.data, q) g = pin.computeGeneralizedGravity(self.pin_robot.model, self.pin_robot.data, q)
# Map back to motor names # Map back to motor names (only arm joints, not fingers)
result = {} result = {}
idx = 0 idx = 0
for motor_name in self.bus_right.motors:
result[f"right_{motor_name}"] = float(g[idx]) # Left arm torques (joints 1-7)
idx += 1
for motor_name in self.bus_left.motors: for motor_name in self.bus_left.motors:
if motor_name == "gripper":
result["left_gripper"] = 0.0 # No gravity compensation for gripper
continue
result[f"left_{motor_name}"] = float(g[idx]) result[f"left_{motor_name}"] = float(g[idx])
idx += 1 idx += 1
# Skip left finger joint torques in output
idx += 2
# Right arm torques (joints 1-7)
for motor_name in self.bus_right.motors:
if motor_name == "gripper":
result["right_gripper"] = 0.0 # No gravity compensation for gripper
continue
result[f"right_{motor_name}"] = float(g[idx])
idx += 1
# Skip right finger joint torques in output
idx += 2
return result return result
@@ -18,6 +18,9 @@ import logging
import time import time
from typing import Any, Dict from typing import Any, Dict
import numpy as np
import pinocchio as pin
from lerobot.motors import Motor, MotorCalibration, MotorNormMode from lerobot.motors import Motor, MotorCalibration, MotorNormMode
from lerobot.motors.damiao import DamiaoMotorsBus from lerobot.motors.damiao import DamiaoMotorsBus
from lerobot.motors.damiao.tables import MotorType from lerobot.motors.damiao.tables import MotorType
@@ -84,6 +87,25 @@ class OpenArmsLeader(Teleoperator):
data_bitrate=self.config.can_data_bitrate if self.config.use_can_fd else None, data_bitrate=self.config.can_data_bitrate if self.config.use_can_fd else None,
) )
# Initialize Pinocchio robot model for dynamics (optional)
self.pin_robot = None
try:
# Load URDF - try external path first (with meshes), then repository
import os
from os.path import expanduser, dirname
# Try external URDF with meshes first
external_urdf_path = expanduser("~/Documents/openarm_description/openarm_bimanual_pybullet.urdf")
if os.path.exists(external_urdf_path):
urdf_path = external_urdf_path
urdf_dir = dirname(urdf_path)
self.pin_robot = pin.RobotWrapper.BuildFromURDF(urdf_path, urdf_dir)
self.pin_robot.data = self.pin_robot.model.createData()
logger.info(f"Loaded OpenArms URDF for dynamics computation from {urdf_path}")
except Exception as e:
logger.warning(f"Could not load URDF for dynamics: {e}. Gravity compensation will not be available.")
@property @property
def action_features(self) -> Dict[str, type]: def action_features(self) -> Dict[str, type]:
"""Features produced by this teleoperator.""" """Features produced by this teleoperator."""
@@ -308,3 +330,87 @@ class OpenArmsLeader(Teleoperator):
logger.info(f"{self} disconnected.") logger.info(f"{self} disconnected.")
def _deg_to_rad(self, deg: Dict[str, float | int]) -> Dict[str, float]:
"""Convert degrees to radians for all motors."""
return {m: np.deg2rad(float(v)) for m, v in deg.items()}
def _gravity_from_q(self, q_rad: Dict[str, float]) -> Dict[str, float]:
"""
Compute g(q) [N·m] for all joints in the robot.
The order of joints in the URDF matches the concatenated motor lists (right then left).
Args:
q_rad: Dictionary mapping motor names (with arm prefix) to positions in radians
Returns:
Dictionary mapping motor names to gravity torques in N·m
Raises:
RuntimeError: If URDF model is not loaded
"""
if self.pin_robot is None:
raise RuntimeError(
"Cannot compute gravity: URDF model not loaded. "
"Ensure urdf/openarms.urdf exists and is valid."
)
# Build position vector in the order of motors (left arm, then right arm)
# This order must match the URDF joint order
# URDF has: left_joint1-7, left_finger_joint1-2, right_joint1-7, right_finger_joint1-2
q = np.zeros(self.pin_robot.model.nq)
idx = 0
# Left arm motors (first in URDF) - joints 1-7
for motor_name in self.bus_left.motors:
if motor_name == "gripper":
continue # Skip gripper, will be handled separately
full_name = f"left_{motor_name}"
q[idx] = q_rad.get(full_name, 0.0)
idx += 1
# Skip left finger joints (leave as zeros)
idx += 2
# Right arm motors (second in URDF) - joints 1-7
for motor_name in self.bus_right.motors:
if motor_name == "gripper":
continue # Skip gripper, will be handled separately
full_name = f"right_{motor_name}"
q[idx] = q_rad.get(full_name, 0.0)
idx += 1
# Skip right finger joints (leave as zeros)
idx += 2
# Compute generalized gravity vector
g = pin.computeGeneralizedGravity(self.pin_robot.model, self.pin_robot.data, q)
# Map back to motor names (only arm joints, not fingers)
result = {}
idx = 0
# Left arm torques (joints 1-7)
for motor_name in self.bus_left.motors:
if motor_name == "gripper":
result["left_gripper"] = 0.0 # No gravity compensation for gripper
continue
result[f"left_{motor_name}"] = float(g[idx])
idx += 1
# Skip left finger joint torques in output
idx += 2
# Right arm torques (joints 1-7)
for motor_name in self.bus_right.motors:
if motor_name == "gripper":
result["right_gripper"] = 0.0 # No gravity compensation for gripper
continue
result[f"right_{motor_name}"] = float(g[idx])
idx += 1
# Skip right finger joint torques in output
idx += 2
return result