feat(rl): add bus-control primitives and smooth move functionality for leader intervention

This commit is contained in:
Khalil Meftah
2026-04-27 18:31:13 +02:00
parent 13418dcd7b
commit 7b82a5c381
@@ -36,6 +36,12 @@ intact (so :func:`get_action` returns ``{"<motor>.pos": float}``) while adding:
toggled on, leader torque is disabled so the user can move it freely. toggled on, leader torque is disabled so the user can move it freely.
* Lower position-loop gains on :func:`connect` (``P=16, I=0, D=16``) so the * Lower position-loop gains on :func:`connect` (``P=16, I=0, D=16``) so the
haptic follow does not jerk the user's hand when grabbing the leader. haptic follow does not jerk the user's hand when grabbing the leader.
* Bus-control primitives (:func:`enable_torque`, :func:`disable_torque`,
:func:`write_goal_positions`) and a :func:`smooth_move_to` helper. These
satisfy the ``teleop_has_motor_control`` capability gate in
``examples/hil/hil_utils.py``, so the BC-style HIL data collector
(``examples/hil/hil_data_collection.py``) can drive an SO leader the same way
it drives the OpenArm Mini -- pause / smooth-mirror to follower / take over.
The joint-to-EE-delta conversion does **not** happen here; it is performed by The joint-to-EE-delta conversion does **not** happen here; it is performed by
:class:`LeaderArmInterventionStep` in the action processor pipeline so the :class:`LeaderArmInterventionStep` in the action processor pipeline so the
@@ -48,6 +54,7 @@ import contextlib
import logging import logging
import os import os
import sys import sys
import time
from typing import Any from typing import Any
import numpy as np import numpy as np
@@ -170,16 +177,101 @@ class SOLeaderFollower(SOLeader):
self._listener.daemon = True self._listener.daemon = True
self._listener.start() self._listener.start()
@check_if_not_connected def enable_torque(self) -> None:
def get_action(self) -> RobotAction: """Enable position-loop torque on every motor.
# When the user has just toggled into intervention, make sure leader
# torque is OFF so they can move it without fighting the position loop. Exposed alongside :func:`disable_torque` and :func:`write_goal_positions`
if self._is_intervention and self._leader_torque_enabled: so this teleop satisfies the ``teleop_has_motor_control`` gate used by
``examples/hil/hil_data_collection.py`` (mirrors the OpenArm Mini API).
Errors are logged and swallowed -- the loop must keep ticking even if a
single haptic update fails.
"""
if not self.is_connected:
return
try:
self.bus.sync_write("Torque_Enable", 1)
self._leader_torque_enabled = True
except Exception as e: # pragma: no cover - hardware path
logger.warning(f"[SOLeaderFollower] could not enable leader torque: {e}")
def disable_torque(self) -> None:
"""Disable position-loop torque so the user can move the leader freely."""
if not self.is_connected:
return
try: try:
self.bus.sync_write("Torque_Enable", 0) self.bus.sync_write("Torque_Enable", 0)
self._leader_torque_enabled = False self._leader_torque_enabled = False
except Exception as e: # pragma: no cover - hardware path except Exception as e: # pragma: no cover - hardware path
logger.warning(f"[SOLeaderFollower] could not disable leader torque: {e}") logger.warning(f"[SOLeaderFollower] could not disable leader torque: {e}")
def write_goal_positions(self, positions: dict[str, float]) -> None:
"""Push goal positions to the leader bus (no torque toggling).
Accepts dataset-style keys ``{"<motor>.pos": deg}`` (matches what
:func:`get_action` produces and what :func:`smooth_move_to` and
:func:`send_action` consume) -- bare motor names are also tolerated
for parity with :class:`OpenArmMini.write_goal_positions`.
"""
if not self.is_connected:
return
goal_pos: dict[str, float] = {}
for motor in self.bus.motors:
if f"{motor}.pos" in positions:
goal_pos[motor] = float(positions[f"{motor}.pos"])
elif motor in positions:
goal_pos[motor] = float(positions[motor])
if not goal_pos:
return
try:
self.bus.sync_write("Goal_Position", goal_pos)
except Exception as e: # pragma: no cover - hardware path
logger.warning(f"[SOLeaderFollower] could not push goal position to leader: {e}")
def smooth_move_to(
self,
target_pos: dict[str, float],
duration_s: float = 2.0,
fps: int = 50,
) -> None:
"""Linearly ramp the leader from its current pose to ``target_pos``.
Mirrors the ``teleop_smooth_move_to`` helper from
``examples/hil/hil_utils.py`` so the leader can be safely re-engaged
without yanking the user's hand -- typical use is right after
:func:`connect` (or whenever the leader and follower drift apart, e.g.
on episode reset). Blocks for ``duration_s`` seconds.
"""
if not self.is_connected:
return
steps = max(int(duration_s * fps), 1)
try:
current = self.get_action()
except Exception as e: # pragma: no cover - hardware path
logger.warning(f"[SOLeaderFollower] smooth_move_to could not read current pose: {e}")
return
self.enable_torque()
if not self._leader_torque_enabled:
return
for step in range(steps + 1):
t = step / steps
interp = {}
for key, current_val in current.items():
if key in target_pos:
interp[key] = current_val * (1.0 - t) + float(target_pos[key]) * t
else:
interp[key] = current_val
self.write_goal_positions(interp)
time.sleep(1.0 / fps)
@check_if_not_connected
def get_action(self) -> RobotAction:
# When the user has just toggled into intervention, make sure leader
# torque is OFF so they can move it without fighting the position loop.
if self._is_intervention and self._leader_torque_enabled:
self.disable_torque()
return super().get_action() return super().get_action()
def send_action(self, action: dict[str, float]) -> None: # type: ignore[override] def send_action(self, action: dict[str, float]) -> None: # type: ignore[override]
@@ -193,9 +285,6 @@ class SOLeaderFollower(SOLeader):
as the user toggles intervention on (SPACE), torque is dropped in as the user toggles intervention on (SPACE), torque is dropped in
:func:`get_action` so the human can move the leader freely. :func:`get_action` so the human can move the leader freely.
Errors talking to the bus are logged and swallowed -- the policy /
learner loop must keep ticking even if a single haptic update fails.
Args: Args:
action: Dictionary of follower motor positions, ``{motor.pos: deg}``. action: Dictionary of follower motor positions, ``{motor.pos: deg}``.
""" """
@@ -215,20 +304,11 @@ class SOLeaderFollower(SOLeader):
return return
if not self._leader_torque_enabled: if not self._leader_torque_enabled:
try: self.enable_torque()
self.bus.sync_write("Torque_Enable", 1) if not self._leader_torque_enabled:
self._leader_torque_enabled = True
except Exception as e: # pragma: no cover - hardware path
logger.warning(f"[SOLeaderFollower] could not enable leader torque: {e}")
return return
goal_pos = {m: float(action[f"{m}.pos"]) for m in self.bus.motors if f"{m}.pos" in action} self.write_goal_positions(action)
if not goal_pos:
return
try:
self.bus.sync_write("Goal_Position", goal_pos)
except Exception as e: # pragma: no cover - hardware path
logger.warning(f"[SOLeaderFollower] could not push goal position to leader: {e}")
def get_teleop_events(self) -> dict[TeleopEvents, bool]: def get_teleop_events(self) -> dict[TeleopEvents, bool]:
events = { events = {