From ca8c191de58b2caaadf11efd2812d444e19d288e Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 3 Sep 2026 14:20:26 -0700 Subject: [PATCH 1/9] feat(imitation): add direct OpenYAM teaching --- dimos/cli/commands/collect.py | 247 ++++++++++++++++++ dimos/cli/commands/test_collect.py | 125 +++++++++ dimos/cli/dimos.py | 2 + dimos/control/tasks/teach_task/_registry.py | 17 ++ dimos/control/tasks/teach_task/teach_task.py | 138 ++++++++++ .../tasks/teach_task/test_teach_task.py | 172 ++++++++++++ dimos/imitation/README.md | 82 +++++- dimos/imitation/collection/episode_monitor.py | 28 +- .../collection/test_episode_monitor.py | 40 +++ dimos/robot/all_blueprints.py | 1 + .../openyam/blueprints/learning_collection.py | 73 +++++- .../blueprints/test_learning_collection.py | 59 +++++ 12 files changed, 966 insertions(+), 18 deletions(-) create mode 100644 dimos/cli/commands/collect.py create mode 100644 dimos/cli/commands/test_collect.py create mode 100644 dimos/control/tasks/teach_task/_registry.py create mode 100644 dimos/control/tasks/teach_task/teach_task.py create mode 100644 dimos/control/tasks/teach_task/test_teach_task.py diff --git a/dimos/cli/commands/collect.py b/dimos/cli/commands/collect.py new file mode 100644 index 0000000000..8074b912ac --- /dev/null +++ b/dimos/cli/commands/collect.py @@ -0,0 +1,247 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Interactive controls for an already-running teach collection stack.""" + +from __future__ import annotations + +from typing import Any, cast + +from rich.panel import Panel +from rich.text import Text +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer, Static +import typer + +from dimos.cli import theme +from dimos.msgs.imitation_msgs.EpisodeStatus import EpisodeStatus +from dimos.porcelain.dimos import Dimos + +_MONITOR = "EpisodeMonitorModule" +_COORDINATOR = "ControlCoordinator" +_GRIPPER_TASK = "arm_gripper" +_REQUIRED_TASKS = {"teach_openyam", _GRIPPER_TASK} + + +class TeachCollectionSession: + """RPC client for the operator controls used by the collection panel.""" + + def __init__(self, client: Dimos, monitor: Any, coordinator: Any) -> None: + self._client = client + self._monitor = monitor + self._coordinator = coordinator + self.gripper_target: float | None = None + + @classmethod + def connect(cls) -> TeachCollectionSession: + """Attach to and validate the canonical teach collection modules.""" + client = Dimos.connect() + try: + modules = {info.instance_name: info for info in client.list_modules()} + for name, rpcs in { + _MONITOR: {"command", "get_status"}, + _COORDINATOR: {"list_tasks", "task_invoke"}, + }.items(): + info = modules.get(name) + if info is None: + raise RuntimeError(f"running stack has no {name!r} module") + available = {rpc.name for rpc in info.rpcs} + missing = rpcs - available + if missing: + raise RuntimeError(f"{name!r} is missing RPCs: {sorted(missing)}") + + monitor = cast("Any", client.get_module(_MONITOR)) + coordinator = cast("Any", client.get_module(_COORDINATOR)) + tasks = set(coordinator.list_tasks()) + missing_tasks = _REQUIRED_TASKS - tasks + if missing_tasks: + raise RuntimeError(f"ControlCoordinator is missing tasks: {sorted(missing_tasks)}") + monitor.get_status() + return cls(client, monitor, coordinator) + except Exception: + client.stop() + raise + + def get_status(self) -> EpisodeStatus: + """Read the monitor's latest state.""" + status = self._monitor.get_status() + if not isinstance(status, EpisodeStatus): + raise RuntimeError( + f"EpisodeMonitorModule returned {type(status).__name__}, expected EpisodeStatus" + ) + return status + + def command(self, event: str) -> EpisodeStatus: + """Send one episode command.""" + status = self._monitor.command(event) + if not isinstance(status, EpisodeStatus): + raise RuntimeError( + f"EpisodeMonitorModule returned {type(status).__name__}, expected EpisodeStatus" + ) + return status + + def set_gripper(self, target: float) -> None: + """Set and retain a normalized gripper target.""" + accepted = self._coordinator.task_invoke( + _GRIPPER_TASK, + "set_normalized", + {"values": [target], "t_now": None}, + ) + if accepted is not True: + raise RuntimeError(f"arm_gripper rejected normalized target {target}") + self.gripper_target = target + + def close(self) -> None: + """Close only this RPC client; leave the daemon and robot running.""" + self._client.stop() + + +class TeachCollectionApp(App[None]): + """Small keyboard panel for teach collection.""" + + CSS = f""" + Screen {{ + align: center middle; + background: {theme.BACKGROUND}; + }} + #status {{ + width: 72; + height: auto; + }} + """ + + BINDINGS = [ + Binding("space", "toggle_recording", "Start / save"), + Binding("d", "discard", "Discard"), + Binding("o", "open_gripper", "Open gripper"), + Binding("c", "close_gripper", "Close gripper"), + Binding("q", "quit", "Detach"), + Binding("ctrl+c", "quit", "Detach", show=False), + ] + + def __init__(self, session: TeachCollectionSession) -> None: + super().__init__() + self._session = session + self._status = session.get_status() + self._message = "Drag the arm by hand; press Space when the take begins." + self._detached = False + + def compose(self) -> ComposeResult: + yield Static(self._render(), id="status") + yield Footer() + + def on_mount(self) -> None: + self.set_interval(0.25, self._poll) + + def on_unmount(self) -> None: + self._session.close() + + def _render(self) -> Panel: + recording = self._status.state == "recording" + state_style = theme.ERROR if recording else theme.SUCCESS + gripper = ( + "measured position" + if self._session.gripper_target is None + else ("open (1.0)" if self._session.gripper_target == 1.0 else "closed (0.0)") + ) + body = Text() + body.append("Task ", style="bold") + body.append(f"{self._status.task_label}\n") + body.append("State ", style="bold") + body.append(f"{self._status.state.upper()}\n", style=f"bold {state_style}") + body.append("Episodes ", style="bold") + body.append( + f"{self._status.episodes_saved} saved, {self._status.episodes_discarded} discarded\n" + ) + body.append("Gripper ", style="bold") + body.append(f"{gripper}\n\n") + body.append(self._message) + if self._detached: + body.append( + "\n\nRPC connection closed; the daemon and arm are still running.", + style=theme.ERROR, + ) + return Panel(body, title="OpenYAM teach collection", border_style=theme.BORDER) + + def _refresh(self) -> None: + self.query_one("#status", Static).update(self._render()) + + def _poll(self) -> None: + if self._detached: + return + try: + self._status = self._session.get_status() + self._refresh() + except Exception as exc: + self._fail(exc) + + def _fail(self, exc: Exception) -> None: + self._message = f"Connection error: {exc}" + self._detached = True + self._session.close() + self._refresh() + + def _episode_command(self, event: str) -> None: + if self._detached: + return + try: + self._status = self._session.command(event) + self._message = { + "start": "Recording. Drag the arm through the demonstration.", + "save": "Episode saved. Reset the scene for the next take.", + "discard": "Episode discarded. Reset the scene and try again.", + }.get(self._status.last_event, self._status.last_event) + self._refresh() + except Exception as exc: + self._fail(exc) + + def _set_gripper(self, target: float) -> None: + if self._detached: + return + try: + self._session.set_gripper(target) + self._message = "Gripper opened." if target == 1.0 else "Gripper closed." + self._refresh() + except Exception as exc: + self._fail(exc) + + def action_toggle_recording(self) -> None: + self._episode_command("toggle") + + def action_discard(self) -> None: + self._episode_command("discard") + + def action_open_gripper(self) -> None: + self._set_gripper(1.0) + + def action_close_gripper(self) -> None: + self._set_gripper(0.0) + + def action_quit(self) -> None: # type: ignore[override] + if not self._detached and self._status.state == "recording": + self._message = "Save with Space or discard with D before detaching." + self._refresh() + return + self.exit() + + +def collect() -> None: + """Control an already-running OpenYAM teach collection stack.""" + try: + session = TeachCollectionSession.connect() + except Exception as exc: + typer.echo(f"Unable to attach collection controls: {exc}", err=True) + raise typer.Exit(1) from exc + TeachCollectionApp(session).run() diff --git a/dimos/cli/commands/test_collect.py b/dimos/cli/commands/test_collect.py new file mode 100644 index 0000000000..7adf1ca8c6 --- /dev/null +++ b/dimos/cli/commands/test_collect.py @@ -0,0 +1,125 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any + +from pytest_mock import MockerFixture + +from dimos.cli.commands.collect import TeachCollectionApp, TeachCollectionSession +from dimos.core.introspection.module.info import ModuleInfo, RpcInfo +from dimos.msgs.imitation_msgs.EpisodeStatus import EpisodeStatus + + +def _status(state: str = "idle") -> EpisodeStatus: + return EpisodeStatus( + ts=1.0, + state=state, # type: ignore[arg-type] + episodes_saved=0, + episodes_discarded=0, + task_label="pick up the block", + ) + + +def _session(mocker: MockerFixture) -> tuple[TeachCollectionSession, Any, Any, Any]: + client = mocker.Mock() + monitor = mocker.Mock() + coordinator = mocker.Mock() + monitor.get_status.return_value = _status() + monitor.command.side_effect = [_status("recording"), _status("idle")] + coordinator.task_invoke.return_value = True + return TeachCollectionSession(client, monitor, coordinator), client, monitor, coordinator + + +def test_session_routes_episode_and_gripper_commands(mocker: MockerFixture) -> None: + session, _, monitor, coordinator = _session(mocker) + + assert session.command("toggle").state == "recording" + session.set_gripper(1.0) + session.set_gripper(0.0) + + monitor.command.assert_called_once_with("toggle") + assert coordinator.task_invoke.call_args_list == [ + mocker.call( + "arm_gripper", + "set_normalized", + {"values": [1.0], "t_now": None}, + ), + mocker.call( + "arm_gripper", + "set_normalized", + {"values": [0.0], "t_now": None}, + ), + ] + assert session.gripper_target == 0.0 + + +def test_panel_actions_route_keys_and_guard_quit(mocker: MockerFixture) -> None: + session, _, monitor, coordinator = _session(mocker) + app = TeachCollectionApp(session) + mocker.patch.object(app, "_refresh") + exit_mock = mocker.patch.object(app, "exit") + + app.action_toggle_recording() + app.action_quit() + app.action_open_gripper() + app.action_close_gripper() + app.action_discard() + app.action_quit() + + assert monitor.command.call_args_list == [mocker.call("toggle"), mocker.call("discard")] + assert coordinator.task_invoke.call_count == 2 + exit_mock.assert_called_once_with() + + +def test_panel_binds_the_documented_keys() -> None: + assert {binding.key: binding.action for binding in TeachCollectionApp.BINDINGS} == { + "space": "toggle_recording", + "d": "discard", + "o": "open_gripper", + "c": "close_gripper", + "q": "quit", + "ctrl+c": "quit", + } + + +def test_rpc_failure_detaches_without_stopping_the_daemon(mocker: MockerFixture) -> None: + session, client, monitor, _ = _session(mocker) + app = TeachCollectionApp(session) + mocker.patch.object(app, "_refresh") + monitor.command.side_effect = RuntimeError("stack disappeared") + + app.action_toggle_recording() + + assert app._detached is True + client.stop.assert_called_once_with() + + +def test_connect_rejects_the_wrong_stack_and_closes_client(mocker: MockerFixture) -> None: + client = mocker.Mock() + client.list_modules.return_value = [ + ModuleInfo( + name="EpisodeMonitorModule", + instance_name="EpisodeMonitorModule", + rpcs=[RpcInfo(name="command"), RpcInfo(name="get_status")], + ) + ] + mocker.patch("dimos.cli.commands.collect.Dimos.connect", return_value=client) + + try: + TeachCollectionSession.connect() + except RuntimeError as exc: + assert "ControlCoordinator" in str(exc) + else: + raise AssertionError("wrong stack should fail validation") + client.stop.assert_called_once_with() diff --git a/dimos/cli/dimos.py b/dimos/cli/dimos.py index 3bc1df6be9..134493a6a8 100644 --- a/dimos/cli/dimos.py +++ b/dimos/cli/dimos.py @@ -53,6 +53,7 @@ from dimos.cli.commands.bake import bake from dimos.cli.commands.cameracalibrate import cameracalibrate from dimos.cli.commands.data import data_app +from dimos.cli.commands.collect import collect from dimos.cli.commands.dataprep import dataprep_app from dimos.cli.commands.docs import docs from dimos.cli.commands.global_options import create_dynamic_callback @@ -128,6 +129,7 @@ def cli_main() -> None: )(bake) main.command(name="list")(list_blueprints) main.command()(docs) +main.command()(collect) main.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})(spy) main.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})(lcmspy) main.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})(agentspy) diff --git a/dimos/control/tasks/teach_task/_registry.py b/dimos/control/tasks/teach_task/_registry.py new file mode 100644 index 0000000000..9e3226f440 --- /dev/null +++ b/dimos/control/tasks/teach_task/_registry.py @@ -0,0 +1,17 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +TASK_FACTORIES = { + "teach": "dimos.control.tasks.teach_task.teach_task:create_task", +} diff --git a/dimos/control/tasks/teach_task/teach_task.py b/dimos/control/tasks/teach_task/teach_task.py new file mode 100644 index 0000000000..e27214e74a --- /dev/null +++ b/dimos/control/tasks/teach_task/teach_task.py @@ -0,0 +1,138 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Measured-position passthrough for gravity-compensated teaching.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Any + +from dimos.control.hardware_interface import ConnectedWholeBody +from dimos.control.task import ( + BaseControlTask, + ControlMode, + CoordinatorState, + JointCommandOutput, + ResourceClaim, +) + + +@dataclass(frozen=True) +class TeachControlTaskConfig: + """Configuration for a gravity-compensated teach task.""" + + joint_names: tuple[str, ...] + priority: int = 10 + + +class TeachControlTask(BaseControlTask): + """Continuously command each joint's measured position. + + On zero-stiffness whole-body hardware this keeps gravity compensation and + damping active while allowing an operator to move the mechanism by hand. + """ + + def __init__(self, name: str, config: TeachControlTaskConfig) -> None: + self._name = name + self._config = config + + def claim(self) -> ResourceClaim: + """Claim the taught joints at the configured priority.""" + return ResourceClaim( + joints=frozenset(self._config.joint_names), + priority=self._config.priority, + mode=ControlMode.SERVO_POSITION, + ) + + def is_active(self) -> bool: + """Keep the hardware control loop active for the entire run.""" + return True + + def compute(self, state: CoordinatorState) -> JointCommandOutput | None: + """Mirror a complete, finite measured-position snapshot.""" + positions: list[float] = [] + for joint_name in self._config.joint_names: + position = state.joints.get_position(joint_name) + if position is None or not math.isfinite(position): + return None + positions.append(position) + return JointCommandOutput( + joint_names=list(self._config.joint_names), + positions=positions, + mode=ControlMode.SERVO_POSITION, + ) + + def on_preempted(self, by_task: str, joints: frozenset[str]) -> None: + """Allow higher-priority tasks to override individual joints.""" + + +def _validate_hardware(cfg: Any, hardware: Any) -> None: + where = f"teach task {cfg.name!r}" + joint_names = list(cfg.joint_names) + if not joint_names: + raise ValueError(f"{where}: requires at least one joint") + if len(set(joint_names)) != len(joint_names): + raise ValueError(f"{where}: joint_names must not contain duplicates") + + owners: list[ConnectedWholeBody] = [] + for joint_name in joint_names: + matches = [ + connected + for connected in (hardware or {}).values() + if joint_name in connected.component.joints + ] + if not matches: + raise ValueError(f"{where}: joint {joint_name!r} is not owned by coordinator hardware") + if len(matches) > 1: + raise ValueError(f"{where}: joint {joint_name!r} is owned by multiple components") + owner = matches[0] + if not isinstance(owner, ConnectedWholeBody): + raise ValueError(f"{where}: requires whole-body hardware") + owners.append(owner) + + owner = owners[0] + if any(candidate is not owner for candidate in owners[1:]): + raise ValueError(f"{where}: all joints must belong to one whole-body component") + component = owner.component + wb_config = component.wb_config + if wb_config is None or wb_config.kp is None or wb_config.kd is None: + raise ValueError(f"{where}: whole-body hardware requires explicit kp and kd") + if len(wb_config.kp) != len(component.joints) or len(wb_config.kd) != len(component.joints): + raise ValueError( + f"{where}: kp and kd must match the component's {len(component.joints)} joints" + ) + + indices = [component.joints.index(name) for name in joint_names] + stiffness = [wb_config.kp[index] for index in indices] + damping = [wb_config.kd[index] for index in indices] + if any(not math.isfinite(value) for value in [*stiffness, *damping]): + raise ValueError(f"{where}: kp and kd must be finite") + if any(value != 0.0 for value in stiffness): + raise ValueError(f"{where}: requires zero stiffness (kp=0) for every taught joint") + if any(value < 0.0 for value in damping): + raise ValueError(f"{where}: damping (kd) must be non-negative") + + +def create_task(cfg: Any, hardware: Any) -> TeachControlTask: + """Build and validate a teach task from coordinator configuration.""" + _validate_hardware(cfg, hardware) + return TeachControlTask( + cfg.name, + TeachControlTaskConfig( + joint_names=tuple(cfg.joint_names), + priority=cfg.priority, + ), + ) diff --git a/dimos/control/tasks/teach_task/test_teach_task.py b/dimos/control/tasks/teach_task/test_teach_task.py new file mode 100644 index 0000000000..723bb209c0 --- /dev/null +++ b/dimos/control/tasks/teach_task/test_teach_task.py @@ -0,0 +1,172 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import replace + +import pytest +from pytest_mock import MockerFixture + +from dimos.control.components import HardwareComponent, HardwareType +from dimos.control.coordinator import TaskConfig +from dimos.control.hardware_interface import ConnectedHardware, ConnectedWholeBody +from dimos.control.task import ControlMode, CoordinatorState, JointStateSnapshot +from dimos.control.tasks.gripper_task.gripper_task import ( + GripperControlTask, + GripperControlTaskConfig, +) +from dimos.control.tasks.teach_task.teach_task import ( + TeachControlTask, + TeachControlTaskConfig, + create_task, +) +from dimos.control.tick_loop import TickLoop +from dimos.hardware.manipulators.spec import ManipulatorAdapter +from dimos.hardware.whole_body.spec import WholeBodyAdapter, WholeBodyConfig +from dimos.robot.manipulators.openyam.config import OPENYAM_JOINTS +from dimos.robot.manipulators.openyam.learning import OPENYAM_LEARNING_PROFILE + + +def _state(positions: dict[str, float]) -> CoordinatorState: + return CoordinatorState(joints=JointStateSnapshot(joint_positions=positions)) + + +def _whole_body( + mocker: MockerFixture, + *, + joints: list[str] | None = None, + kp: tuple[float, ...] | None = None, + kd: tuple[float, ...] | None = None, +) -> ConnectedWholeBody: + names = list(joints or OPENYAM_JOINTS) + component = HardwareComponent( + hardware_id="robot", + hardware_type=HardwareType.WHOLE_BODY, + joints=names, + wb_config=WholeBodyConfig( + kp=(0.0,) * len(names) if kp is None else kp, + kd=(1.0,) * len(names) if kd is None else kd, + ), + ) + return ConnectedWholeBody(mocker.Mock(spec=WholeBodyAdapter), component) + + +def _cfg(joints: list[str] | None = None) -> TaskConfig: + return TaskConfig( + name="teach", + type="teach", + joint_names=list(OPENYAM_JOINTS if joints is None else joints), + priority=10, + ) + + +def test_teach_task_mirrors_a_complete_measured_state_in_order() -> None: + task = TeachControlTask( + "teach", + TeachControlTaskConfig(tuple(OPENYAM_JOINTS), priority=10), + ) + positions = {name: float(index) for index, name in enumerate(reversed(OPENYAM_JOINTS))} + + output = task.compute(_state(positions)) + + assert task.is_active() + assert task.claim().mode is ControlMode.SERVO_POSITION + assert output is not None + assert output.joint_names == OPENYAM_JOINTS + assert output.positions == [positions[name] for name in OPENYAM_JOINTS] + + +@pytest.mark.parametrize("bad_position", [None, float("nan"), float("inf")]) +def test_teach_task_rejects_incomplete_or_nonfinite_state(bad_position: float | None) -> None: + task = TeachControlTask("teach", TeachControlTaskConfig(tuple(OPENYAM_JOINTS))) + positions = {name: 0.0 for name in OPENYAM_JOINTS} + if bad_position is None: + del positions[OPENYAM_JOINTS[0]] + else: + positions[OPENYAM_JOINTS[0]] = bad_position + + assert task.compute(_state(positions)) is None + + +def test_gripper_preempts_only_the_seventh_teach_action() -> None: + teach = TeachControlTask("teach", TeachControlTaskConfig(tuple(OPENYAM_JOINTS), priority=10)) + gripper = GripperControlTask( + "arm_gripper", + GripperControlTaskConfig([OPENYAM_JOINTS[-1]], priority=20), + limits=[(0.0, 1.0)], + ) + assert gripper.set_normalized([1.0]) + state = _state({name: index / 10 for index, name in enumerate(OPENYAM_JOINTS)}) + commands = [ + (teach, teach.claim(), teach.compute(state)), + (gripper, gripper.claim(), gripper.compute(state)), + ] + + winners, _ = TickLoop._arbitrate(object.__new__(TickLoop), commands) + + assert list(winners) == OPENYAM_JOINTS + assert list(winners) == OPENYAM_LEARNING_PROFILE.dataprep_config().action["action"].names + assert [value for value, _, _ in winners.values()][:-1] == pytest.approx( + [index / 10 for index in range(6)] + ) + assert winners[OPENYAM_JOINTS[-1]] == (1.0, ControlMode.SERVO_POSITION, "arm_gripper") + + +def test_factory_accepts_zero_stiffness_whole_body_hardware( + mocker: MockerFixture, +) -> None: + task = create_task(_cfg(), {"robot": _whole_body(mocker)}) + assert task.claim().joints == frozenset(OPENYAM_JOINTS) + + +def test_factory_rejects_non_whole_body_hardware(mocker: MockerFixture) -> None: + component = HardwareComponent( + hardware_id="robot", + hardware_type=HardwareType.MANIPULATOR, + joints=list(OPENYAM_JOINTS), + ) + hardware = { + "robot": ConnectedHardware(mocker.Mock(spec=ManipulatorAdapter), component), + } + + with pytest.raises(ValueError, match="requires whole-body hardware"): + create_task(_cfg(), hardware) + + +@pytest.mark.parametrize( + ("cfg", "component_update", "match"), + [ + (_cfg([]), {}, "requires at least one joint"), + (_cfg([OPENYAM_JOINTS[0], OPENYAM_JOINTS[0]]), {}, "duplicates"), + (_cfg(["missing"]), {}, "not owned"), + (_cfg(), {"kp": (1.0,) * len(OPENYAM_JOINTS)}, "zero stiffness"), + ( + _cfg(), + {"kd": (float("nan"),) * len(OPENYAM_JOINTS)}, + "must be finite", + ), + ], +) +def test_factory_rejects_unsafe_configuration( + mocker: MockerFixture, + cfg: TaskConfig, + component_update: dict[str, tuple[float, ...]], + match: str, +) -> None: + hardware = _whole_body(mocker) + if component_update: + assert hardware.component.wb_config is not None + hardware.component.wb_config = replace(hardware.component.wb_config, **component_update) + + with pytest.raises(ValueError, match=match): + create_task(cfg, {"robot": hardware}) diff --git a/dimos/imitation/README.md b/dimos/imitation/README.md index 846e5c3958..16f6ff9296 100644 --- a/dimos/imitation/README.md +++ b/dimos/imitation/README.md @@ -1,11 +1,14 @@ # Imitation Learning Collect demonstrations, build training datasets, and run trained policies in -DimOS. Teleoperation records episodes to a SQLite or MCAP artifact, and DataPrep -converts that recording into a LeRobot or HDF5 dataset for imitation learning. +DimOS. Quest teleoperation or direct arm teaching records episodes to a SQLite +or MCAP artifact. DataPrep converts that recording into a LeRobot or HDF5 +dataset for imitation learning. ``` -teleop (Quest) ─▶ recorder ─▶ session__.db/.mcap ─▶ dimos dataprep ─▶ dataset +Quest teleop ─┐ + ├─▶ recorder ─▶ session__.db/.mcap ─▶ dimos dataprep ─▶ dataset +direct teach ─┘ ``` After training, use the production @@ -56,6 +59,79 @@ prints one line per transition: > End each good take with **B** before quitting — an episode still recording at > shutdown is dropped. +### OpenYAM direct teaching + +Direct teaching removes the Quest teleoperator. The arm runs with gravity +compensation, zero position stiffness, and joint damping. Move it by hand while +the existing OpenYAM observation and action streams are recorded. + +Start the hardware stack in one terminal. Be ready to support the arm as it +activates, and keep people and obstacles outside its workspace. + +```bash +dimos --can-port follower_l run learning-collect-teach-openyam --daemon \ + --task "pick up the red block" \ + --WristCamera.hardware.camera-index 0 \ + --nativecollectionrecorder.store.path data/recordings/openyam-teach.mcap +``` + +Attach the collection panel from another terminal: + +```bash +dimos collect +``` + +```text +┌────────────────────── OpenYAM teach collection ──────────────────────┐ +│ Task pick up the red block │ +│ State RECORDING │ +│ Episodes 2 saved, 0 discarded │ +│ Gripper closed (0.0) │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +| Key | Action | +| --- | --- | +| **Space** | Start an episode; press again to save it | +| **D** | Discard the in-progress episode | +| **O** | Open the gripper | +| **C** | Close the gripper | +| **Q** or **Ctrl-C** | Detach the panel while idle | + +The panel refuses to detach while recording. Save or discard the take first. +Detaching closes only the panel's RPC connection; the arm remains active in +gravity-compensation mode until you run `dimos stop`. + +A complete session is: + +```text +start daemon ─▶ attach panel ─▶ start/save takes ─▶ detach panel ─▶ stop daemon +``` + +After collection, stop the stack cleanly and build the dataset with the same +OpenYAM profile used by Quest collection and policy rollout: + +```bash +dimos stop +dimos dataprep build \ + --source data/recordings/openyam-teach.mcap \ + --profile dimos.robot.manipulators.openyam.learning:OPENYAM_LEARNING_PROFILE \ + --output data/datasets/openyam-teach +``` + +The action row contains the arm's measured position at that instant plus the +operator's current gripper target. No timing shift or alternate data profile is +required. + +Before a production collection, run one hardware smoke test: + +1. Support the arm, start the daemon, and confirm that it can be moved by hand + without position-hold resistance. It should retain joint damping. +2. Attach `dimos collect` and verify that **O** and **C** move the gripper in the + expected directions. +3. Record and save a short take, stop the daemon, then inspect the MCAP with + `dimos dataprep inspect` and `OPENYAM_LEARNING_PROFILE`. + ### Where the recording goes ``` diff --git a/dimos/imitation/collection/episode_monitor.py b/dimos/imitation/collection/episode_monitor.py index bc0150efd0..792fe55bff 100644 --- a/dimos/imitation/collection/episode_monitor.py +++ b/dimos/imitation/collection/episode_monitor.py @@ -12,12 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Single point of Quest-input → EpisodeStatus translation. +"""Single point of operator-input → EpisodeStatus translation. -Watches buttons, runs the start/save/discard state machine, +Watches Quest buttons and accepts RPC commands, runs the episode state machine, publishes EpisodeStatus on every transition. RecordReplay (or whatever records the bus) captures that stream into session.db; DataPrep reads only -the recorded EpisodeStatus events offline — never raw buttons. +the recorded EpisodeStatus events offline — never raw operator input. """ from __future__ import annotations @@ -93,6 +93,7 @@ def __init__(self, **kwargs: Any) -> None: self._state: RecordingState = "idle" self._saved: int = 0 self._discarded: int = 0 + self._last_event: EpisodeEvent = "init" self._lock = threading.Lock() self._transition_lock = threading.Lock() self._stopping = False @@ -122,6 +123,7 @@ def stop(self) -> None: if self._state == "recording": self._discarded += 1 self._state = "idle" + self._last_event = "discard" status = self._snapshot("discard", time.time()) else: status = None @@ -148,17 +150,30 @@ def _on_buttons(self, msg: Buttons) -> None: for event_name in fired: self._transition(event_name, ts) - def _transition(self, event: EpisodeCommand, ts: float) -> None: + @rpc + def command(self, event: EpisodeCommand) -> EpisodeStatus: + """Apply an episode command from an attached operator interface.""" + return self._transition(event, time.time()) + + @rpc + def get_status(self) -> EpisodeStatus: + """Return the latest episode state without publishing a new event.""" + with self._lock: + return self._snapshot(self._last_event, time.time()) + + def _transition(self, event: EpisodeCommand, ts: float) -> EpisodeStatus: """State-machine transition. Publishes EpisodeStatus on every change. ``toggle`` resolves to ``start`` when idle and ``save`` when recording, so one button can begin and end a take. The resolved event is what gets published (DataPrep only ever sees start/save/discard). """ + if event not in ("start", "save", "discard", "toggle"): + raise ValueError(f"unknown episode command: {event!r}") with self._transition_lock: with self._lock: if self._stopping: - return + return self._snapshot(self._last_event, ts) if event == "toggle": event = "save" if self._state == "recording" else "start" if event == "start": @@ -174,9 +189,10 @@ def _transition(self, event: EpisodeCommand, ts: float) -> None: if self._state == "recording": self._discarded += 1 self._state = "idle" + self._last_event = event # Snapshot under the mutation's lock so the event matches the state. status = self._snapshot(event, ts) - self._emit(status) + return self._emit(status) def _snapshot(self, last_event: EpisodeEvent, ts: float) -> EpisodeStatus: """Build a status from current state. Caller must hold `self._lock`.""" diff --git a/dimos/imitation/collection/test_episode_monitor.py b/dimos/imitation/collection/test_episode_monitor.py index 93a454abaf..bbfb416576 100644 --- a/dimos/imitation/collection/test_episode_monitor.py +++ b/dimos/imitation/collection/test_episode_monitor.py @@ -93,6 +93,46 @@ def test_toggle_starts_then_saves(make_monitor: Callable[..., EpisodeMonitorModu assert events[-1].task_label == "pick up the block" +def test_rpc_commands_use_the_same_state_machine( + make_monitor: Callable[..., EpisodeMonitorModule], +) -> None: + m = make_monitor() + + recording = m.command("toggle") + saved = m.command("toggle") + + assert [event.last_event for event in _events(m)] == ["start", "save"] + assert recording.state == "recording" + assert saved.state == "idle" + assert saved.episodes_saved == 1 + + +def test_get_status_does_not_publish( + make_monitor: Callable[..., EpisodeMonitorModule], +) -> None: + m = make_monitor() + m.command("start") + event_count = len(_events(m)) + + status = m.get_status() + + assert status.state == "recording" + assert status.last_event == "start" + assert len(_events(m)) == event_count + + +def test_invalid_rpc_command_is_rejected_without_changing_state( + make_monitor: Callable[..., EpisodeMonitorModule], +) -> None: + m = make_monitor() + + with pytest.raises(ValueError, match="unknown episode command"): + m.command("pause") # type: ignore[arg-type] + + assert m.get_status().state == "idle" + assert _events(m) == [] + + def test_task_is_required(make_monitor: Callable[..., EpisodeMonitorModule]) -> None: with pytest.raises(ValidationError, match="task"): EpisodeMonitorModule() diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 753fbf5274..4dd573d5b8 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -78,6 +78,7 @@ "learning-collect-quest-openyam": "dimos.robot.manipulators.openyam.blueprints.learning_collection:learning_collect_quest_openyam", "learning-collect-quest-piper": "dimos.imitation.collection.blueprint:learning_collect_quest_piper", "learning-collect-quest-xarm7": "dimos.imitation.collection.blueprint:learning_collect_quest_xarm7", + "learning-collect-teach-openyam": "dimos.robot.manipulators.openyam.blueprints.learning_collection:learning_collect_teach_openyam", "learning-rollout-quest-openyam": "dimos.robot.manipulators.openyam.blueprints.learning_rollout:learning_rollout_quest_openyam", "mid360": "dimos.hardware.sensors.lidar.livox.livox_blueprints:mid360", "mid360-fastlio": "dimos.hardware.sensors.lidar.fastlio2.fastlio_blueprints:mid360_fastlio", diff --git a/dimos/robot/manipulators/openyam/blueprints/learning_collection.py b/dimos/robot/manipulators/openyam/blueprints/learning_collection.py index a8d3b1fe07..9cec855d55 100644 --- a/dimos/robot/manipulators/openyam/blueprints/learning_collection.py +++ b/dimos/robot/manipulators/openyam/blueprints/learning_collection.py @@ -16,16 +16,24 @@ from __future__ import annotations +from dataclasses import replace from datetime import datetime from dimos.constants import STATE_DIR -from dimos.core.coordination.blueprints import autoconnect +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.core.coordination.blueprints import Blueprint, autoconnect from dimos.experimental.memory.rust_recorder import RustMcapStoreConfig from dimos.hardware.sensors.camera.module import CameraModule from dimos.hardware.sensors.camera.webcam import WebcamConfig +from dimos.hardware.whole_body.spec import WholeBodyConfig from dimos.imitation.collection.episode_monitor import EpisodeMonitorModule from dimos.imitation.collection.native_recorder import NativeCollectionRecorder from dimos.robot.manipulators.openyam.blueprints.teleop import teleop_quest_openyam +from dimos.robot.manipulators.openyam.config import ( + OPENYAM_GRIPPER_JOINT, + OPENYAM_JOINTS, + openyam_hardware, +) from dimos.robot.manipulators.openyam.learning import OPENYAM_LEARNING_PROFILE @@ -33,14 +41,14 @@ def _session_mcap() -> str: return str(STATE_DIR / "recordings" / f"session_openyam_{datetime.now():%Y%m%d_%H%M%S}.mcap") -learning_collect_quest_openyam = autoconnect( - NativeCollectionRecorder.blueprint( - store=RustMcapStoreConfig(path=_session_mcap()), - record_tf=False, - ), - EpisodeMonitorModule.blueprint(), - teleop_quest_openyam, - CameraModule.blueprint( +def _teach_session_mcap() -> str: + return str( + STATE_DIR / "recordings" / f"session_openyam_teach_{datetime.now():%Y%m%d_%H%M%S}.mcap" + ) + + +def _wrist_camera() -> Blueprint: + return CameraModule.blueprint( instance_name="WristCamera", hardware=WebcamConfig( camera_index=0, @@ -50,5 +58,52 @@ def _session_mcap() -> str: frame_id_prefix=OPENYAM_LEARNING_PROFILE.camera_frame_prefix, ), frame_id=OPENYAM_LEARNING_PROFILE.camera_frame_id, + ) + + +learning_collect_quest_openyam = autoconnect( + NativeCollectionRecorder.blueprint( + store=RustMcapStoreConfig(path=_session_mcap()), + record_tf=False, + ), + EpisodeMonitorModule.blueprint(), + teleop_quest_openyam, + _wrist_camera(), +) + + +OPENYAM_TEACH_DAMPING = (5.0, 5.0, 5.0, 1.5, 1.5, 1.5, 0.0) +_openyam_teach_hardware = replace( + openyam_hardware(), + wb_config=WholeBodyConfig( + kp=(0.0,) * len(OPENYAM_JOINTS), + kd=OPENYAM_TEACH_DAMPING, + ), +) + +learning_collect_teach_openyam = autoconnect( + NativeCollectionRecorder.blueprint( + store=RustMcapStoreConfig(path=_teach_session_mcap()), + record_tf=False, + ), + EpisodeMonitorModule.blueprint(), + ControlCoordinator.blueprint( + instance_name="ControlCoordinator", + hardware=[_openyam_teach_hardware], + tasks=[ + TaskConfig( + name="teach_openyam", + type="teach", + joint_names=list(OPENYAM_JOINTS), + priority=10, + ), + TaskConfig( + name="arm_gripper", + type="gripper", + joint_names=[OPENYAM_GRIPPER_JOINT], + priority=20, + ), + ], ), + _wrist_camera(), ) diff --git a/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py b/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py index 43270c3116..05c8d42a3d 100644 --- a/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py +++ b/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py @@ -12,11 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. +from dimos.control.coordinator import ControlCoordinator from dimos.core.coordination.blueprint_config.parser import BlueprintConfigParser +from dimos.hardware.sensors.camera.module import CameraModule +from dimos.imitation.collection.episode_monitor import EpisodeMonitorModule from dimos.imitation.collection.native_recorder import NativeCollectionRecorder from dimos.robot.manipulators.openyam.blueprints.learning_collection import ( learning_collect_quest_openyam, + learning_collect_teach_openyam, ) +from dimos.robot.manipulators.openyam.config import OPENYAM_JOINTS def test_openyam_collection_uses_the_native_recorder() -> None: @@ -50,3 +55,57 @@ def test_native_openyam_paths_are_configurable_from_cli() -> None: ) assert parsed.module_kwargs("episodemonitormodule")["task"] == "pick up the red block" assert learning_collect_quest_openyam.active_blueprints[0].kwargs["record_tf"] is False + + +def test_openyam_teach_collection_is_a_minimal_native_stack() -> None: + modules = [atom.module for atom in learning_collect_teach_openyam.active_blueprints] + + assert modules == [ + NativeCollectionRecorder, + EpisodeMonitorModule, + ControlCoordinator, + CameraModule, + ] + + +def test_openyam_teach_collection_uses_gravity_compensation_and_zero_stiffness() -> None: + coordinator = next( + atom + for atom in learning_collect_teach_openyam.active_blueprints + if atom.module is ControlCoordinator + ) + hardware = coordinator.kwargs["hardware"][0] + assert hardware.joints == OPENYAM_JOINTS + assert hardware.wb_config is not None + assert hardware.wb_config.kp == (0.0,) * len(OPENYAM_JOINTS) + assert hardware.wb_config.kd == (5.0, 5.0, 5.0, 1.5, 1.5, 1.5, 0.0) + if hardware.adapter_type == "openyam_damiao": + assert hardware.adapter_kwargs["runtime_config"].gravity_comp is True + + tasks = coordinator.kwargs["tasks"] + assert [(task.name, task.type, task.joint_names, task.priority) for task in tasks] == [ + ("teach_openyam", "teach", OPENYAM_JOINTS, 10), + ("arm_gripper", "gripper", [OPENYAM_JOINTS[-1]], 20), + ] + + +def test_native_openyam_teach_paths_are_configurable_from_cli() -> None: + parsed = BlueprintConfigParser(learning_collect_teach_openyam).parse( + [ + "--nativecollectionrecorder.store.path", + "/tmp/native-openyam-teach.mcap", + "--WristCamera.hardware.camera-index", + "/dev/v4l/by-id/usb-wrist-camera", + "--task", + "place the cup", + ], + environ={}, + ) + + assert parsed.module_kwargs("nativecollectionrecorder")["store"]["path"] == ( + "/tmp/native-openyam-teach.mcap" + ) + assert parsed.module_kwargs("WristCamera")["hardware"]["camera_index"] == ( + "/dev/v4l/by-id/usb-wrist-camera" + ) + assert parsed.module_kwargs("episodemonitormodule")["task"] == "place the cup" From c91793eca61b87fa952bf4570adefb202d1ad0e6 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 3 Sep 2026 14:52:58 -0700 Subject: [PATCH 2/9] fix(imitation): loosen OpenYAM teach controls --- dimos/cli/commands/collect.py | 49 +++-------------- dimos/cli/commands/test_collect.py | 34 +++--------- dimos/hardware/whole_body/damiao/adapter.py | 14 ++++- dimos/hardware/whole_body/damiao/config.py | 1 + .../whole_body/damiao/test_adapter.py | 54 +++++++++++++++++++ dimos/imitation/README.md | 18 +++---- .../openyam/blueprints/learning_collection.py | 24 +++++---- .../blueprints/test_learning_collection.py | 4 +- 8 files changed, 105 insertions(+), 93 deletions(-) diff --git a/dimos/cli/commands/collect.py b/dimos/cli/commands/collect.py index 8074b912ac..2cb2c41108 100644 --- a/dimos/cli/commands/collect.py +++ b/dimos/cli/commands/collect.py @@ -31,18 +31,15 @@ _MONITOR = "EpisodeMonitorModule" _COORDINATOR = "ControlCoordinator" -_GRIPPER_TASK = "arm_gripper" -_REQUIRED_TASKS = {"teach_openyam", _GRIPPER_TASK} +_REQUIRED_TASKS = {"teach_openyam"} class TeachCollectionSession: """RPC client for the operator controls used by the collection panel.""" - def __init__(self, client: Dimos, monitor: Any, coordinator: Any) -> None: + def __init__(self, client: Dimos, monitor: Any) -> None: self._client = client self._monitor = monitor - self._coordinator = coordinator - self.gripper_target: float | None = None @classmethod def connect(cls) -> TeachCollectionSession: @@ -52,7 +49,7 @@ def connect(cls) -> TeachCollectionSession: modules = {info.instance_name: info for info in client.list_modules()} for name, rpcs in { _MONITOR: {"command", "get_status"}, - _COORDINATOR: {"list_tasks", "task_invoke"}, + _COORDINATOR: {"list_tasks"}, }.items(): info = modules.get(name) if info is None: @@ -69,7 +66,7 @@ def connect(cls) -> TeachCollectionSession: if missing_tasks: raise RuntimeError(f"ControlCoordinator is missing tasks: {sorted(missing_tasks)}") monitor.get_status() - return cls(client, monitor, coordinator) + return cls(client, monitor) except Exception: client.stop() raise @@ -92,17 +89,6 @@ def command(self, event: str) -> EpisodeStatus: ) return status - def set_gripper(self, target: float) -> None: - """Set and retain a normalized gripper target.""" - accepted = self._coordinator.task_invoke( - _GRIPPER_TASK, - "set_normalized", - {"values": [target], "t_now": None}, - ) - if accepted is not True: - raise RuntimeError(f"arm_gripper rejected normalized target {target}") - self.gripper_target = target - def close(self) -> None: """Close only this RPC client; leave the daemon and robot running.""" self._client.stop() @@ -125,8 +111,6 @@ class TeachCollectionApp(App[None]): BINDINGS = [ Binding("space", "toggle_recording", "Start / save"), Binding("d", "discard", "Discard"), - Binding("o", "open_gripper", "Open gripper"), - Binding("c", "close_gripper", "Close gripper"), Binding("q", "quit", "Detach"), Binding("ctrl+c", "quit", "Detach", show=False), ] @@ -135,7 +119,7 @@ def __init__(self, session: TeachCollectionSession) -> None: super().__init__() self._session = session self._status = session.get_status() - self._message = "Drag the arm by hand; press Space when the take begins." + self._message = "Move the arm and gripper by hand; press Space when the take begins." self._detached = False def compose(self) -> ComposeResult: @@ -151,11 +135,6 @@ def on_unmount(self) -> None: def _render(self) -> Panel: recording = self._status.state == "recording" state_style = theme.ERROR if recording else theme.SUCCESS - gripper = ( - "measured position" - if self._session.gripper_target is None - else ("open (1.0)" if self._session.gripper_target == 1.0 else "closed (0.0)") - ) body = Text() body.append("Task ", style="bold") body.append(f"{self._status.task_label}\n") @@ -166,7 +145,7 @@ def _render(self) -> Panel: f"{self._status.episodes_saved} saved, {self._status.episodes_discarded} discarded\n" ) body.append("Gripper ", style="bold") - body.append(f"{gripper}\n\n") + body.append("passive — move by hand\n\n") body.append(self._message) if self._detached: body.append( @@ -207,28 +186,12 @@ def _episode_command(self, event: str) -> None: except Exception as exc: self._fail(exc) - def _set_gripper(self, target: float) -> None: - if self._detached: - return - try: - self._session.set_gripper(target) - self._message = "Gripper opened." if target == 1.0 else "Gripper closed." - self._refresh() - except Exception as exc: - self._fail(exc) - def action_toggle_recording(self) -> None: self._episode_command("toggle") def action_discard(self) -> None: self._episode_command("discard") - def action_open_gripper(self) -> None: - self._set_gripper(1.0) - - def action_close_gripper(self) -> None: - self._set_gripper(0.0) - def action_quit(self) -> None: # type: ignore[override] if not self._detached and self._status.state == "recording": self._message = "Save with Space or discard with D before detaching." diff --git a/dimos/cli/commands/test_collect.py b/dimos/cli/commands/test_collect.py index 7adf1ca8c6..731fd1d1ac 100644 --- a/dimos/cli/commands/test_collect.py +++ b/dimos/cli/commands/test_collect.py @@ -31,54 +31,34 @@ def _status(state: str = "idle") -> EpisodeStatus: ) -def _session(mocker: MockerFixture) -> tuple[TeachCollectionSession, Any, Any, Any]: +def _session(mocker: MockerFixture) -> tuple[TeachCollectionSession, Any, Any]: client = mocker.Mock() monitor = mocker.Mock() - coordinator = mocker.Mock() monitor.get_status.return_value = _status() monitor.command.side_effect = [_status("recording"), _status("idle")] - coordinator.task_invoke.return_value = True - return TeachCollectionSession(client, monitor, coordinator), client, monitor, coordinator + return TeachCollectionSession(client, monitor), client, monitor -def test_session_routes_episode_and_gripper_commands(mocker: MockerFixture) -> None: - session, _, monitor, coordinator = _session(mocker) +def test_session_routes_episode_commands(mocker: MockerFixture) -> None: + session, _, monitor = _session(mocker) assert session.command("toggle").state == "recording" - session.set_gripper(1.0) - session.set_gripper(0.0) monitor.command.assert_called_once_with("toggle") - assert coordinator.task_invoke.call_args_list == [ - mocker.call( - "arm_gripper", - "set_normalized", - {"values": [1.0], "t_now": None}, - ), - mocker.call( - "arm_gripper", - "set_normalized", - {"values": [0.0], "t_now": None}, - ), - ] - assert session.gripper_target == 0.0 def test_panel_actions_route_keys_and_guard_quit(mocker: MockerFixture) -> None: - session, _, monitor, coordinator = _session(mocker) + session, _, monitor = _session(mocker) app = TeachCollectionApp(session) mocker.patch.object(app, "_refresh") exit_mock = mocker.patch.object(app, "exit") app.action_toggle_recording() app.action_quit() - app.action_open_gripper() - app.action_close_gripper() app.action_discard() app.action_quit() assert monitor.command.call_args_list == [mocker.call("toggle"), mocker.call("discard")] - assert coordinator.task_invoke.call_count == 2 exit_mock.assert_called_once_with() @@ -86,15 +66,13 @@ def test_panel_binds_the_documented_keys() -> None: assert {binding.key: binding.action for binding in TeachCollectionApp.BINDINGS} == { "space": "toggle_recording", "d": "discard", - "o": "open_gripper", - "c": "close_gripper", "q": "quit", "ctrl+c": "quit", } def test_rpc_failure_detaches_without_stopping_the_daemon(mocker: MockerFixture) -> None: - session, client, monitor, _ = _session(mocker) + session, client, monitor = _session(mocker) app = TeachCollectionApp(session) mocker.patch.object(app, "_refresh") monitor.command.side_effect = RuntimeError("stack disappeared") diff --git a/dimos/hardware/whole_body/damiao/adapter.py b/dimos/hardware/whole_body/damiao/adapter.py index 75b1c75c33..20935c7253 100644 --- a/dimos/hardware/whole_body/damiao/adapter.py +++ b/dimos/hardware/whole_body/damiao/adapter.py @@ -78,6 +78,11 @@ def __init__( unknown_buses = config.bus_devices.keys() - set(self.bus_names) if unknown_buses: raise ValueError(f"unknown CAN bus overrides: {sorted(unknown_buses)}") + unknown_grippers = set(config.passive_grippers) - set(self.gripper_joints) + if unknown_grippers: + raise ValueError(f"unknown passive grippers: {sorted(unknown_grippers)}") + if len(config.passive_grippers) != len(set(config.passive_grippers)): + raise ValueError("passive_grippers contains duplicate names") if len(self.bus_names) != len(set(self.bus_names)): raise ValueError("Damiao topology contains duplicate logical bus names") @@ -240,6 +245,10 @@ def activate(self) -> bool: for arm in self._arms.values(): arm.set_mode("mit") self._robot.enable() + for name in self._runtime_config.passive_grippers: + self._grippers[name].disable() + if self._runtime_config.passive_grippers: + self._robot.tick(self._runtime_config.tick_deadline_us) self._active = True self.read_motor_states() return True @@ -335,6 +344,8 @@ def write_motor_commands(self, commands: list[MotorCommand]) -> bool: commands[arm_count:], strict=True, ): + if name in self._runtime_config.passive_grippers: + continue if not np.isfinite(command.q) or not 0.0 <= command.q <= 1.0: raise ValueError(f"gripper {name!r} opening must be in [0, 1]") @@ -363,7 +374,8 @@ def write_motor_commands(self, commands: list[MotorCommand]) -> bool: for name in self.gripper_joints: opening = commands[offset].q - self._grippers[name].set_opening(opening) + if name not in self._runtime_config.passive_grippers: + self._grippers[name].set_opening(opening) offset += 1 self._robot.tick(self._runtime_config.tick_deadline_us) diff --git a/dimos/hardware/whole_body/damiao/config.py b/dimos/hardware/whole_body/damiao/config.py index d9b95092e2..ce0c3829bf 100644 --- a/dimos/hardware/whole_body/damiao/config.py +++ b/dimos/hardware/whole_body/damiao/config.py @@ -31,4 +31,5 @@ class DamiaoRuntimeConfig: bus_devices: dict[_NonEmptyString, _NonEmptyString] = Field(default_factory=dict) gravity_comp: bool = Field(default=True, strict=True) + passive_grippers: tuple[_NonEmptyString, ...] = () tick_deadline_us: int = Field(default=1_000, ge=1, strict=True) diff --git a/dimos/hardware/whole_body/damiao/test_adapter.py b/dimos/hardware/whole_body/damiao/test_adapter.py index c1739d4809..6bfa33e702 100644 --- a/dimos/hardware/whole_body/damiao/test_adapter.py +++ b/dimos/hardware/whole_body/damiao/test_adapter.py @@ -67,12 +67,16 @@ def __init__(self, opening: float) -> None: self.opening = opening self.command_error: Exception | None = None self.commands: list[float] = [] + self.disable_count = 0 def set_opening(self, opening: float) -> None: if self.command_error is not None: raise self.command_error self.commands.append(opening) + def disable(self) -> None: + self.disable_count += 1 + class FakeTransport: def __init__(self) -> None: @@ -296,6 +300,14 @@ def test_init_unknown_bus_override_raises_value_error(dual_robot: FakeRobot) -> ) +def test_init_unknown_passive_gripper_raises_value_error(dual_robot: FakeRobot) -> None: + with pytest.raises(ValueError, match="unknown passive grippers"): + DualAdapter( + dual_robot, + runtime_config=DamiaoRuntimeConfig(passive_grippers=("missing",)), + ) + + def test_init_duplicate_logical_bus_names_raises_value_error(dual_robot: FakeRobot) -> None: class DuplicateBusAdapter(DualAdapter): bus_names = ("left", "left") @@ -360,12 +372,14 @@ def test_init_rehydrates_serialized_runtime_config(dual_robot: FakeRobot) -> Non runtime_config={ "bus_devices": {"left": "can8"}, "gravity_comp": False, + "passive_grippers": ["left_gripper"], "tick_deadline_us": 2_000, }, ) assert adapter._runtime_config.bus_devices == {"left": "can8"} assert adapter._runtime_config.gravity_comp is False + assert adapter._runtime_config.passive_grippers == ("left_gripper",) assert adapter._runtime_config.tick_deadline_us == 2_000 @@ -570,6 +584,25 @@ def test_activate_enable_failure_disables_robot( assert dual_robot.disable_count == 1 +def test_activate_disables_configured_passive_gripper( + dual_robot: FakeRobot, + adapter_factory: Callable[..., DualAdapter], +) -> None: + adapter = adapter_factory( + dual_robot, + runtime_config=DamiaoRuntimeConfig( + gravity_comp=False, + passive_grippers=("left_gripper",), + ), + ) + assert adapter.connect() + + assert adapter.activate() + + assert cast("FakeGripper", dual_robot["left_gripper"]).disable_count == 1 + assert cast("FakeGripper", dual_robot["right_gripper"]).disable_count == 0 + + def test_deactivate_connected_adapter_disables_robot( active_dual_adapter: DualAdapter, dual_robot: FakeRobot, @@ -756,6 +789,27 @@ def test_write_motor_commands_grippers_routes_normalized_openings( assert cast("FakeGripper", dual_robot["right_gripper"]).commands == [0.75] +def test_write_motor_commands_ignores_passive_gripper_target( + dual_robot: FakeRobot, + adapter_factory: Callable[..., DualAdapter], +) -> None: + adapter = adapter_factory( + dual_robot, + runtime_config=DamiaoRuntimeConfig( + gravity_comp=False, + passive_grippers=("left_gripper",), + ), + ) + assert adapter.connect() + assert adapter.activate() + commands = [MotorCommand(q=0.0)] * 4 + [MotorCommand(q=2.0), MotorCommand(q=0.75)] + + assert adapter.write_motor_commands(commands) + + assert cast("FakeGripper", dual_robot["left_gripper"]).commands == [] + assert cast("FakeGripper", dual_robot["right_gripper"]).commands == [0.75] + + def test_write_motor_commands_combined_command_ticks_once( active_dual_adapter: DualAdapter, dual_robot: FakeRobot, diff --git a/dimos/imitation/README.md b/dimos/imitation/README.md index 16f6ff9296..41ae02cc1c 100644 --- a/dimos/imitation/README.md +++ b/dimos/imitation/README.md @@ -86,7 +86,7 @@ dimos collect │ Task pick up the red block │ │ State RECORDING │ │ Episodes 2 saved, 0 discarded │ -│ Gripper closed (0.0) │ +│ Gripper passive — move by hand │ └─────────────────────────────────────────────────────────────────────┘ ``` @@ -94,8 +94,6 @@ dimos collect | --- | --- | | **Space** | Start an episode; press again to save it | | **D** | Discard the in-progress episode | -| **O** | Open the gripper | -| **C** | Close the gripper | | **Q** or **Ctrl-C** | Detach the panel while idle | The panel refuses to detach while recording. Save or discard the take first. @@ -119,16 +117,16 @@ dimos dataprep build \ --output data/datasets/openyam-teach ``` -The action row contains the arm's measured position at that instant plus the -operator's current gripper target. No timing shift or alternate data profile is -required. +The action row contains the measured arm and gripper positions at that instant. +No timing shift or alternate data profile is required. Before a production collection, run one hardware smoke test: -1. Support the arm, start the daemon, and confirm that it can be moved by hand - without position-hold resistance. It should retain joint damping. -2. Attach `dimos collect` and verify that **O** and **C** move the gripper in the - expected directions. +1. Support the arm, start the daemon, and confirm that the arm and gripper can + be moved by hand without position-hold resistance. The arm should retain + light joint damping while the gripper motor remains disabled. +2. Attach `dimos collect` and verify that the panel reports the gripper as + passive. 3. Record and save a short take, stop the daemon, then inspect the MCAP with `dimos dataprep inspect` and `OPENYAM_LEARNING_PROFILE`. diff --git a/dimos/robot/manipulators/openyam/blueprints/learning_collection.py b/dimos/robot/manipulators/openyam/blueprints/learning_collection.py index 9cec855d55..c4e6c6e7e5 100644 --- a/dimos/robot/manipulators/openyam/blueprints/learning_collection.py +++ b/dimos/robot/manipulators/openyam/blueprints/learning_collection.py @@ -25,12 +25,12 @@ from dimos.experimental.memory.rust_recorder import RustMcapStoreConfig from dimos.hardware.sensors.camera.module import CameraModule from dimos.hardware.sensors.camera.webcam import WebcamConfig +from dimos.hardware.whole_body.damiao.config import DamiaoRuntimeConfig from dimos.hardware.whole_body.spec import WholeBodyConfig from dimos.imitation.collection.episode_monitor import EpisodeMonitorModule from dimos.imitation.collection.native_recorder import NativeCollectionRecorder from dimos.robot.manipulators.openyam.blueprints.teleop import teleop_quest_openyam from dimos.robot.manipulators.openyam.config import ( - OPENYAM_GRIPPER_JOINT, OPENYAM_JOINTS, openyam_hardware, ) @@ -72,9 +72,21 @@ def _wrist_camera() -> Blueprint: ) -OPENYAM_TEACH_DAMPING = (5.0, 5.0, 5.0, 1.5, 1.5, 1.5, 0.0) +OPENYAM_TEACH_DAMPING = (2.0, 2.0, 2.0, 0.5, 0.5, 0.5, 0.0) +_openyam_teach_hardware = openyam_hardware() +if _openyam_teach_hardware.adapter_type == "openyam_damiao": + runtime_config = _openyam_teach_hardware.adapter_kwargs["runtime_config"] + if not isinstance(runtime_config, DamiaoRuntimeConfig): + raise TypeError("OpenYAM Damiao hardware requires DamiaoRuntimeConfig") + _openyam_teach_hardware = replace( + _openyam_teach_hardware, + adapter_kwargs={ + **_openyam_teach_hardware.adapter_kwargs, + "runtime_config": replace(runtime_config, passive_grippers=("gripper",)), + }, + ) _openyam_teach_hardware = replace( - openyam_hardware(), + _openyam_teach_hardware, wb_config=WholeBodyConfig( kp=(0.0,) * len(OPENYAM_JOINTS), kd=OPENYAM_TEACH_DAMPING, @@ -97,12 +109,6 @@ def _wrist_camera() -> Blueprint: joint_names=list(OPENYAM_JOINTS), priority=10, ), - TaskConfig( - name="arm_gripper", - type="gripper", - joint_names=[OPENYAM_GRIPPER_JOINT], - priority=20, - ), ], ), _wrist_camera(), diff --git a/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py b/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py index 05c8d42a3d..7040e0172d 100644 --- a/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py +++ b/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py @@ -78,14 +78,14 @@ def test_openyam_teach_collection_uses_gravity_compensation_and_zero_stiffness() assert hardware.joints == OPENYAM_JOINTS assert hardware.wb_config is not None assert hardware.wb_config.kp == (0.0,) * len(OPENYAM_JOINTS) - assert hardware.wb_config.kd == (5.0, 5.0, 5.0, 1.5, 1.5, 1.5, 0.0) + assert hardware.wb_config.kd == (2.0, 2.0, 2.0, 0.5, 0.5, 0.5, 0.0) if hardware.adapter_type == "openyam_damiao": assert hardware.adapter_kwargs["runtime_config"].gravity_comp is True + assert hardware.adapter_kwargs["runtime_config"].passive_grippers == ("gripper",) tasks = coordinator.kwargs["tasks"] assert [(task.name, task.type, task.joint_names, task.priority) for task in tasks] == [ ("teach_openyam", "teach", OPENYAM_JOINTS, 10), - ("arm_gripper", "gripper", [OPENYAM_JOINTS[-1]], 20), ] From 38eeab81f2faf32ceca8b983f2795c7ca5ab395a Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 3 Sep 2026 15:14:52 -0700 Subject: [PATCH 3/9] fix(imitation): enable zero-impedance teach gripper --- dimos/hardware/whole_body/damiao/adapter.py | 10 ++++-- .../whole_body/damiao/test_adapter.py | 35 ++++++++++++++++--- dimos/imitation/README.md | 3 +- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/dimos/hardware/whole_body/damiao/adapter.py b/dimos/hardware/whole_body/damiao/adapter.py index 20935c7253..ece418cc3f 100644 --- a/dimos/hardware/whole_body/damiao/adapter.py +++ b/dimos/hardware/whole_body/damiao/adapter.py @@ -246,7 +246,10 @@ def activate(self) -> bool: arm.set_mode("mit") self._robot.enable() for name in self._runtime_config.passive_grippers: - self._grippers[name].disable() + gripper = self._grippers[name] + gripper.set_mode("mit") + gripper.enable() + gripper.mit_control(0.0, 0.0, float(gripper.motor.position), 0.0, 0.0) if self._runtime_config.passive_grippers: self._robot.tick(self._runtime_config.tick_deadline_us) self._active = True @@ -374,7 +377,10 @@ def write_motor_commands(self, commands: list[MotorCommand]) -> bool: for name in self.gripper_joints: opening = commands[offset].q - if name not in self._runtime_config.passive_grippers: + if name in self._runtime_config.passive_grippers: + gripper = self._grippers[name] + gripper.mit_control(0.0, 0.0, float(gripper.motor.position), 0.0, 0.0) + else: self._grippers[name].set_opening(opening) offset += 1 diff --git a/dimos/hardware/whole_body/damiao/test_adapter.py b/dimos/hardware/whole_body/damiao/test_adapter.py index 6bfa33e702..dd17c4d406 100644 --- a/dimos/hardware/whole_body/damiao/test_adapter.py +++ b/dimos/hardware/whole_body/damiao/test_adapter.py @@ -65,8 +65,12 @@ def mit_control(self, commands: np.ndarray) -> None: class FakeGripper: def __init__(self, opening: float) -> None: self.opening = opening + self.motor = Mock(position=opening) self.command_error: Exception | None = None self.commands: list[float] = [] + self.modes: list[str] = [] + self.mit_commands: list[tuple[float, float, float, float, float]] = [] + self.enable_count = 0 self.disable_count = 0 def set_opening(self, opening: float) -> None: @@ -77,6 +81,15 @@ def set_opening(self, opening: float) -> None: def disable(self) -> None: self.disable_count += 1 + def enable(self) -> None: + self.enable_count += 1 + + def set_mode(self, mode: str) -> None: + self.modes.append(mode) + + def mit_control(self, kp: float, kd: float, q: float, dq: float, tau: float) -> None: + self.mit_commands.append((kp, kd, q, dq, tau)) + class FakeTransport: def __init__(self) -> None: @@ -584,7 +597,7 @@ def test_activate_enable_failure_disables_robot( assert dual_robot.disable_count == 1 -def test_activate_disables_configured_passive_gripper( +def test_activate_enables_zero_impedance_for_configured_passive_gripper( dual_robot: FakeRobot, adapter_factory: Callable[..., DualAdapter], ) -> None: @@ -599,8 +612,15 @@ def test_activate_disables_configured_passive_gripper( assert adapter.activate() - assert cast("FakeGripper", dual_robot["left_gripper"]).disable_count == 1 - assert cast("FakeGripper", dual_robot["right_gripper"]).disable_count == 0 + left_gripper = cast("FakeGripper", dual_robot["left_gripper"]) + right_gripper = cast("FakeGripper", dual_robot["right_gripper"]) + assert left_gripper.disable_count == 0 + assert left_gripper.modes == ["mit"] + assert left_gripper.enable_count == 1 + assert left_gripper.mit_commands == [(0.0, 0.0, 0.5, 0.0, 0.0)] + assert right_gripper.modes == [] + assert right_gripper.enable_count == 0 + assert right_gripper.mit_commands == [] def test_deactivate_connected_adapter_disables_robot( @@ -789,7 +809,7 @@ def test_write_motor_commands_grippers_routes_normalized_openings( assert cast("FakeGripper", dual_robot["right_gripper"]).commands == [0.75] -def test_write_motor_commands_ignores_passive_gripper_target( +def test_write_motor_commands_keeps_passive_gripper_at_zero_impedance( dual_robot: FakeRobot, adapter_factory: Callable[..., DualAdapter], ) -> None: @@ -806,7 +826,12 @@ def test_write_motor_commands_ignores_passive_gripper_target( assert adapter.write_motor_commands(commands) - assert cast("FakeGripper", dual_robot["left_gripper"]).commands == [] + left_gripper = cast("FakeGripper", dual_robot["left_gripper"]) + assert left_gripper.commands == [] + assert left_gripper.mit_commands == [ + (0.0, 0.0, 0.5, 0.0, 0.0), + (0.0, 0.0, 0.5, 0.0, 0.0), + ] assert cast("FakeGripper", dual_robot["right_gripper"]).commands == [0.75] diff --git a/dimos/imitation/README.md b/dimos/imitation/README.md index 41ae02cc1c..a6fd5b1f9a 100644 --- a/dimos/imitation/README.md +++ b/dimos/imitation/README.md @@ -124,7 +124,8 @@ Before a production collection, run one hardware smoke test: 1. Support the arm, start the daemon, and confirm that the arm and gripper can be moved by hand without position-hold resistance. The arm should retain - light joint damping while the gripper motor remains disabled. + light joint damping while the enabled gripper runs with zero stiffness, + zero damping, and zero feed-forward torque. 2. Attach `dimos collect` and verify that the panel reports the gripper as passive. 3. Record and save a short take, stop the daemon, then inspect the MCAP with From 84b5c1898409e0fb1fa5ff154988c42eec9a50cd Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 3 Sep 2026 15:56:11 -0700 Subject: [PATCH 4/9] fix(imitation): make OpenYAM teach data convertible --- dimos/imitation/README.md | 11 ++++++----- dimos/imitation/dataprep/lerobot.py | 10 ++++++++-- dimos/imitation/dataprep/test_lerobot_cli.py | 17 ++++++++++++++--- dimos/robot/manipulators/openyam/learning.py | 6 ++++-- .../robot/manipulators/openyam/test_learning.py | 15 ++++++++++++++- 5 files changed, 46 insertions(+), 13 deletions(-) diff --git a/dimos/imitation/README.md b/dimos/imitation/README.md index a6fd5b1f9a..27037a3ab1 100644 --- a/dimos/imitation/README.md +++ b/dimos/imitation/README.md @@ -106,19 +106,20 @@ A complete session is: start daemon ─▶ attach panel ─▶ start/save takes ─▶ detach panel ─▶ stop daemon ``` -After collection, stop the stack cleanly and build the dataset with the same -OpenYAM profile used by Quest collection and policy rollout: +After collection, stop the stack cleanly and build the dataset with the +direct-teach profile: ```bash dimos stop dimos dataprep build \ --source data/recordings/openyam-teach.mcap \ - --profile dimos.robot.manipulators.openyam.learning:OPENYAM_LEARNING_PROFILE \ + --profile dimos.robot.manipulators.openyam.learning:OPENYAM_TEACH_LEARNING_PROFILE \ --output data/datasets/openyam-teach ``` The action row contains the measured arm and gripper positions at that instant. -No timing shift or alternate data profile is required. +The direct-teach profile therefore reads both state and action from the continuous +coordinator joint-state stream; Quest collection continues to use accepted commands. Before a production collection, run one hardware smoke test: @@ -129,7 +130,7 @@ Before a production collection, run one hardware smoke test: 2. Attach `dimos collect` and verify that the panel reports the gripper as passive. 3. Record and save a short take, stop the daemon, then inspect the MCAP with - `dimos dataprep inspect` and `OPENYAM_LEARNING_PROFILE`. + `dimos dataprep inspect` and `OPENYAM_TEACH_LEARNING_PROFILE`. ### Where the recording goes diff --git a/dimos/imitation/dataprep/lerobot.py b/dimos/imitation/dataprep/lerobot.py index eb7de9218f..b6fc404330 100644 --- a/dimos/imitation/dataprep/lerobot.py +++ b/dimos/imitation/dataprep/lerobot.py @@ -72,7 +72,7 @@ def _run(request: Request) -> Result: output = result.stderr.strip() or result.stdout.strip() raise RuntimeError(f"LeRobot dataprep exited with status {result.returncode}: {output}") try: - return RESULT_ADAPTER.validate_json(result.stdout) + return RESULT_ADAPTER.validate_json(result.stdout.rstrip().rsplit("\n", 1)[-1]) except ValueError as error: raise RuntimeError( f"LeRobot dataprep returned an invalid result: {result.stdout!r}" @@ -81,6 +81,12 @@ def _run(request: Request) -> Result: def run_lerobot_dataprep(config: DataPrepConfig) -> Path: """Build a dataset in the isolated LeRobot environment.""" + config = config.model_copy( + update={ + "source": str(Path(config.source).resolve()), + "output": config.output.model_copy(update={"path": config.output.path.resolve()}), + } + ) result = _run(BuildRequest(config=config)) if not isinstance(result, BuildResult): raise RuntimeError(f"LeRobot dataprep returned {result.command!r} for a build request") @@ -89,7 +95,7 @@ def run_lerobot_dataprep(config: DataPrepConfig) -> Path: def inspect_lerobot_dataset(path: Path) -> dict[str, Any]: """Inspect a dataset in the isolated LeRobot environment.""" - result = _run(InspectRequest(path=path)) + result = _run(InspectRequest(path=path.resolve())) if not isinstance(result, InspectResult): raise RuntimeError(f"LeRobot dataprep returned {result.command!r} for an inspect request") return result.info diff --git a/dimos/imitation/dataprep/test_lerobot_cli.py b/dimos/imitation/dataprep/test_lerobot_cli.py index 5ea982c112..b55c51eb7b 100644 --- a/dimos/imitation/dataprep/test_lerobot_cli.py +++ b/dimos/imitation/dataprep/test_lerobot_cli.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json from pathlib import Path import subprocess @@ -92,7 +93,12 @@ def test_conversion_runs_packaged_module_in_policy_project( run = mocker.patch( "dimos.imitation.dataprep.lerobot.subprocess.run", return_value=subprocess.CompletedProcess( - [], 0, stdout=f'{{"command":"build","path":"{tmp_path / "dataset"}"}}', stderr="" + [], + 0, + stdout=( + f'[dataprep] wrote 1 episode\n{{"command":"build","path":"{tmp_path / "dataset"}"}}' + ), + stderr="", ), ) config = DataPrepConfig( @@ -113,6 +119,9 @@ def test_conversion_runs_packaged_module_in_policy_project( assert run.call_args.kwargs["capture_output"] is True assert run.call_args.kwargs["text"] is True assert '"command":"build"' in run.call_args.kwargs["input"] + assert json.loads(run.call_args.kwargs["input"])["config"]["source"] == str( + Path("recording.db").resolve() + ) def test_conversion_reports_missing_uv(tmp_path: Path, mocker: pytest_mock.MockerFixture) -> None: @@ -148,8 +157,9 @@ def test_conversion_reports_child_process_diagnostics( def test_inspection_uses_the_same_isolated_entrypoint( - tmp_path: Path, mocker: pytest_mock.MockerFixture + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mocker: pytest_mock.MockerFixture ) -> None: + monkeypatch.chdir(tmp_path) run = mocker.patch( "dimos.imitation.dataprep.lerobot.subprocess.run", return_value=subprocess.CompletedProcess( @@ -157,5 +167,6 @@ def test_inspection_uses_the_same_isolated_entrypoint( ), ) - assert inspect_lerobot_dataset(tmp_path / "dataset") == {"format": "lerobot"} + assert inspect_lerobot_dataset(Path("dataset")) == {"format": "lerobot"} assert '"command":"inspect"' in run.call_args.kwargs["input"] + assert json.loads(run.call_args.kwargs["input"])["path"] == str(tmp_path / "dataset") diff --git a/dimos/robot/manipulators/openyam/learning.py b/dimos/robot/manipulators/openyam/learning.py index 61ae240d4c..f70839689e 100644 --- a/dimos/robot/manipulators/openyam/learning.py +++ b/dimos/robot/manipulators/openyam/learning.py @@ -31,7 +31,7 @@ class OpenYamLearningProfile(BaseConfig): - """One fixed observation/action schema for OpenYAM learning.""" + """One fixed observation/action schema for an OpenYAM collection mode.""" robot_type: str = "openyam" joint_names: tuple[str, ...] = tuple(OPENYAM_JOINTS) @@ -43,6 +43,7 @@ class OpenYamLearningProfile(BaseConfig): camera_frame_prefix: str = "wrist" camera_frame_id: str = "wrist_camera_link" image_feature: str = "observation.images.wrist" + action_stream: str = "applied_joint_position_command" repo_id: str = "local/openyam-wrist" def dataprep_config(self) -> DataPrepConfig: @@ -68,7 +69,7 @@ def dataprep_config(self) -> DataPrepConfig: }, action={ "action": FeatureSpec( - stream="applied_joint_position_command", + stream=self.action_stream, field="position", dtype="float32", shape=(len(joint_names),), @@ -95,3 +96,4 @@ def dataprep_config(self) -> DataPrepConfig: OPENYAM_LEARNING_PROFILE = OpenYamLearningProfile() +OPENYAM_TEACH_LEARNING_PROFILE = OpenYamLearningProfile(action_stream="coordinator_joint_state") diff --git a/dimos/robot/manipulators/openyam/test_learning.py b/dimos/robot/manipulators/openyam/test_learning.py index 91d5eae19e..f219e2bef5 100644 --- a/dimos/robot/manipulators/openyam/test_learning.py +++ b/dimos/robot/manipulators/openyam/test_learning.py @@ -13,7 +13,10 @@ # limitations under the License. from dimos.robot.manipulators.openyam.config import OPENYAM_JOINTS -from dimos.robot.manipulators.openyam.learning import OPENYAM_LEARNING_PROFILE +from dimos.robot.manipulators.openyam.learning import ( + OPENYAM_LEARNING_PROFILE, + OPENYAM_TEACH_LEARNING_PROFILE, +) def test_openyam_profile_builds_matching_observation_and_action_schema() -> None: @@ -27,3 +30,13 @@ def test_openyam_profile_builds_matching_observation_and_action_schema() -> None assert config.action["action"].names == OPENYAM_JOINTS assert config.observation[profile.image_feature].shape == (480, 640, 3) assert config.output.metadata["robot_type"] == "openyam" + + +def test_openyam_teach_profile_uses_measured_joint_state_as_action() -> None: + profile = OPENYAM_TEACH_LEARNING_PROFILE + + config = profile.dataprep_config() + + assert config.observation["observation.state"].names == OPENYAM_JOINTS + assert config.action["action"].names == OPENYAM_JOINTS + assert config.action["action"].stream == "coordinator_joint_state" From 80e463f25e6cc6a835fa325c4eba95826a421ebc Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 3 Sep 2026 16:20:03 -0700 Subject: [PATCH 5/9] docs(imitation): launch dataset viewer through uv --- dimos/imitation/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dimos/imitation/README.md b/dimos/imitation/README.md index 27037a3ab1..b70b7d8016 100644 --- a/dimos/imitation/README.md +++ b/dimos/imitation/README.md @@ -117,6 +117,18 @@ dimos dataprep build \ --output data/datasets/openyam-teach ``` +Visualize an episode from the isolated LeRobot environment so its matching +Rerun viewer executable is available on `PATH`: + +```bash +uv run --project dimos/imitation/policy/lerobot/python --frozen \ + lerobot-dataset-viz \ + --repo-id local/openyam-wrist \ + --root "$PWD/data/datasets/openyam-teach" \ + --episode-index 0 \ + --mode local +``` + The action row contains the measured arm and gripper positions at that instant. The direct-teach profile therefore reads both state and action from the continuous coordinator joint-state stream; Quest collection continues to use accepted commands. From 702531d787dbf00648eb70e5285f9d7e26f3a0ce Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 3 Sep 2026 16:39:19 -0700 Subject: [PATCH 6/9] fix(imitation): use portable LeRobot video decoding --- .../python/dimos_lerobot/mcap_dataprep_tests.py | 5 +++++ .../imitation/policy/lerobot/python/pyproject.toml | 3 +++ dimos/imitation/policy/lerobot/python/uv.lock | 13 +++++-------- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/dimos/imitation/policy/lerobot/python/dimos_lerobot/mcap_dataprep_tests.py b/dimos/imitation/policy/lerobot/python/dimos_lerobot/mcap_dataprep_tests.py index 2bd7bdc6bf..3cc166e262 100644 --- a/dimos/imitation/policy/lerobot/python/dimos_lerobot/mcap_dataprep_tests.py +++ b/dimos/imitation/policy/lerobot/python/dimos_lerobot/mcap_dataprep_tests.py @@ -21,6 +21,7 @@ from typing import Any from dimos_lerobot.dataprep import write +from lerobot.datasets.lerobot_dataset import LeRobotDataset from mcap.writer import Writer as McapWriter import numpy as np @@ -197,3 +198,7 @@ def test_mcap_converts_to_lerobot_dataset(tmp_path: Path) -> None: assert info["total_frames"] == 3 assert info["fps"] == 30 assert info["features"]["action"]["names"] == JOINTS + + dataset = LeRobotDataset("local/openyam-mcap", root=root) + frame = dataset[0] + assert tuple(frame["observation.images.wrist"].shape) == (3, 64, 64) diff --git a/dimos/imitation/policy/lerobot/python/pyproject.toml b/dimos/imitation/policy/lerobot/python/pyproject.toml index 27fcf8516c..83c9c7a055 100644 --- a/dimos/imitation/policy/lerobot/python/pyproject.toml +++ b/dimos/imitation/policy/lerobot/python/pyproject.toml @@ -26,6 +26,9 @@ override-dependencies = [ # LeRobot's headless OpenCV wheel owns the same cv2/ tree as DimOS's # opencv-contrib-python dependency. Contrib is the required superset. "opencv-python-headless; sys_platform == 'never'", + # LeRobot selects TorchCodec whenever it is importable, but its native wheel + # cannot load against newer system FFmpeg ABIs. Use LeRobot's PyAV backend. + "torchcodec; sys_platform == 'never'", ] [tool.setuptools.packages.find] diff --git a/dimos/imitation/policy/lerobot/python/uv.lock b/dimos/imitation/policy/lerobot/python/uv.lock index f6edf74a0a..66fe2d4b74 100644 --- a/dimos/imitation/policy/lerobot/python/uv.lock +++ b/dimos/imitation/policy/lerobot/python/uv.lock @@ -10,7 +10,10 @@ resolution-markers = [ ] [manifest] -overrides = [{ name = "opencv-python-headless", marker = "sys_platform == 'never'" }] +overrides = [ + { name = "opencv-python-headless", marker = "sys_platform == 'never'" }, + { name = "torchcodec", marker = "sys_platform == 'never'" }, +] [[package]] name = "accelerate" @@ -636,7 +639,7 @@ dataset = [ { name = "jsonlines" }, { name = "pandas" }, { name = "pyarrow" }, - { name = "torchcodec", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and sys_platform == 'linux') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'arm64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'win32'" }, + { name = "torchcodec", marker = "sys_platform == 'never'" }, ] viz = [ { name = "foxglove-sdk" }, @@ -1447,12 +1450,6 @@ wheels = [ name = "torchcodec" version = "0.11.1" source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/85/38f4843ff2a6bf7dfb71a153acd99024dadb96749965a67524c2f1cc1894/torchcodec-0.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:57056e91d1d883d0fb77ca7759e304be9c0bdb4ea0e37bde5c2e361347063b8c", size = 4368988, upload-time = "2026-04-14T18:24:51.46Z" }, - { url = "https://files.pythonhosted.org/packages/4b/85/3b41034b0f1289423745f918ace2a1e1e86b9c578c2e2461b6afcbb5354a/torchcodec-0.11.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f1aee486a84247fcaa67870ac5005aa8d382a9839e91e476fa71b5b3d9fda9b7", size = 2397532, upload-time = "2026-04-14T18:24:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a9/a2b6ee3e84c55bdd0c45fd991dde71c95a99115ec9e26938b212b4545dcf/torchcodec-0.11.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6c26e90e7aa982302644d0af8cb706318682bb390f48a80ecbfeab03499acd04", size = 2329883, upload-time = "2026-04-14T18:24:55.467Z" }, - { url = "https://files.pythonhosted.org/packages/82/48/683114a4ed6b59f76b6919532a5db0f4068787be26bab92cc18a1dfa6794/torchcodec-0.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:3fd2d10e0e0a5f455c1c87dc1380b3bd43b77dd5eeeaf479470643b1c04a2dd2", size = 1921066, upload-time = "2026-04-14T18:24:57.102Z" }, -] [[package]] name = "torchvision" From 5149cdcec6d543730f82daa6b35b98a1bdca7524 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 3 Sep 2026 17:14:28 -0700 Subject: [PATCH 7/9] fix(memory): refresh native recorder before launch --- Cargo.lock | 48 +++++++++++-------- dimos/experimental/memory/README.md | 5 +- dimos/experimental/memory/rust/flake.nix | 2 +- dimos/mapping/ray_tracing/rust/Cargo.toml | 2 +- .../nav_3d/mls_planner/rust/Cargo.toml | 2 +- examples/native-modules/rust/Cargo.toml | 2 +- 6 files changed, 35 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9871402b31..35e4d1954e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -629,7 +629,7 @@ dependencies = [ [[package]] name = "dimos-lcm" version = "0.1.0" -source = "git+https://github.com/dimensionalOS/dimos-lcm.git?branch=rust-codegen#04d78e8622500244123ba9cefa4c51b4cb454549" +source = "git+https://github.com/dimensionalOS/dimos-lcm.git?rev=dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5#dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5" dependencies = [ "byteorder", "socket2 0.5.10", @@ -644,7 +644,7 @@ dependencies = [ "crossbeam-channel", "crossbeam-utils", "dimos-module", - "lcm-msgs", + "lcm-msgs 0.1.0 (git+https://github.com/dimensionalOS/dimos-lcm.git?branch=rust-codegen)", "lz4_flex 0.14.0", "mcap", "rayon", @@ -664,7 +664,7 @@ version = "0.1.0" dependencies = [ "ahash", "dimos-module", - "lcm-msgs", + "lcm-msgs 0.1.0 (git+https://github.com/dimensionalOS/dimos-lcm.git?rev=dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5)", "rayon", "serde", "tokio", @@ -690,7 +690,7 @@ version = "0.1.0" dependencies = [ "dimos-lcm", "dimos-module-macros", - "lcm-msgs", + "lcm-msgs 0.1.0 (git+https://github.com/dimensionalOS/dimos-lcm.git?rev=dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5)", "nalgebra", "rayon", "serde", @@ -711,7 +711,7 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.4", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", ] [[package]] @@ -719,7 +719,7 @@ name = "dimos-native-module-examples" version = "0.1.0" dependencies = [ "dimos-module", - "lcm-msgs", + "lcm-msgs 0.1.0 (git+https://github.com/dimensionalOS/dimos-lcm.git?rev=dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5)", "serde", "tokio", "tracing", @@ -733,7 +733,7 @@ dependencies = [ "ahash", "arrayvec", "dimos-module", - "lcm-msgs", + "lcm-msgs 0.1.0 (git+https://github.com/dimensionalOS/dimos-lcm.git?rev=dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5)", "nalgebra", "rayon", "serde", @@ -1604,6 +1604,14 @@ dependencies = [ "byteorder", ] +[[package]] +name = "lcm-msgs" +version = "0.1.0" +source = "git+https://github.com/dimensionalOS/dimos-lcm.git?rev=dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5#dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5" +dependencies = [ + "byteorder", +] + [[package]] name = "libc" version = "0.2.189" @@ -1628,9 +1636,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.21" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "libc", ] @@ -1776,9 +1784,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", @@ -2290,13 +2298,13 @@ dependencies = [ [[package]] name = "prometheus-client-derive-encode" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9adf1691c04c0a5ff46ff8f262b58beb07b0dbb61f96f9f54f6cbd82106ed87f" +checksum = "01e34894696ff94f64a20c2c373a6440903e9c2789a303d68ec6e6f953f890e4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -3128,9 +3136,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "socket2" @@ -3439,9 +3447,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -3521,9 +3529,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ "indexmap 2.14.1", "serde_core", diff --git a/dimos/experimental/memory/README.md b/dimos/experimental/memory/README.md index 0537a95ce4..ca07570ac1 100644 --- a/dimos/experimental/memory/README.md +++ b/dimos/experimental/memory/README.md @@ -11,8 +11,9 @@ The recorder is built as a locked Nix package. Nix supplies Rust, CMake, NASM, SQLite, and the native libraries used by TurboJPEG, so none of those tools or development packages need to be installed on the host. -The Python module builds the package automatically on first use. To build it -ahead of time, run: +The Python module resolves the package through Nix before each launch, so its +wire protocol always matches the Python checkout. Nix reuses the cached package +when the native sources have not changed. To build it ahead of time, run: ```bash cd dimos/experimental/memory/rust diff --git a/dimos/experimental/memory/rust/flake.nix b/dimos/experimental/memory/rust/flake.nix index 0a950fafe4..f9057c2a25 100644 --- a/dimos/experimental/memory/rust/flake.nix +++ b/dimos/experimental/memory/rust/flake.nix @@ -38,7 +38,7 @@ cargoLock = { lockFile = ../../../../Cargo.lock; outputHashes = { - "dimos-lcm-0.1.0" = "sha256-GGkx4Mn6NYP6KZecmoRLKGWIih/+y8OgNn12DeXX6n8="; + "dimos-lcm-0.1.0" = "sha256-Z0tKEjNb/VIyfJcYTeZjWhWWYMhQ8wtoe8TjhonPUns="; }; }; diff --git a/dimos/mapping/ray_tracing/rust/Cargo.toml b/dimos/mapping/ray_tracing/rust/Cargo.toml index 501f8c5028..73445f8093 100644 --- a/dimos/mapping/ray_tracing/rust/Cargo.toml +++ b/dimos/mapping/ray_tracing/rust/Cargo.toml @@ -33,7 +33,7 @@ region_bounds = "geometry_msgs.PoseStamped" [dependencies] dimos-module = { path = "../../../../native/rust/dimos-module" } -lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", branch = "rust-codegen" } +lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", rev = "dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] } serde = { version = "1", features = ["derive"] } ahash = "0.8" diff --git a/dimos/navigation/nav_3d/mls_planner/rust/Cargo.toml b/dimos/navigation/nav_3d/mls_planner/rust/Cargo.toml index 2c5d39de04..b6e1a82fa2 100644 --- a/dimos/navigation/nav_3d/mls_planner/rust/Cargo.toml +++ b/dimos/navigation/nav_3d/mls_planner/rust/Cargo.toml @@ -36,7 +36,7 @@ path = "nav_msgs.Path" [dependencies] dimos-module = { path = "../../../../../native/rust/dimos-module" } -lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", branch = "rust-codegen" } +lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", rev = "dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] } serde = { version = "1", features = ["derive"] } ahash = "0.8" diff --git a/examples/native-modules/rust/Cargo.toml b/examples/native-modules/rust/Cargo.toml index d2c1d48d48..9ee0334293 100644 --- a/examples/native-modules/rust/Cargo.toml +++ b/examples/native-modules/rust/Cargo.toml @@ -21,7 +21,7 @@ path = "src/tf_broadcaster.rs" [dependencies] dimos-module = { path = "../../../native/rust/dimos-module" } -lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", branch = "rust-codegen" } +lcm-msgs = { git = "https://github.com/dimensionalOS/dimos-lcm.git", rev = "dd2159513ebfaa7ebc5fc32bf60209cd09aa1ca5" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } serde = { version = "1", features = ["derive"] } tracing = "0.1" From 1ef8eb8884bcbf38c9b19037ca0ca8d2a6dc5cb6 Mon Sep 17 00:00:00 2001 From: cc Date: Thu, 3 Sep 2026 18:05:16 -0700 Subject: [PATCH 8/9] refactor(imitation): simplify direct teach controls --- dimos/cli/commands/collect.py | 195 +++++++++++++++--- dimos/cli/commands/test_collect.py | 66 +++++- dimos/control/tasks/teach_task/_registry.py | 17 -- dimos/control/tasks/teach_task/teach_task.py | 138 ------------- .../tasks/teach_task/test_teach_task.py | 172 --------------- dimos/imitation/README.md | 22 +- .../openyam/blueprints/learning_collection.py | 3 +- .../blueprints/test_learning_collection.py | 12 +- 8 files changed, 249 insertions(+), 376 deletions(-) delete mode 100644 dimos/control/tasks/teach_task/_registry.py delete mode 100644 dimos/control/tasks/teach_task/teach_task.py delete mode 100644 dimos/control/tasks/teach_task/test_teach_task.py diff --git a/dimos/cli/commands/collect.py b/dimos/cli/commands/collect.py index 2cb2c41108..71b6698abd 100644 --- a/dimos/cli/commands/collect.py +++ b/dimos/cli/commands/collect.py @@ -16,13 +16,13 @@ from __future__ import annotations +import time from typing import Any, cast -from rich.panel import Panel -from rich.text import Text from textual.app import App, ComposeResult from textual.binding import Binding -from textual.widgets import Footer, Static +from textual.containers import Container, Horizontal +from textual.widgets import Button, Footer, Static import typer from dimos.cli import theme @@ -40,6 +40,7 @@ class TeachCollectionSession: def __init__(self, client: Dimos, monitor: Any) -> None: self._client = client self._monitor = monitor + self._closed = False @classmethod def connect(cls) -> TeachCollectionSession: @@ -91,20 +92,90 @@ def command(self, event: str) -> EpisodeStatus: def close(self) -> None: """Close only this RPC client; leave the daemon and robot running.""" - self._client.stop() + if not self._closed: + self._client.stop() + self._closed = True class TeachCollectionApp(App[None]): - """Small keyboard panel for teach collection.""" + """Operator dashboard for hand-guided teach collection.""" + CSS_PATH = theme.CSS_PATH CSS = f""" Screen {{ align: center middle; background: {theme.BACKGROUND}; }} - #status {{ - width: 72; + + #dashboard {{ + width: 82; + max-width: 95%; height: auto; + padding: 1 2; + border: double {theme.BORDER}; + background: {theme.BG}; + }} + + #title {{ + height: 1; + content-align: center middle; + color: {theme.ACCENT}; + text-style: bold; + }} + + #task {{ + height: 1; + text-align: center; + color: {theme.WHITE}; + }} + + #state {{ + height: 3; + margin-top: 1; + border: round {theme.SUCCESS}; + content-align: center middle; + color: {theme.SUCCESS}; + text-style: bold; + }} + + #state.recording, #state.disconnected {{ + border: round {theme.ERROR}; + color: {theme.ERROR}; + }} + + #counters {{ + height: 3; + }} + + .counter {{ + width: 1fr; + margin: 0 1; + border: round {theme.DIM}; + content-align: center middle; + text-align: center; + }} + + #guidance {{ + height: 3; + content-align: center middle; + text-align: center; + color: {theme.FOREGROUND}; + }} + + #message {{ + height: 2; + content-align: center middle; + text-align: center; + color: {theme.WARNING}; + }} + + #actions {{ + height: 3; + }} + + #actions Button {{ + width: 1fr; + margin: 0 1; }} """ @@ -119,49 +190,97 @@ def __init__(self, session: TeachCollectionSession) -> None: super().__init__() self._session = session self._status = session.get_status() - self._message = "Move the arm and gripper by hand; press Space when the take begins." + self._message = "Reset the scene, then start a take." self._detached = False + self._recording_started_at: float | None = None def compose(self) -> ComposeResult: - yield Static(self._render(), id="status") + with Container(id="dashboard"): + yield Static("OPENYAM / TEACH COLLECTION", id="title") + yield Static(id="task") + yield Static(id="state") + with Horizontal(id="counters"): + yield Static(id="saved", classes="counter") + yield Static(id="discarded", classes="counter") + yield Static(id="guidance") + yield Static(id="message") + with Horizontal(id="actions"): + yield Button("Start recording", id="toggle", variant="success") + yield Button("Discard", id="discard", variant="error", disabled=True) + yield Button("Detach", id="detach") yield Footer() def on_mount(self) -> None: + self._refresh() self.set_interval(0.25, self._poll) def on_unmount(self) -> None: self._session.close() - def _render(self) -> Panel: + @staticmethod + def _format_elapsed(seconds: float) -> str: + minutes, seconds = divmod(max(seconds, 0.0), 60.0) + return f"{int(minutes):02d}:{seconds:04.1f}" + + def _set_status(self, status: EpisodeStatus) -> None: + was_recording = self._status.state == "recording" + self._status = status + recording = status.state == "recording" + if recording and not was_recording: + self._recording_started_at = time.monotonic() + elif not recording: + self._recording_started_at = None + + def _state_text(self) -> str: + if self._detached: + return "DISCONNECTED" recording = self._status.state == "recording" - state_style = theme.ERROR if recording else theme.SUCCESS - body = Text() - body.append("Task ", style="bold") - body.append(f"{self._status.task_label}\n") - body.append("State ", style="bold") - body.append(f"{self._status.state.upper()}\n", style=f"bold {state_style}") - body.append("Episodes ", style="bold") - body.append( - f"{self._status.episodes_saved} saved, {self._status.episodes_discarded} discarded\n" + if not recording: + return "READY" + elapsed = ( + "--:--" + if self._recording_started_at is None + else self._format_elapsed(time.monotonic() - self._recording_started_at) ) - body.append("Gripper ", style="bold") - body.append("passive — move by hand\n\n") - body.append(self._message) - if self._detached: - body.append( - "\n\nRPC connection closed; the daemon and arm are still running.", - style=theme.ERROR, - ) - return Panel(body, title="OpenYAM teach collection", border_style=theme.BORDER) + return f"● RECORDING {elapsed}" def _refresh(self) -> None: - self.query_one("#status", Static).update(self._render()) + recording = self._status.state == "recording" + state = self.query_one("#state", Static) + state.set_class(recording and not self._detached, "recording") + state.set_class(self._detached, "disconnected") + state.update(self._state_text()) + + task = self._status.task_label or "Untitled task" + self.query_one("#task", Static).update(f"TASK {task}") + self.query_one("#saved", Static).update(f"SAVED\n{self._status.episodes_saved}") + self.query_one("#discarded", Static).update(f"DISCARDED\n{self._status.episodes_discarded}") + guidance = ( + "Move the gravity-compensated arm and passive gripper by hand.\n" + "Press Space to save this episode, or D to discard it." + if recording + else "Reset the scene and place the arm at the starting pose.\n" + "Press Space when the demonstration begins." + ) + if self._detached: + guidance = "The RPC connection closed. The daemon and arm are still running." + self.query_one("#guidance", Static).update(guidance) + self.query_one("#message", Static).update(self._message) + + toggle = self.query_one("#toggle", Button) + toggle.label = "Save episode" if recording else "Start recording" + toggle.variant = "error" if recording else "success" + toggle.disabled = self._detached + self.query_one("#discard", Button).disabled = self._detached or not recording + detach = self.query_one("#detach", Button) + detach.label = "Exit" if self._detached else "Detach" + detach.disabled = recording and not self._detached def _poll(self) -> None: if self._detached: return try: - self._status = self._session.get_status() + self._set_status(self._session.get_status()) self._refresh() except Exception as exc: self._fail(exc) @@ -176,7 +295,7 @@ def _episode_command(self, event: str) -> None: if self._detached: return try: - self._status = self._session.command(event) + self._set_status(self._session.command(event)) self._message = { "start": "Recording. Drag the arm through the demonstration.", "save": "Episode saved. Reset the scene for the next take.", @@ -190,8 +309,22 @@ def action_toggle_recording(self) -> None: self._episode_command("toggle") def action_discard(self) -> None: + if self._status.state != "recording": + self._message = "Nothing to discard. Start a take first." + self._refresh() + return self._episode_command("discard") + def on_button_pressed(self, event: Button.Pressed) -> None: + actions = { + "toggle": self.action_toggle_recording, + "discard": self.action_discard, + "detach": self.action_quit, + } + action = actions.get(event.button.id or "") + if action is not None: + action() + def action_quit(self) -> None: # type: ignore[override] if not self._detached and self._status.state == "recording": self._message = "Save with Space or discard with D before detaching." diff --git a/dimos/cli/commands/test_collect.py b/dimos/cli/commands/test_collect.py index 731fd1d1ac..14a99abcac 100644 --- a/dimos/cli/commands/test_collect.py +++ b/dimos/cli/commands/test_collect.py @@ -15,18 +15,26 @@ from typing import Any from pytest_mock import MockerFixture +from textual.widgets import Button, Static from dimos.cli.commands.collect import TeachCollectionApp, TeachCollectionSession from dimos.core.introspection.module.info import ModuleInfo, RpcInfo from dimos.msgs.imitation_msgs.EpisodeStatus import EpisodeStatus -def _status(state: str = "idle") -> EpisodeStatus: +def _status( + state: str = "idle", + *, + event: str = "init", + saved: int = 0, + discarded: int = 0, +) -> EpisodeStatus: return EpisodeStatus( ts=1.0, state=state, # type: ignore[arg-type] - episodes_saved=0, - episodes_discarded=0, + episodes_saved=saved, + episodes_discarded=discarded, + last_event=event, # type: ignore[arg-type] task_label="pick up the block", ) @@ -35,7 +43,10 @@ def _session(mocker: MockerFixture) -> tuple[TeachCollectionSession, Any, Any]: client = mocker.Mock() monitor = mocker.Mock() monitor.get_status.return_value = _status() - monitor.command.side_effect = [_status("recording"), _status("idle")] + monitor.command.side_effect = [ + _status("recording", event="start"), + _status("idle", event="discard", discarded=1), + ] return TeachCollectionSession(client, monitor), client, monitor @@ -71,15 +82,54 @@ def test_panel_binds_the_documented_keys() -> None: } -def test_rpc_failure_detaches_without_stopping_the_daemon(mocker: MockerFixture) -> None: +async def test_dashboard_buttons_follow_episode_state(mocker: MockerFixture) -> None: + session, _, monitor = _session(mocker) + app = TeachCollectionApp(session) + mocker.patch.object(app, "set_interval") + + async with app.run_test(size=(80, 24)) as pilot: + assert str(app.query_one("#state", Static).render()) == "READY" + assert str(app.query_one("#toggle", Button).label) == "Start recording" + assert app.query_one("#discard", Button).disabled + + await pilot.click("#toggle") + + assert "RECORDING" in str(app.query_one("#state", Static).render()) + assert str(app.query_one("#toggle", Button).label) == "Save episode" + assert not app.query_one("#discard", Button).disabled + assert app.query_one("#detach", Button).disabled + + await pilot.click("#discard") + + assert str(app.query_one("#state", Static).render()) == "READY" + assert str(app.query_one("#discarded", Static).render()) == "DISCARDED\n1" + assert not app.query_one("#detach", Button).disabled + assert monitor.command.call_args_list == [mocker.call("toggle"), mocker.call("discard")] + + +def test_dashboard_formats_recording_time() -> None: + assert TeachCollectionApp._format_elapsed(0.0) == "00:00.0" + assert TeachCollectionApp._format_elapsed(62.34) == "01:02.3" + + +async def test_rpc_failure_disables_controls_without_stopping_daemon( + mocker: MockerFixture, +) -> None: session, client, monitor = _session(mocker) app = TeachCollectionApp(session) - mocker.patch.object(app, "_refresh") + mocker.patch.object(app, "set_interval") monitor.command.side_effect = RuntimeError("stack disappeared") - app.action_toggle_recording() + async with app.run_test(size=(80, 24)) as pilot: + await pilot.click("#toggle") + + assert str(app.query_one("#state", Static).render()) == "DISCONNECTED" + assert app.query_one("#toggle", Button).disabled + assert app.query_one("#discard", Button).disabled + assert not app.query_one("#detach", Button).disabled + assert str(app.query_one("#detach", Button).label) == "Exit" - assert app._detached is True + # Closing the failed dashboard must only detach this RPC client. client.stop.assert_called_once_with() diff --git a/dimos/control/tasks/teach_task/_registry.py b/dimos/control/tasks/teach_task/_registry.py deleted file mode 100644 index 9e3226f440..0000000000 --- a/dimos/control/tasks/teach_task/_registry.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -TASK_FACTORIES = { - "teach": "dimos.control.tasks.teach_task.teach_task:create_task", -} diff --git a/dimos/control/tasks/teach_task/teach_task.py b/dimos/control/tasks/teach_task/teach_task.py deleted file mode 100644 index e27214e74a..0000000000 --- a/dimos/control/tasks/teach_task/teach_task.py +++ /dev/null @@ -1,138 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Measured-position passthrough for gravity-compensated teaching.""" - -from __future__ import annotations - -from dataclasses import dataclass -import math -from typing import Any - -from dimos.control.hardware_interface import ConnectedWholeBody -from dimos.control.task import ( - BaseControlTask, - ControlMode, - CoordinatorState, - JointCommandOutput, - ResourceClaim, -) - - -@dataclass(frozen=True) -class TeachControlTaskConfig: - """Configuration for a gravity-compensated teach task.""" - - joint_names: tuple[str, ...] - priority: int = 10 - - -class TeachControlTask(BaseControlTask): - """Continuously command each joint's measured position. - - On zero-stiffness whole-body hardware this keeps gravity compensation and - damping active while allowing an operator to move the mechanism by hand. - """ - - def __init__(self, name: str, config: TeachControlTaskConfig) -> None: - self._name = name - self._config = config - - def claim(self) -> ResourceClaim: - """Claim the taught joints at the configured priority.""" - return ResourceClaim( - joints=frozenset(self._config.joint_names), - priority=self._config.priority, - mode=ControlMode.SERVO_POSITION, - ) - - def is_active(self) -> bool: - """Keep the hardware control loop active for the entire run.""" - return True - - def compute(self, state: CoordinatorState) -> JointCommandOutput | None: - """Mirror a complete, finite measured-position snapshot.""" - positions: list[float] = [] - for joint_name in self._config.joint_names: - position = state.joints.get_position(joint_name) - if position is None or not math.isfinite(position): - return None - positions.append(position) - return JointCommandOutput( - joint_names=list(self._config.joint_names), - positions=positions, - mode=ControlMode.SERVO_POSITION, - ) - - def on_preempted(self, by_task: str, joints: frozenset[str]) -> None: - """Allow higher-priority tasks to override individual joints.""" - - -def _validate_hardware(cfg: Any, hardware: Any) -> None: - where = f"teach task {cfg.name!r}" - joint_names = list(cfg.joint_names) - if not joint_names: - raise ValueError(f"{where}: requires at least one joint") - if len(set(joint_names)) != len(joint_names): - raise ValueError(f"{where}: joint_names must not contain duplicates") - - owners: list[ConnectedWholeBody] = [] - for joint_name in joint_names: - matches = [ - connected - for connected in (hardware or {}).values() - if joint_name in connected.component.joints - ] - if not matches: - raise ValueError(f"{where}: joint {joint_name!r} is not owned by coordinator hardware") - if len(matches) > 1: - raise ValueError(f"{where}: joint {joint_name!r} is owned by multiple components") - owner = matches[0] - if not isinstance(owner, ConnectedWholeBody): - raise ValueError(f"{where}: requires whole-body hardware") - owners.append(owner) - - owner = owners[0] - if any(candidate is not owner for candidate in owners[1:]): - raise ValueError(f"{where}: all joints must belong to one whole-body component") - component = owner.component - wb_config = component.wb_config - if wb_config is None or wb_config.kp is None or wb_config.kd is None: - raise ValueError(f"{where}: whole-body hardware requires explicit kp and kd") - if len(wb_config.kp) != len(component.joints) or len(wb_config.kd) != len(component.joints): - raise ValueError( - f"{where}: kp and kd must match the component's {len(component.joints)} joints" - ) - - indices = [component.joints.index(name) for name in joint_names] - stiffness = [wb_config.kp[index] for index in indices] - damping = [wb_config.kd[index] for index in indices] - if any(not math.isfinite(value) for value in [*stiffness, *damping]): - raise ValueError(f"{where}: kp and kd must be finite") - if any(value != 0.0 for value in stiffness): - raise ValueError(f"{where}: requires zero stiffness (kp=0) for every taught joint") - if any(value < 0.0 for value in damping): - raise ValueError(f"{where}: damping (kd) must be non-negative") - - -def create_task(cfg: Any, hardware: Any) -> TeachControlTask: - """Build and validate a teach task from coordinator configuration.""" - _validate_hardware(cfg, hardware) - return TeachControlTask( - cfg.name, - TeachControlTaskConfig( - joint_names=tuple(cfg.joint_names), - priority=cfg.priority, - ), - ) diff --git a/dimos/control/tasks/teach_task/test_teach_task.py b/dimos/control/tasks/teach_task/test_teach_task.py deleted file mode 100644 index 723bb209c0..0000000000 --- a/dimos/control/tasks/teach_task/test_teach_task.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright 2026 Dimensional Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -from dataclasses import replace - -import pytest -from pytest_mock import MockerFixture - -from dimos.control.components import HardwareComponent, HardwareType -from dimos.control.coordinator import TaskConfig -from dimos.control.hardware_interface import ConnectedHardware, ConnectedWholeBody -from dimos.control.task import ControlMode, CoordinatorState, JointStateSnapshot -from dimos.control.tasks.gripper_task.gripper_task import ( - GripperControlTask, - GripperControlTaskConfig, -) -from dimos.control.tasks.teach_task.teach_task import ( - TeachControlTask, - TeachControlTaskConfig, - create_task, -) -from dimos.control.tick_loop import TickLoop -from dimos.hardware.manipulators.spec import ManipulatorAdapter -from dimos.hardware.whole_body.spec import WholeBodyAdapter, WholeBodyConfig -from dimos.robot.manipulators.openyam.config import OPENYAM_JOINTS -from dimos.robot.manipulators.openyam.learning import OPENYAM_LEARNING_PROFILE - - -def _state(positions: dict[str, float]) -> CoordinatorState: - return CoordinatorState(joints=JointStateSnapshot(joint_positions=positions)) - - -def _whole_body( - mocker: MockerFixture, - *, - joints: list[str] | None = None, - kp: tuple[float, ...] | None = None, - kd: tuple[float, ...] | None = None, -) -> ConnectedWholeBody: - names = list(joints or OPENYAM_JOINTS) - component = HardwareComponent( - hardware_id="robot", - hardware_type=HardwareType.WHOLE_BODY, - joints=names, - wb_config=WholeBodyConfig( - kp=(0.0,) * len(names) if kp is None else kp, - kd=(1.0,) * len(names) if kd is None else kd, - ), - ) - return ConnectedWholeBody(mocker.Mock(spec=WholeBodyAdapter), component) - - -def _cfg(joints: list[str] | None = None) -> TaskConfig: - return TaskConfig( - name="teach", - type="teach", - joint_names=list(OPENYAM_JOINTS if joints is None else joints), - priority=10, - ) - - -def test_teach_task_mirrors_a_complete_measured_state_in_order() -> None: - task = TeachControlTask( - "teach", - TeachControlTaskConfig(tuple(OPENYAM_JOINTS), priority=10), - ) - positions = {name: float(index) for index, name in enumerate(reversed(OPENYAM_JOINTS))} - - output = task.compute(_state(positions)) - - assert task.is_active() - assert task.claim().mode is ControlMode.SERVO_POSITION - assert output is not None - assert output.joint_names == OPENYAM_JOINTS - assert output.positions == [positions[name] for name in OPENYAM_JOINTS] - - -@pytest.mark.parametrize("bad_position", [None, float("nan"), float("inf")]) -def test_teach_task_rejects_incomplete_or_nonfinite_state(bad_position: float | None) -> None: - task = TeachControlTask("teach", TeachControlTaskConfig(tuple(OPENYAM_JOINTS))) - positions = {name: 0.0 for name in OPENYAM_JOINTS} - if bad_position is None: - del positions[OPENYAM_JOINTS[0]] - else: - positions[OPENYAM_JOINTS[0]] = bad_position - - assert task.compute(_state(positions)) is None - - -def test_gripper_preempts_only_the_seventh_teach_action() -> None: - teach = TeachControlTask("teach", TeachControlTaskConfig(tuple(OPENYAM_JOINTS), priority=10)) - gripper = GripperControlTask( - "arm_gripper", - GripperControlTaskConfig([OPENYAM_JOINTS[-1]], priority=20), - limits=[(0.0, 1.0)], - ) - assert gripper.set_normalized([1.0]) - state = _state({name: index / 10 for index, name in enumerate(OPENYAM_JOINTS)}) - commands = [ - (teach, teach.claim(), teach.compute(state)), - (gripper, gripper.claim(), gripper.compute(state)), - ] - - winners, _ = TickLoop._arbitrate(object.__new__(TickLoop), commands) - - assert list(winners) == OPENYAM_JOINTS - assert list(winners) == OPENYAM_LEARNING_PROFILE.dataprep_config().action["action"].names - assert [value for value, _, _ in winners.values()][:-1] == pytest.approx( - [index / 10 for index in range(6)] - ) - assert winners[OPENYAM_JOINTS[-1]] == (1.0, ControlMode.SERVO_POSITION, "arm_gripper") - - -def test_factory_accepts_zero_stiffness_whole_body_hardware( - mocker: MockerFixture, -) -> None: - task = create_task(_cfg(), {"robot": _whole_body(mocker)}) - assert task.claim().joints == frozenset(OPENYAM_JOINTS) - - -def test_factory_rejects_non_whole_body_hardware(mocker: MockerFixture) -> None: - component = HardwareComponent( - hardware_id="robot", - hardware_type=HardwareType.MANIPULATOR, - joints=list(OPENYAM_JOINTS), - ) - hardware = { - "robot": ConnectedHardware(mocker.Mock(spec=ManipulatorAdapter), component), - } - - with pytest.raises(ValueError, match="requires whole-body hardware"): - create_task(_cfg(), hardware) - - -@pytest.mark.parametrize( - ("cfg", "component_update", "match"), - [ - (_cfg([]), {}, "requires at least one joint"), - (_cfg([OPENYAM_JOINTS[0], OPENYAM_JOINTS[0]]), {}, "duplicates"), - (_cfg(["missing"]), {}, "not owned"), - (_cfg(), {"kp": (1.0,) * len(OPENYAM_JOINTS)}, "zero stiffness"), - ( - _cfg(), - {"kd": (float("nan"),) * len(OPENYAM_JOINTS)}, - "must be finite", - ), - ], -) -def test_factory_rejects_unsafe_configuration( - mocker: MockerFixture, - cfg: TaskConfig, - component_update: dict[str, tuple[float, ...]], - match: str, -) -> None: - hardware = _whole_body(mocker) - if component_update: - assert hardware.component.wb_config is not None - hardware.component.wb_config = replace(hardware.component.wb_config, **component_update) - - with pytest.raises(ValueError, match=match): - create_task(cfg, {"robot": hardware}) diff --git a/dimos/imitation/README.md b/dimos/imitation/README.md index b70b7d8016..1adc870aef 100644 --- a/dimos/imitation/README.md +++ b/dimos/imitation/README.md @@ -63,7 +63,9 @@ prints one line per transition: Direct teaching removes the Quest teleoperator. The arm runs with gravity compensation, zero position stiffness, and joint damping. Move it by hand while -the existing OpenYAM observation and action streams are recorded. +the existing OpenYAM observation and action streams are recorded. The coordinator's +idle trajectory holder keeps sending the motor commands that apply gravity and +damping; zero stiffness makes its latched position target inert. Start the hardware stack in one terminal. Be ready to support the arm as it activates, and keep people and obstacles outside its workspace. @@ -82,12 +84,17 @@ dimos collect ``` ```text -┌────────────────────── OpenYAM teach collection ──────────────────────┐ -│ Task pick up the red block │ -│ State RECORDING │ -│ Episodes 2 saved, 0 discarded │ -│ Gripper passive — move by hand │ -└─────────────────────────────────────────────────────────────────────┘ +╔════════════════ OPENYAM / TEACH COLLECTION ════════════════╗ +║ TASK pick up the red block ║ +║ ║ +║ ● RECORDING 00:08.4 ║ +║ ║ +║ SAVED 2 DISCARDED 0 ║ +║ ║ +║ Move the gravity-compensated arm and passive gripper. ║ +║ ║ +║ [ Save episode ] [ Discard ] [ Detach ] ║ +╚════════════════════════════════════════════════════════════╝ ``` | Key | Action | @@ -96,6 +103,7 @@ dimos collect | **D** | Discard the in-progress episode | | **Q** or **Ctrl-C** | Detach the panel while idle | +The same actions are available as clickable buttons. The panel refuses to detach while recording. Save or discard the take first. Detaching closes only the panel's RPC connection; the arm remains active in gravity-compensation mode until you run `dimos stop`. diff --git a/dimos/robot/manipulators/openyam/blueprints/learning_collection.py b/dimos/robot/manipulators/openyam/blueprints/learning_collection.py index c4e6c6e7e5..784eaaf71d 100644 --- a/dimos/robot/manipulators/openyam/blueprints/learning_collection.py +++ b/dimos/robot/manipulators/openyam/blueprints/learning_collection.py @@ -105,9 +105,10 @@ def _wrist_camera() -> Blueprint: tasks=[ TaskConfig( name="teach_openyam", - type="teach", + type="trajectory", joint_names=list(OPENYAM_JOINTS), priority=10, + params={"hold_position_when_idle": True}, ), ], ), diff --git a/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py b/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py index 7040e0172d..cb17605355 100644 --- a/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py +++ b/dimos/robot/manipulators/openyam/blueprints/test_learning_collection.py @@ -84,8 +84,16 @@ def test_openyam_teach_collection_uses_gravity_compensation_and_zero_stiffness() assert hardware.adapter_kwargs["runtime_config"].passive_grippers == ("gripper",) tasks = coordinator.kwargs["tasks"] - assert [(task.name, task.type, task.joint_names, task.priority) for task in tasks] == [ - ("teach_openyam", "teach", OPENYAM_JOINTS, 10), + assert [ + (task.name, task.type, task.joint_names, task.priority, task.params) for task in tasks + ] == [ + ( + "teach_openyam", + "trajectory", + OPENYAM_JOINTS, + 10, + {"hold_position_when_idle": True}, + ), ] From 1cc0cc9c27633c9b63f64b0955738720965be2f9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 03:24:46 +0000 Subject: [PATCH 9/9] [autofix.ci] apply automated fixes --- dimos/cli/dimos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dimos/cli/dimos.py b/dimos/cli/dimos.py index 134493a6a8..08d8a74be5 100644 --- a/dimos/cli/dimos.py +++ b/dimos/cli/dimos.py @@ -52,8 +52,8 @@ from dimos.cli.commands.apriltag import apriltag from dimos.cli.commands.bake import bake from dimos.cli.commands.cameracalibrate import cameracalibrate -from dimos.cli.commands.data import data_app from dimos.cli.commands.collect import collect +from dimos.cli.commands.data import data_app from dimos.cli.commands.dataprep import dataprep_app from dimos.cli.commands.docs import docs from dimos.cli.commands.global_options import create_dynamic_callback