From 9b42ce54d329bedd3ea12507f41f0bbeebb11b14 Mon Sep 17 00:00:00 2001 From: Aidan Gao Date: Wed, 21 Oct 2026 00:58:29 -0400 Subject: [PATCH] robot: 20 N rollout gripper force limit with a live per-arm dashboard control The YAM rollout configs default `gripper_force_limit` to 20 N (was the firmware default), and the rollout dashboard gets a per-arm force-limit control that the rollout loop applies through `robot.set_gripper_force_limit` while an episode runs. Co-Authored-By: Claude Fable 5.1 --- docs/YAM_RUNTIME.md | 9 ++ .../robot/yam_rl2_earbuds_hpt300_rollout.yaml | 3 +- .../robot/yam_rl2_hptflow_rollout.yaml | 3 +- .../robot/yam_rl2_towels_hpt300_rollout.yaml | 3 +- egomimic/robot/rollout.py | 25 ++++ egomimic/robot/rollout_dashboard.py | 32 +++++ .../robot/rollout_dashboard_static/app.js | 34 ++++++ .../robot/rollout_dashboard_static/index.html | 9 +- .../robot/rollout_dashboard_static/style.css | 6 + tests/test_rollout_dashboard.py | 113 +++++++++++++++++- 10 files changed, 230 insertions(+), 7 deletions(-) diff --git a/docs/YAM_RUNTIME.md b/docs/YAM_RUNTIME.md index ab4ea4549..a2d875f0d 100644 --- a/docs/YAM_RUNTIME.md +++ b/docs/YAM_RUNTIME.md @@ -458,6 +458,15 @@ the builder synchronizes rollout's copy); keep template coordinates out of JavaScript. Refresh the browser to load these static-only changes; no collection or robot session restart is needed. +### Rollout gripper force limit + +Rollouts start with `robot.gripper_force_limit: 20.0` N (the YAM driver's maximum +is 50 N; GELLO collection stays at 50). When the robot can change it live +(`YamInterface.set_gripper_force_limit`), the rollout dashboard shows one +**Gripper force limit** slider per arm (1-50 N). A change is applied by the +rollout thread on its next control tick and the dashboard shows the limit the +driver reports; a driver that refuses keeps its limit and says so in the status. + ### Optional rollout IK pose relaxation `ik_relaxation.enabled` defaults to `false`. On YAM Cartesian rollouts the diff --git a/egomimic/hydra_configs/robot/yam_rl2_earbuds_hpt300_rollout.yaml b/egomimic/hydra_configs/robot/yam_rl2_earbuds_hpt300_rollout.yaml index f562b5827..1308e2db2 100644 --- a/egomimic/hydra_configs/robot/yam_rl2_earbuds_hpt300_rollout.yaml +++ b/egomimic/hydra_configs/robot/yam_rl2_earbuds_hpt300_rollout.yaml @@ -10,7 +10,8 @@ robot: gripper_type: linear_4310 gripper_kp: 20.0 gripper_kd: 0.5 - gripper_force_limit: 50.0 + # Rollouts cap gripper force at 20 N (Aidan 2026-10-21); live per-arm control on the dashboard. + gripper_force_limit: 20.0 zero_gravity_mode: true enable_auto_recovery: true frequency: 60 diff --git a/egomimic/hydra_configs/robot/yam_rl2_hptflow_rollout.yaml b/egomimic/hydra_configs/robot/yam_rl2_hptflow_rollout.yaml index c5ab5121a..0f8b58c0d 100644 --- a/egomimic/hydra_configs/robot/yam_rl2_hptflow_rollout.yaml +++ b/egomimic/hydra_configs/robot/yam_rl2_hptflow_rollout.yaml @@ -10,7 +10,8 @@ robot: gripper_type: linear_4310 gripper_kp: 20.0 gripper_kd: 0.5 - gripper_force_limit: 50.0 + # Rollouts cap gripper force at 20 N (Aidan 2026-10-21); live per-arm control on the dashboard. + gripper_force_limit: 20.0 zero_gravity_mode: true enable_auto_recovery: true frequency: 60 diff --git a/egomimic/hydra_configs/robot/yam_rl2_towels_hpt300_rollout.yaml b/egomimic/hydra_configs/robot/yam_rl2_towels_hpt300_rollout.yaml index dac2c7e3a..608ec5682 100644 --- a/egomimic/hydra_configs/robot/yam_rl2_towels_hpt300_rollout.yaml +++ b/egomimic/hydra_configs/robot/yam_rl2_towels_hpt300_rollout.yaml @@ -10,7 +10,8 @@ robot: gripper_type: linear_4310 gripper_kp: 20.0 gripper_kd: 0.5 - gripper_force_limit: 50.0 + # Rollouts cap gripper force at 20 N (Aidan 2026-10-21); live per-arm control on the dashboard. + gripper_force_limit: 20.0 zero_gravity_mode: true enable_auto_recovery: true frequency: 60 diff --git a/egomimic/robot/rollout.py b/egomimic/robot/rollout.py index 5af305c6e..1b8700ab5 100644 --- a/egomimic/robot/rollout.py +++ b/egomimic/robot/rollout.py @@ -354,6 +354,21 @@ def publish_ik_relaxation(): publish_ik_relaxation() + # Live gripper force limit: only robots that can change it (YamInterface). + set_force = getattr(robot, "set_gripper_force_limit", None) + force_limits = ( + {arm: float(robot.gripper_force_limit) for arm in robot.arms} + if callable(set_force) and hasattr(robot, "gripper_force_limit") + else {} + ) + + def publish_gripper_force(): + publish = getattr(view, "set_gripper_force_limits", None) + if callable(publish) and force_limits: + publish(dict(force_limits)) + + publish_gripper_force() + def publish_ik_solver(error=None): publish = getattr(view, "set_ik_solver", None) if callable(publish): @@ -486,6 +501,16 @@ def publish_finished_episodes(): queue.clear() _reset_policy_state(policy) publish_ik_relaxation() + take_force = getattr(view, "take_gripper_force_request", None) + force_request = take_force() if callable(take_force) else None + if force_request is not None and force_limits: + try: + force_limits.update( + set_force(force_request["value"], arm=force_request["arm"]) + ) + except (KeyError, TypeError, ValueError, RuntimeError) as error: + _set_view_status(view, f"Gripper force unchanged: {error}") + publish_gripper_force() bundle = _take_model_selection_request(view) if bundle is not None: # Freeze the current measured pose before the potentially slow diff --git a/egomimic/robot/rollout_dashboard.py b/egomimic/robot/rollout_dashboard.py index 27fd5caf9..7385cf605 100644 --- a/egomimic/robot/rollout_dashboard.py +++ b/egomimic/robot/rollout_dashboard.py @@ -621,6 +621,8 @@ def __init__( self._inference_controls_revision = 0 self._ik_relaxation = {"available": False, "enabled": False} self._ik_relaxation_request = None + self._gripper_force_limits: dict[str, float] = {} + self._gripper_force_request = None self._ik_solver = {"available": False, "backend": "mujoco", "error": None} self._ik_solver_request = None self._inference_ms = deque(maxlen=20) @@ -889,6 +891,30 @@ def take_ik_relaxation_request(self): request, self._ik_relaxation_request = self._ik_relaxation_request, None return request + def set_gripper_force_limits(self, limits) -> None: + """Publish the limits the rollout thread applied, never command a robot.""" + with self._lock: + self._gripper_force_limits = {arm: float(v) for arm, v in limits.items()} + + def request_gripper_force(self, request) -> bool: + """Queue one arm's new force limit (1-50 N) for the rollout thread.""" + if not isinstance(request, Mapping): + return False + try: + arm, value = request.get("arm"), float(request.get("value")) + except (TypeError, ValueError): + return False + with self._lock: + if arm not in self._gripper_force_limits or not 1 <= value <= 50: + return False + self._gripper_force_request = {"arm": arm, "value": value} + return True + + def take_gripper_force_request(self): + with self._lock: + request, self._gripper_force_request = self._gripper_force_request, None + return request + def request_inference_override(self, name: object, value: object) -> None: """Compatibility wrapper for callers submitting one declared value.""" self.request_inference_overrides({name: value}) @@ -1036,6 +1062,7 @@ def _snapshot(self) -> dict: "inference_controls": deepcopy(self._inference_controls), "ik_relaxation": dict(self._ik_relaxation), "ik_solver": dict(self._ik_solver), + "gripper_force_limits": dict(self._gripper_force_limits), "inference_controls_revision": self._inference_controls_revision, "inference": { "samples": len(inference_ms), @@ -1111,6 +1138,7 @@ async def websocket(request): inference_controls_revision = self._inference_controls_revision ik_relaxation = dict(self._ik_relaxation) ik_solver = dict(self._ik_solver) + gripper_force_limits = dict(self._gripper_force_limits) checkpoint = self._checkpoint rollout_time = self._rollout_timer.snapshot() for client in superseded: @@ -1135,6 +1163,7 @@ async def websocket(request): "inference_controls": inference_controls, "ik_relaxation": ik_relaxation, "ik_solver": ik_solver, + "gripper_force_limits": gripper_force_limits, "inference_controls_revision": inference_controls_revision, "video_recording_enabled": self.video_recording_config[ "enabled" @@ -1184,6 +1213,8 @@ async def websocket(request): self.request_ik_solver(command["ik_solver"]) if "ik_relaxation" in command: self.request_ik_relaxation(command["ik_relaxation"]) + if "gripper_force" in command: + self.request_gripper_force(command["gripper_force"]) overrides = command.get( "inference_overrides", command.get("inference_override") ) @@ -1303,6 +1334,7 @@ async def episodes(_request): "inference_controls": snapshot["inference_controls"], "ik_relaxation": snapshot["ik_relaxation"], "ik_solver": snapshot["ik_solver"], + "gripper_force_limits": snapshot["gripper_force_limits"], "inference_controls_revision": snapshot[ "inference_controls_revision" ], diff --git a/egomimic/robot/rollout_dashboard_static/app.js b/egomimic/robot/rollout_dashboard_static/app.js index 31e391d7f..d27013901 100644 --- a/egomimic/robot/rollout_dashboard_static/app.js +++ b/egomimic/robot/rollout_dashboard_static/app.js @@ -167,6 +167,38 @@ function updateIkRelaxation(settings) { : ''; } +function updateGripperForce(limits) { + const entries = Object.entries(limits ?? {}); + const root = $('gripper-controls'); + $('gripper-settings').hidden = entries.length === 0; + if (root.childElementCount !== entries.length) { + root.replaceChildren(); + for (const [arm] of entries) { + const label = document.createElement('label'); + label.className = 'gripper-control'; + label.dataset.arm = arm; + const name = document.createElement('span'); + name.textContent = `${arm} arm`; + const slider = document.createElement('input'); + slider.type = 'range'; slider.min = '1'; slider.max = '50'; slider.step = '1'; + const readout = document.createElement('output'); + slider.oninput = () => { readout.textContent = `${slider.value} N`; }; + slider.onchange = () => { + if (!send({gripper_force: {arm, value: Number(slider.value)}})) reportDisconnected(); + }; + label.append(name, slider, readout); + root.append(label); + } + } + for (const [arm, value] of entries) { + const slider = root.querySelector(`[data-arm="${arm}"] input`); + if (slider && document.activeElement !== slider) { + slider.value = value; + slider.nextElementSibling.textContent = `${Math.round(value)} N`; + } + } +} + $('ik-relaxation').addEventListener('change', event => { const enabled = event.target.checked; if (send({ik_relaxation: enabled})) { @@ -622,6 +654,7 @@ function chooseVelocityAction(action) { function configure(message) { ikRelaxationPending = null; updateIkRelaxation(message.ik_relaxation); + updateGripperForce(message.gripper_force_limits); updateIkSolver(message.ik_solver); waitForStart = Boolean(message.wait_for_start); paused = Boolean(message.paused); @@ -684,6 +717,7 @@ function configure(message) { function frame(message) { updateIkRelaxation(message.ik_relaxation); + updateGripperForce(message.gripper_force_limits); updateIkSolver(message.ik_solver); paused = Boolean(message.paused); started = Boolean(message.started); diff --git a/egomimic/robot/rollout_dashboard_static/index.html b/egomimic/robot/rollout_dashboard_static/index.html index c445bca21..7bc44f567 100644 --- a/egomimic/robot/rollout_dashboard_static/index.html +++ b/egomimic/robot/rollout_dashboard_static/index.html @@ -4,7 +4,7 @@ YAM rollout - +
@@ -52,6 +52,11 @@

Rollout monitor

Allow small IK pose errors +