Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/YAM_RUNTIME.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion egomimic/hydra_configs/robot/yam_rl2_hptflow_rollout.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions egomimic/robot/rollout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions egomimic/robot/rollout_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down Expand Up @@ -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")
)
Expand Down Expand Up @@ -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"
],
Expand Down
34 changes: 34 additions & 0 deletions egomimic/robot/rollout_dashboard_static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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})) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions egomimic/robot/rollout_dashboard_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>YAM rollout</title>
<link rel="stylesheet" href="/style.css?v=11">
<link rel="stylesheet" href="/style.css?v=12">
</head>
<body>
<main>
Expand Down Expand Up @@ -52,6 +52,11 @@ <h1>Rollout monitor</h1>
Allow small IK pose errors
<span id="ik-relaxation-detail"></span>
</label>
<section id="gripper-settings" class="gripper-settings" aria-label="Gripper force settings" hidden>
<strong>Gripper force limit</strong>
<div id="gripper-controls"></div>
<small>Applied on the next control tick (1–50 N).</small>
</section>
<section id="velocity-decision" class="velocity-decision" hidden aria-live="assertive">
<strong>Joint velocity limit reached</strong>
<p id="velocity-detail"></p>
Expand Down Expand Up @@ -150,6 +155,6 @@ <h2>Select rollout model</h2>
</dialog>
<!-- Generated from robot/reset_overlay_templates.yaml. Run build_reset_guides.py. -->
<script id="reset-guide-catalog" type="application/json">{"version":1,"templates":[{"id":"rl2-yam-earbuds-20261010","label":"Earbuds \u2014 left / case / right","camera":"front_img_1","image_size":[640,480],"provenance":{"camera_serial":"230322272195","captured_at":"2026-10-10T22:56:09-04:00","source_image_sha256":"ba58a2044fbaef73f4f916a067323752ea8916580ee22b546de21bc558b73336","placement":"User-confirmed table-plane projection: size 105.7, x 0, y 63.5","warning":"Recheck placement after moving the camera or table. Not surveyed calibration."},"regions":[{"label":"Left","points":[[216.06140961614906,231.80945168925663],[283.22134650936016,231.45398786744497],[280.2821555535088,295.01799006867714],[207.04833752125842,295.1446907785335]]},{"label":"Case","points":[[283.22134650936016,231.45398786744497],[350.7955911838059,231.12289178846373],[354.0180506380843,294.7067966731917],[280.2821555535088,295.01799006867714]]},{"label":"Right","points":[[350.7955911838059,231.12289178846373],[418.1763060993439,230.81930061865893],[427.51468215290413,294.2126378607729],[354.0180506380843,294.7067966731917]]}]}]}</script>
<script type="module" src="/app.js?v=14"></script>
<script type="module" src="/app.js?v=15"></script>
</body>
</html>
6 changes: 6 additions & 0 deletions egomimic/robot/rollout_dashboard_static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,9 @@ dialog header { align-items: center; }
@media (max-width: 700px) {
.rollout-button-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.gripper-settings { margin: 1rem 0; padding: .8rem 1rem; border: 1px solid var(--line, #ccd2d8); border-radius: .5rem; }
.gripper-settings small { display: block; margin-top: .35rem; opacity: .7; }
.gripper-control { display: inline-flex; align-items: center; gap: .6rem; margin: .5rem 1rem 0 0; }
.gripper-control span { min-width: 4.5rem; text-transform: capitalize; }
.gripper-control input { width: 12rem; }
.gripper-control output { min-width: 3.5rem; }
113 changes: 111 additions & 2 deletions tests/test_rollout_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,8 +746,8 @@ def test_dashboard_uses_space_and_places_dynamic_inference_controls_below_camera
assert 'class="rollout-button-grid"' in html
assert ".rollout-button-grid { display: grid;" in (static / "style.css").read_text()
assert ".recording[hidden]" in (static / "style.css").read_text()
assert "style.css?v=11" in html
assert "app.js?v=14" in html
assert "style.css?v=12" in html
assert "app.js?v=15" in html
assert 'id="rollout-time"' in html
assert "inference_override" in javascript
assert "updateInferenceControls" in javascript
Expand Down Expand Up @@ -1564,3 +1564,112 @@ def test_dashboard_offers_episode_recording_next_to_video():
assert "record_episode: 'start'" in javascript
assert "save_episode: outcome" in javascript
assert "/api/episodes" in javascript


def test_dashboard_gripper_force_command_reaches_the_rollout_thread(tmp_path):
"""The browser's per-arm force slider becomes one validated request; limits the
rollout thread applied are what the dashboard shows."""
dashboard = RolloutDashboard(
("front_img_1",),
host="127.0.0.1",
port=available_loopback_port(),
open_browser=False,
wait_for_start=True,
action_overlay=overlay_config(calibration_file(tmp_path)),
)
dashboard.set_gripper_force_limits({"left": 20.0, "right": 20.0})

async def send_force():
from aiohttp import ClientSession

async with ClientSession() as session:
async with session.ws_connect(f"{dashboard.url}/ws") as ws:
config = await ws.receive_json()
assert config["gripper_force_limits"] == {"left": 20.0, "right": 20.0}
await ws.send_json({"gripper_force": {"arm": "left", "value": 6}})

try:
asyncio.run(send_force())
deadline = time.monotonic() + 1.0
request = None
while request is None and time.monotonic() < deadline:
request = dashboard.take_gripper_force_request()
time.sleep(0.01)
assert request == {"arm": "left", "value": 6.0}
for bad in (
{"arm": "left", "value": 0},
{"arm": "left", "value": 51},
{"arm": "middle", "value": 6},
{"arm": "left", "value": "x"},
6,
):
assert dashboard.request_gripper_force(bad) is False
assert dashboard.take_gripper_force_request() is None
finally:
dashboard.close()


class ForceRobot(FakeRobot):
def __init__(self, fail=False):
super().__init__()
self.gripper_force_limit = 20.0
self.fail = fail
self.force_calls = []

def set_gripper_force_limit(self, force_limit, arm=None):
self.force_calls.append((force_limit, arm))
if self.fail:
raise RuntimeError("driver cannot change gripper force")
return {arm: float(force_limit)}


class ForceView(GatedView):
def __init__(self, controls, requests):
super().__init__(controls)
self.requests = iter(requests)
self.limits = []

def take_gripper_force_request(self):
return next(self.requests, None)

def set_gripper_force_limits(self, limits):
self.limits.append(limits)


@pytest.mark.parametrize("fail", [False, True])
def test_rollout_applies_dashboard_gripper_force_requests(monkeypatch, fail):
monkeypatch.setattr("egomimic.robot.rollout.time.sleep", lambda _: None)
robot = ForceRobot(fail=fail)
view = ForceView([None, None, "q"], [None, {"arm": "right", "value": 6.0}])
target = np.zeros((1, 14), dtype=float)
target[:, [6, 13]] = 0.5
policy = SimpleNamespace(action_type="cartesian", predict=lambda _obs: target)
run_rollout(
robot,
policy,
{
"frequency": 30,
"max_steps": 4,
"max_joint_velocity": 1.0,
"preview": {"enabled": False},
},
view=view,
)
assert robot.force_calls == [(6.0, "right")]
assert view.limits[0] == {"left": 20.0, "right": 20.0}
expected = {"left": 20.0, "right": 20.0 if fail else 6.0}
assert view.limits[-1] == expected
assert any("Gripper force unchanged" in s for s in view.statuses) is fail


def test_rollout_configs_cap_gripper_force_at_20_newtons():
from pathlib import Path

import yaml

robot_configs = Path(__file__).resolve().parents[1] / "egomimic/hydra_configs/robot"
for name in ("hptflow", "earbuds_hpt300", "towels_hpt300"):
config = yaml.safe_load(
(robot_configs / f"yam_rl2_{name}_rollout.yaml").read_text()
)
assert config["robot"]["gripper_force_limit"] == 20.0, name
Loading