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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
from dimos.hardware.whole_body.dual_openyam_damiao.adapter import (
DualOpenYamDamiaoAdapter,
)
from dimos.robot.manipulators.dual_openyam.config import DUAL_OPENYAM_JOINTS
from dimos.robot.manipulators.dual_openyam.joints import (
DUAL_OPENYAM_JOINTS,
)

pytestmark = pytest.mark.self_hosted

Expand Down
13 changes: 12 additions & 1 deletion dimos/manipulation/manipulation_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,18 @@ def _execute_generated_plan(self, plan: GeneratedPlan) -> bool:
with self._lock:
self._last_plan = plan
self._state = ManipulationState.COMPLETED
return self.execute(blocking=False, plan_id=plan.plan_id).status is ExecutionStatus.ACCEPTED
logger.info("Viser plan execution requested", plan_id=plan.plan_id)
result = self.execute(blocking=False, plan_id=plan.plan_id)
if result.status is not ExecutionStatus.ACCEPTED:
logger.warning(
"Viser plan execution rejected",
plan_id=plan.plan_id,
status=result.status.name,
reason=result.message,
)
return False
logger.info("Viser plan execution accepted", plan_id=plan.plan_id)
return True

@property
def world_monitor(self) -> WorldMonitor | None:
Expand Down
23 changes: 23 additions & 0 deletions dimos/manipulation/planning/kinematics/pink_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ def _build_robot_context(
model.lowerPositionLimit[int(joint.idx_q)] = -np.inf
model.upperPositionLimit[int(joint.idx_q)] = np.inf

model = _reduce_to_controlled_joints(model, config, controlled_joints)
data = model.createData()
_assert_base_link_is_model_root(model, config.base_link)
frame_id = _get_frame_id(model, frame_name)
Expand Down Expand Up @@ -321,6 +322,28 @@ def _target_in_model_frame(
return target_model


def _reduce_to_controlled_joints(
model: pinocchio.Model,
config: RobotModelConfig,
controlled_joints: Sequence[str] | None,
) -> pinocchio.Model:
"""Lock joints outside the solve so IK cannot exploit uncommanded motion."""
controlled_joint_names = tuple(controlled_joints or config.joint_names)
controlled_joint_ids = {
_get_joint_id(model, joint_name) for joint_name in controlled_joint_names
}
locked_joint_ids = [
joint_id for joint_id in range(1, len(model.joints)) if joint_id not in controlled_joint_ids
]
if not locked_joint_ids:
return model
return pinocchio.buildReducedModel(
model,
locked_joint_ids,
np.asarray(pinocchio.neutral(model), dtype=np.float64),
)


def _build_joint_mapping(
model: pinocchio.Model,
joint_space: JointSpace,
Expand Down
21 changes: 21 additions & 0 deletions dimos/manipulation/planning/kinematics/test_pink_ik.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,27 @@ def _prepared_test_model() -> PreparedRobotModel:
)


def test_reduce_to_controlled_joints_locks_every_other_joint(mocker: MockerFixture) -> None:
modules = _install_fake_modules(mocker)
model = _FakeModel()
reduced = _FakeModel()
modules.pinocchio.neutral = lambda source: np.zeros(source.nq)
build_reduced_model = mocker.Mock(return_value=reduced)
modules.pinocchio.buildReducedModel = build_reduced_model

result = pink_ik._reduce_to_controlled_joints(
model,
_robot_config(),
["joint_a"],
)

assert result is reduced
args = build_reduced_model.call_args.args
assert args[0] is model
assert args[1] == [1, 3]
assert args[2] == pytest.approx([0.0, 0.0, 0.0])


def _streaming_ik(mocker: MockerFixture, converge: bool = True) -> _StreamingTestPinkIK:
_install_fake_modules(mocker, converge=converge)
return _StreamingTestPinkIK(PinkIKConfig(max_iterations=3))
Expand Down
29 changes: 29 additions & 0 deletions dimos/manipulation/visualization/viser/test_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,3 +778,32 @@ def test_gui_ignores_stale_timed_out_operation_finish() -> None:

assert gui.state.action_status == ActionStatus.FAILED
assert gui.state.error == "Operation timed out after 5.0s"


def test_execute_rejection_reports_coordinator_reason(executable_gui, module_factory, mocker):
gui, submissions, _execute = executable_gui
module = module_factory()
mocker.patch.object(gui, "operator", ManipulationOperator(module, mocker.Mock()))
reason = "Trajectory start for joint arm/j0 differs from current position by 0.1"
mocker.patch.object(
module._control_coordinator,
"task_invoke",
return_value=TrajectoryExecutionResult(
TrajectoryExecutionStatus.START_STATE_MISMATCH, reason
),
)
warning = mocker.patch("dimos.manipulation.manipulation_module.logger.warning")
plan = gui.state.plan_state.plan

gui._submit_execute()
submissions[0]()
gui._refresh_model_state()

assert gui.state.last_result == "execute=False"
assert gui.state.error == reason
warning.assert_called_once_with(
"Viser plan execution rejected",
plan_id=plan.plan_id,
status="REJECTED",
reason=reason,
)
29 changes: 28 additions & 1 deletion dimos/robot/manipulators/dual_openyam/blueprints/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,21 @@

"""Dual OpenYAM coordinator and planning blueprints."""

import math

from dimos.control.coordinator import ControlCoordinatorConfig, TaskConfig
from dimos.control.tasks.trajectory_task.trajectory_task import JOINT_TRAJECTORY_TASK_NAME
from dimos.control.teleop_coordinator import TeleopControlCoordinator
from dimos.core.coordination.blueprints import autoconnect
from dimos.robot.manipulators.common.blueprints import planner
from dimos.robot.manipulators.dual_openyam.config import (
DUAL_OPENYAM_ARM_JOINTS,
dual_openyam_hardware,
dual_openyam_model_config,
)
from dimos.robot.manipulators.dual_openyam.joints import (
DUAL_OPENYAM_ARM_JOINTS,
)
from dimos.robot.manipulators.dual_openyam.model import DUAL_OPENYAM_MODEL


def dual_openyam_trajectory_task(*, priority: int = 20) -> TaskConfig:
Expand Down Expand Up @@ -55,6 +60,28 @@ def _setup_from_config(self) -> None:
right_can_port=self.config.right_can_port,
)
]
# Resolve assets at startup, using the same bounds as the planning model.
component = self.config.hardware[0]
assert component.limits is not None
lower = list(component.limits.position_lower)
upper = list(component.limits.position_upper)
model = DUAL_OPENYAM_MODEL.load()
for name in DUAL_OPENYAM_ARM_JOINTS:
joint = model.get_joint(name)
if (
joint is None
or joint.lower is None
or joint.upper is None
or not math.isfinite(joint.lower)
or not math.isfinite(joint.upper)
or joint.lower >= joint.upper
):
raise ValueError(f"Dual OpenYAM model has invalid position limits for {name!r}")
index = component.joints.index(name)
lower[index] = joint.lower
upper[index] = joint.upper
component.limits.position_lower = lower
component.limits.position_upper = upper
super()._setup_from_config()


Expand Down
90 changes: 51 additions & 39 deletions dimos/robot/manipulators/dual_openyam/blueprints/teleop.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,24 @@
"""Coupled WebXR teleoperation for the complete Dual OpenYAM entity."""

from dimos.control.coordinator import TaskConfig
from dimos.core.coordination.blueprints import autoconnect
from dimos.core.coordination.blueprints import Blueprint, autoconnect
from dimos.manipulation.manipulation_module import ManipulationModule
from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig
from dimos.manipulation.visualization.config import ManipulationVisualizationConfig
from dimos.manipulation.visualization.viser.config import ViserVisualizationConfig
from dimos.robot.manipulators.common.blueprints import teleop_ik_task
from dimos.robot.manipulators.dual_openyam.blueprints.basic import (
DualOpenYamCoordinator,
dual_openyam_trajectory_task,
)
from dimos.robot.manipulators.dual_openyam.config import (
DUAL_OPENYAM_ARM_JOINTS,
DUAL_OPENYAM_GRIPPER_JOINTS,
dual_openyam_hardware,
dual_openyam_model_config,
)
from dimos.robot.manipulators.dual_openyam.joints import (
DUAL_OPENYAM_ARM_JOINTS,
DUAL_OPENYAM_GRIPPER_JOINTS,
)
from dimos.robot.manipulators.dual_openyam.teleop_ik import (
DualOpenYamPinkPoseTargetSolver,
)
Expand Down Expand Up @@ -73,39 +77,47 @@
},
)

teleop_webxr_dual_openyam = autoconnect(
ArmTeleopModule.blueprint(),
DualOpenYamCoordinator.blueprint(
instance_name="ControlCoordinator",
tasks=[
_dual_openyam_webxr_task,
TaskConfig(
name="left_arm_gripper",
type="gripper",
joint_names=[DUAL_OPENYAM_GRIPPER_JOINTS[0]],
priority=20,
stream_bind={"gripper_command": "left_gripper_command"},
),
TaskConfig(
name="right_arm_gripper",
type="gripper",
joint_names=[DUAL_OPENYAM_GRIPPER_JOINTS[1]],
priority=20,
stream_bind={"gripper_command": "right_gripper_command"},
),
dual_openyam_trajectory_task(priority=20),
],
),
ManipulationModule.blueprint(
model=_dual_openyam_webxr_model,
kinematics=_dual_openyam_webxr_pink,
visualization={"backend": "viser"},
),
).remappings(
[
(ArmTeleopModule, "left_controller_output", "left_cartesian_command"),
(ArmTeleopModule, "left_gripper_command", "left_gripper_command"),
(ArmTeleopModule, "right_controller_output", "right_cartesian_command"),
(ArmTeleopModule, "right_gripper_command", "right_gripper_command"),
]
)

def build_dual_openyam_webxr(
*, visualization: ManipulationVisualizationConfig = ViserVisualizationConfig()
) -> Blueprint:
"""Compose dual-arm teleop with deployment-specific visualization."""
return autoconnect(
ArmTeleopModule.blueprint(),
DualOpenYamCoordinator.blueprint(
instance_name="ControlCoordinator",
tasks=[
_dual_openyam_webxr_task,
TaskConfig(
name="left_arm_gripper",
type="gripper",
joint_names=[DUAL_OPENYAM_GRIPPER_JOINTS[0]],
priority=20,
stream_bind={"gripper_command": "left_gripper_command"},
),
TaskConfig(
name="right_arm_gripper",
type="gripper",
joint_names=[DUAL_OPENYAM_GRIPPER_JOINTS[1]],
priority=20,
stream_bind={"gripper_command": "right_gripper_command"},
),
dual_openyam_trajectory_task(priority=20),
],
),
ManipulationModule.blueprint(
model=_dual_openyam_webxr_model,
kinematics=_dual_openyam_webxr_pink,
visualization=visualization,
),
).remappings(
[
(ArmTeleopModule, "left_controller_output", "left_cartesian_command"),
(ArmTeleopModule, "left_gripper_command", "left_gripper_command"),
(ArmTeleopModule, "right_controller_output", "right_cartesian_command"),
(ArmTeleopModule, "right_gripper_command", "right_gripper_command"),
]
)


teleop_webxr_dual_openyam = autoconnect(build_dual_openyam_webxr())
58 changes: 58 additions & 0 deletions dimos/robot/manipulators/dual_openyam/blueprints/test_basic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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 pathlib import Path

import pytest

from dimos.control.coordinator import ControlCoordinator
from dimos.control.hardware_interface import ConnectedWholeBody
from dimos.hardware.whole_body.mock.adapter import MockWholeBodyAdapter
from dimos.robot.assets.model import LoadedRobotModel, RobotModel
from dimos.robot.manipulators.dual_openyam.blueprints.basic import DualOpenYamCoordinator
from dimos.robot.manipulators.dual_openyam.joints import DUAL_OPENYAM_ARM_JOINTS


@pytest.mark.parametrize("ports", [{}, {"left_can_port": "left", "right_can_port": "right"}])
def test_setup_supplies_model_limits_to_hardware(mocker, ports):
names = DUAL_OPENYAM_ARM_JOINTS
# Different bounds and reverse document order catch accidental positional mapping.
bounds = {name: (-float(i + 1), float(i + 2)) for i, name in enumerate(names)}
xml = (
'<robot name="test"><link name="base"/>'
+ "".join(
f'<link name="{name}_link"/><joint name="{name}" type="revolute">'
f'<parent link="base"/><child link="{name}_link"/>'
f'<limit lower="{bounds[name][0]}" upper="{bounds[name][1]}"/></joint>'
for name in reversed(names)
)
+ "</robot>"
)
mocker.patch.object(
RobotModel, "load", return_value=LoadedRobotModel(xml, Path("model.urdf"), {})
)
mocker.patch.object(ControlCoordinator, "_setup_from_config")
coordinator = DualOpenYamCoordinator(**ports)
try:
coordinator._setup_from_config()
component = coordinator.config.hardware[0]
adapter = MockWholeBodyAdapter(dof=len(component.joints))
hardware = ConnectedWholeBody(adapter, component)
limits = hardware.get_limits()
assert limits is not None
assert limits.position_lower == [*[bounds[name][0] for name in names], 0.0, 0.0]
assert limits.position_upper == [*[bounds[name][1] for name in names], 1.0, 1.0]
finally:
coordinator.stop()
Loading
Loading