Skip to content

Update atom action - #432

Open
matafela wants to merge 18 commits into
mainfrom
cj/update-atom-action
Open

Update atom action#432
matafela wants to merge 18 commits into
mainfrom
cj/update-atom-action

Conversation

@matafela

@matafela matafela commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Description

TODO:

  • Fix dual arm cannot use ur solver
  • Coordinate pickment using auto-generated grasp pose.
  • Hand over atom action
  • Assemble affordance
  • Grasp generator add pose filter for dual arm

Type of change

  • Bug fix
  • Enhancement (non-breaking change which improves an existing functionality)

Checklist

  • I have run the black . command to format the code base.
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works
  • Dependencies have been updated, if applicable.

Copilot AI review requested due to automatic review settings July 28, 2026 07:31
@matafela
matafela marked this pull request as draft July 28, 2026 07:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the coordinated pickment workflow to use affordance-driven (auto-generated) antipodal grasp poses, and refactors atomic-action plumbing to accept a simpler grasp target.

Changes:

  • Switched the coordinated pickment tutorial to use create_antipodal_semantics() and run the action via GraspTarget.
  • Added dual-arm grasp pose generation plumbing (Affordance.get_dual_arm_valid_grasp_poses and AntipodalGenerator.get_dual_arm_valid_grasp_poses) and refactored the generator’s filtering path.
  • Refactored PickUp trajectory assembly and rewired CoordinatedPickment to plan grasp/lift trajectories from affordance-sampled grasps.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
scripts/tutorials/atomic_action/coordinated_pickment.py Tutorial now uses create_antipodal_semantics() + GraspTarget for coordinated pickment planning.
embodichain/toolkits/graspkit/pg_grasp/antipodal_generator.py Adds dual-arm antipodal grasp selection and refactors valid-grasp filtering.
embodichain/lab/sim/sim_manager.py Minor whitespace cleanup during sim manager initialization.
embodichain/lab/sim/atomic_actions/primitives/pick_up.py Refactors trajectory construction into a helper; keeps behavior but reorganizes execution.
embodichain/lab/sim/atomic_actions/primitives/coordinated_pickment.py Changes TargetType and replumbs coordinated pickment to use affordance-driven dual-arm grasps and new planning helpers.
embodichain/lab/sim/atomic_actions/affordance.py Adds get_dual_arm_valid_grasp_poses() wrapper around the antipodal generator.
embodichain/lab/gym/utils/gym_utils.py Changes --num_envs default handling in CLI argument setup.
Comments suppressed due to low confidence (2)

embodichain/lab/sim/atomic_actions/primitives/coordinated_pickment.py:544

  • The padding loop also assumes grasp_poses_result[i] is a dict and does not filter out per-side failures (is_success=False). This can lead to shape errors when failed results carry placeholder poses/costs, or to selecting invalid grasps.
        for i in range(n_envs):
            left_result = grasp_poses_result[i]["left"]
            right_result = grasp_poses_result[i]["right"]
            if left_result is not None:

embodichain/toolkits/graspkit/pg_grasp/antipodal_generator.py:801

  • The collision-check failure path (immediately after this _collision_checker.query(...) call) returns placeholder values with inconsistent types/shapes ((4,4) pose, float open-length, cost=0). Downstream code assumes batched tensors and may treat the failure as a valid best-grasp due to the zero cost.
        is_colliding, max_penetration = self._collision_checker.query(
            object_pose,
            valid_grasp_poses,

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 788 to 792
"--num_envs",
help="The number of environments to run in parallel. "
"If not given, falls back to the gym config's `num_envs` (default 1).",
default=None,
default=1,
type=int,
Comment on lines +152 to +159
for i, obj_pose in enumerate(obj_poses):
result = self._generator.get_dual_arm_valid_grasp_poses(
object_pose=obj_pose,
approach_direction=approach_direction,
left_to_right_arm_direction=left_to_right_arm_direction,
middle_empty_ratio=middle_empty_ratio,
)
results.append(result)
Comment on lines 752 to 756
return (
False,
torch.eye(4, device=self.device),
0.0,
torch.zeros(1, device=self.device),
Comment on lines 390 to 394
class CoordinatedPickment(AtomicAction):
"""Pick and move a single object pinched by two hands."""

TargetType: ClassVar[type] = CoordinatedPickmentTarget
TargetType: ClassVar[type] = GraspTarget

Comment on lines +668 to +672
def execute(self, target: GraspTarget, state: WorldState) -> ActionResult:
left_start_qpos, right_start_qpos = self._resolve_dual_arm_start(state)
left_grasp_xpos, right_grasp_xpos = self._resolve_grasp_pose(
target.semantics, left_start_qpos, right_start_qpos
)
Comment on lines +504 to +511
for i in range(n_envs):
left_result = grasp_poses_result[i]["left"]
right_result = grasp_poses_result[i]["right"]
if left_result is None or right_result is None:
logger.log_warning(
f"Failed to find valid dual-arm grasp poses for {i}-th enviroment."
)
continue
Comment on lines +704 to +709
full_trajectory[:, :, :] = last_qpos.unsqueeze(1)

hold_trajectory = torch.empty(
(self.n_envs, 0, self.robot_dof), dtype=torch.float32, device=self.device
# pading trajectory end to match the max length
full_trajectory[:, :n_left_waypoints, self.left_arm_joint_ids] = left_arm_traj
full_trajectory[:, :n_left_waypoints, self.left_hand_joint_ids] = left_hand_traj
full_trajectory[:, :n_right_waypoints, self.right_arm_joint_ids] = (
Comment on lines 715 to 722
return ActionResult(
success=success_mask,
trajectory=full,
success=is_success,
trajectory=full_trajectory,
next_state=WorldState(
last_qpos=full[:, -1, :].clone(),
last_qpos=full_trajectory[:, -1, :].clone(),
held_object=None,
coordinated_held_object=coordinated_held_object,
coordinated_held_object=state.coordinated_held_object,
),
Comment thread scripts/tutorials/atomic_action/coordinated_pickment.py Outdated
@matafela
matafela marked this pull request as ready for review July 30, 2026 08:45
@matafela
matafela requested review from Copilot and yuecideng July 30, 2026 08:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (5)

embodichain/lab/sim/atomic_actions/primitives/coordinated_pickment.py:475

  • _resolve_grasp_pose() dereferences semantics.entity unconditionally, which will raise an AttributeError if the provided GraspTarget semantics has no entity. Other actions validate semantics.entity and emit a clear ValueError; CoordinatedPickment should do the same.
        obj_poses = semantics.entity.get_local_pose(to_matrix=True)

embodichain/lab/sim/atomic_actions/affordance.py:162

  • In get_dual_arm_valid_grasp_poses(), left_to_right_arm_direction is passed to the generator without indexing per-environment. Callers (e.g., CoordinatedPickment) provide a (n_envs, 3) tensor, but AntipodalGenerator.get_dual_arm_valid_grasp_poses expects a single (3,) direction vector; passing the batched tensor will break (e.g., .repeat(n_vert, 1) shape mismatch). Slice the direction per env (and move it to the generator device) inside the loop.
        for i, obj_pose in enumerate(obj_poses):
            result = self._generator.get_dual_arm_valid_grasp_poses(
                object_pose=obj_pose,
                approach_direction=approach_direction,
                left_to_right_arm_direction=left_to_right_arm_direction,

embodichain/lab/sim/atomic_actions/primitives/hand_over.py:212

  • HandOver.execute() assigns final_object_pose = self.final_object_pose and then mutates final_object_pose[:, :3, :3], which also mutates the cached tensor on the action instance. This makes subsequent executions depend on the previous object's rotation. Also, semantics.entity is dereferenced without validation.
        middle_object_pose = self.middle_object_pose.clone()
        final_object_pose = self.final_object_pose
        # force object pose to have the same rotation as the current object pose, so that the handover is feasible.
        current_object_pose = target.semantics.entity.get_local_pose(to_matrix=True)
        middle_object_pose[:, :3, :3] = current_object_pose[:, :3, :3]
        final_object_pose[:, :3, :3] = current_object_pose[:, :3, :3]

embodichain/lab/gym/utils/gym_utils.py:793

  • --num_envs help text says that when not provided it falls back to the gym config's num_envs, but default=1 makes it always provided (so downstream can't distinguish 'unset' vs explicit 1). Either revert the default back to None or update the help + downstream fallback logic accordingly.
        "--num_envs",
        help="The number of environments to run in parallel. "
        "If not given, falls back to the gym config's `num_envs` (default 1).",
        default=1,
        type=int,
    )

embodichain/lab/sim/atomic_actions/primitives/pick_up.py:145

  • _select_feasible_grasp_variants() computes the pre-grasp offset along each grasp pose's local Z axis (pose[:3,2]), but _get_full_pickup_trajectory() offsets along the fixed cfg.approach_direction. Since apply_local_offset adds a world-frame translation, these can diverge when the grasp pose orientation isn't aligned with approach_direction, causing execution-time IK failures for grasps that were deemed feasible during selection. Use the grasp pose's Z axis here for consistency.
        pre_grasp_xpos = self.builder.apply_local_offset(
            grasp_xpos, -self.approach_direction * self.cfg.pre_grasp_distance
        )

Comment on lines 56 to 60
self.d6 = 0.0819
self.a2 = -0.24365
self.a3 = -0.21325
self.urdf_path = get_data_path("UniversalRobots/UR3/UR3.urdf")
elif self.ur_type == "ur3e":
self.d1 = 0.15185
Copilot AI review requested due to automatic review settings July 30, 2026 14:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

yuecideng and others added 2 commits July 31, 2026 06:05
Move action-specific target classes beside their primitives, add a shared object target base, and track held objects by control part. Update tutorials, benchmarks, tests, documentation, and agent context for the new contracts.
Copilot AI review requested due to automatic review settings July 31, 2026 07:58
@matafela
matafela changed the base branch from main to refactor/atomic-action-target-contracts July 31, 2026 08:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

embodichain/lab/sim/atomic_actions/primitives/hand_over.py:212

  • execute() aliases self.final_object_pose and then mutates it in-place when forcing the rotation. That permanently changes the cached pose for future calls. Also, target.semantics.entity is optional and is dereferenced without a guard, which will raise an AttributeError if the caller supplies semantics without an entity.
        final_object_pose = self.final_object_pose
        # force object pose to have the same rotation as the current object pose, so that the handover is feasible.
        current_object_pose = target.semantics.entity.get_local_pose(to_matrix=True)
        middle_object_pose[:, :3, :3] = current_object_pose[:, :3, :3]
        final_object_pose[:, :3, :3] = current_object_pose[:, :3, :3]

embodichain/lab/sim/atomic_actions/affordance.py:156

  • left_to_right_arm_direction is forwarded to the grasp generator without being moved to the generator device/dtype. When the generator runs on CUDA this will raise a device-mismatch error inside get_dual_arm_valid_grasp_poses during tensor math.
                left_to_right_arm_direction=left_to_right_arm_direction,

Comment on lines 611 to 615
object_pose: torch.Tensor,
approach_direction: torch.Tensor,
object_part: str = "center",
visualize_collision: bool = False,
):
Base automatically changed from refactor/atomic-action-target-contracts to main July 31, 2026 08:25
Copilot AI review requested due to automatic review settings August 1, 2026 18:36
matafela added 2 commits August 2, 2026 02:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (7)

embodichain/lab/sim/solvers/ur_solver.py:36

  • URSolver inherits BaseSolver, which always builds a pk_serial_chain from cfg.urdf_path. With URSolverCfg.urdf_path no longer set, initializing URSolver will raise ValueError("Either urdf_pathorchain must be provided.") from create_pk_serial_chain. Restore a default URDF path on the cfg so the base class can build FK/limits safely.
class URSolverCfg(SolverCfg):
    class_type: str = "URSolver"
    ur_type: str = "ur10"
    end_link_name: str = "ee_link"
    root_link_name: str = "base_link"

embodichain/toolkits/graspkit/pg_grasp/antipodal_generator.py:613

  • get_valid_grasp_poses inserted object_part before visualize_collision, which breaks any positional 3rd-argument callers (e.g. internal get_grasp_pose(..., visualize_collision) will silently pass a bool as object_part and ignore visualize_collision). To preserve backward compatibility, keep visualize_collision as the 3rd positional argument and make object_part keyword-only.
    def get_valid_grasp_poses(
        self,
        object_pose: torch.Tensor,
        approach_direction: torch.Tensor,
        object_part: str = "center",

embodichain/lab/sim/solvers/ur_solver.py:58

  • When ur_type is not the default, BaseSolver still builds FK/joint limits from cfg.urdf_path. After removing the per-ur_type URDF overrides, the solver will use the UR10 model even when ur_type is ur3/ur5/..., which can lead to incorrect FK/limits. Consider restoring a ur_type→URDF mapping in __post_init__ so urdf_path stays consistent with the DH parameters.
            self.d6 = 0.0819
            self.a2 = -0.24365
            self.a3 = -0.21325

embodichain/lab/sim/atomic_actions/primitives/hand_over.py:212

  • execute() uses target.semantics.entity.get_local_pose(...) without validating semantics.entity is set, and it mutates self.final_object_pose in-place by aliasing it as final_object_pose. This can produce a hard-to-diagnose AttributeError when the entity is missing and can also leak the modified rotation into subsequent calls. Prefer validating semantics.entity and cloning self.final_object_pose before modifying it.
        semantics = target.semantics
        transfer_object_to_eef = self._resolve_transfer_object_to_eef(state)
        middle_object_pose = self.middle_object_pose.clone()
        final_object_pose = self.final_object_pose
        # force object pose to have the same rotation as the current object pose, so that the handover is feasible.

embodichain/lab/sim/atomic_actions/primitives/place.py:299

  • The new AssembleTarget / _resolve_assemble_place_xpos() path is not covered by unit tests. Existing tests exercise Place with PlaceTarget, but nothing asserts the derived pose base_pose @ assemble_to_base_pose @ object_to_eef or the error paths (missing held object / missing base entity). Adding a focused mocked unit test in tests/sim/atomic_actions/test_actions.py would help prevent regressions.
    def _resolve_assemble_place_xpos(
        self, target: AssembleTarget, state: WorldState
    ) -> torch.Tensor:
        """Derive the place EEF pose from an assembly affordance.

embodichain/lab/sim/atomic_actions/primitives/hand_over.py:117

  • HandOver is a new atomic action with non-trivial planning/state-transfer behavior (reading a held object from the transfer arm and writing a new one for the receiving arm), but there are no unit tests validating its success/failure paths or that WorldState.held_objects is updated correctly. Consider adding a mocked unit test (similar to other action tests) to cover at least: missing transfer-held-object error, successful handover updates, and trajectory shape invariants.
class HandOver(AtomicAction[GraspTarget]):
    """Hand an object from one arm to the other.

    The transferring arm (already holding the object) moves it to a middle
    handover pose, the receiving arm approaches and grasps a different part of

embodichain/lab/sim/atomic_actions/primitives/pick_up.py:165

  • _select_feasible_grasp_variants backs off along the grasp pose's Z axis, but _get_full_pickup_trajectory backs off along the configured approach_direction. Since TrajectoryBuilder.apply_local_offset applies a world-frame translation, these can differ and lead to selecting a grasp variant that is feasible for one pre-grasp definition but executing a different pre-grasp during planning. Consider using the same (grasp-Z) convention in _get_full_pickup_trajectory for consistency.
        pre_grasp_xpos = self.builder.apply_local_offset(
            grasp_xpos, -self.approach_direction * self.cfg.pre_grasp_distance
        )

Copilot AI review requested due to automatic review settings August 1, 2026 18:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 19 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (7)

embodichain/lab/sim/atomic_actions/primitives/pick_up.py:365

  • _select_feasible_grasp_variants() computes the pre-grasp offset along the grasp pose's z-axis, but the actual planned trajectory in _get_full_pickup_trajectory() (and the PickUpCfg docstring) uses the world-frame approach_direction. This mismatch can cause the feasibility screening to accept a grasp variant that later fails during real trajectory planning.

Use the same approach_direction-based offset here (or update both code paths consistently).

        pre_grasp_variants = grasp_variants.clone()
        pre_grasp_z = pre_grasp_variants[..., :3, 2]
        pre_grasp_variants[..., :3, 3] -= pre_grasp_z * self.cfg.pre_grasp_distance

embodichain/lab/sim/atomic_actions/primitives/hand_over.py:212

  • final_object_pose is assigned as a reference to self.final_object_pose and then modified in-place. That mutates the cached tensor stored on the action instance, so subsequent execute() calls can observe the modified pose unexpectedly.

Also, semantics.entity is used without validation; if the target semantics has no entity this will raise an AttributeError instead of a controlled ValueError.

        middle_object_pose = self.middle_object_pose.clone()
        final_object_pose = self.final_object_pose
        # force object pose to have the same rotation as the current object pose, so that the handover is feasible.
        current_object_pose = target.semantics.entity.get_local_pose(to_matrix=True)
        middle_object_pose[:, :3, :3] = current_object_pose[:, :3, :3]

embodichain/lab/sim/atomic_actions/primitives/hand_over.py:231

  • HandOverCfg.pre_grasp_distance is documented as an offset along the (negative) receive_approach_direction, but execute() currently offsets along the grasp pose's local z-axis. This makes the behavior differ from the config contract and can produce unintuitive approach motions when the grasp frame z is not aligned with receive_approach_direction.
        receive_grasp_z = receive_grasp_xpos[..., :3, 2]
        receive_pre_grasp_eef = self.builder.apply_local_offset(
            receive_grasp_xpos,
            -receive_grasp_z * self.cfg.pre_grasp_distance,
        )

embodichain/toolkits/graspkit/pg_grasp/antipodal_generator.py:688

  • get_dual_arm_valid_grasp_poses() projects points onto left_to_right_arm_direction without normalizing it. If callers pass a non-unit vector, the min/max projections and thresholds scale with its magnitude and the masks become inconsistent across call sites.

Normalize (and validate non-zero) left_to_right_arm_direction before computing projections.

        # project mesh_vert_transformed to left_to_right_arm_direction and get the min and max value
        n_vert = mesh_vert_transformed.shape[0]
        projected = (
            mesh_vert_transformed * left_to_right_arm_direction.repeat(n_vert, 1)
        ).sum(dim=-1)

tests/sim/objects/test_dual_arm.py:139

  • This test comment no longer matches the behavior: URSolverCfg no longer pins urdf_path in post_init, so for dual-arm robots Robot.init_solver injects the combined URDF path and link names remain prefixed.

Update the comment to reflect why prefixed link names are expected here.

        # URSolverCfg pins urdf_path to the single-arm URDF in __post_init__, so
        # the engine keeps link names UNPREFIXED to match that URDF (arm-local).

embodichain/lab/sim/atomic_actions/primitives/place.py:77

  • Place now supports AssembleTarget, but there are no behavioral tests exercising the AssembleTarget pathway (only a TargetType membership assertion). This leaves base-pose tracking, held-object transform usage, and error cases (missing held object / missing base_object_entity) unverified.

Add unit tests that run Place.execute() with an AssembleTarget and validate the resolved EEF release pose and error handling.

@dataclass(frozen=True, slots=True, eq=False)
class AssembleTarget(ActionTarget):
    """Place a held assemble object onto a base object at a relative pose.

    The base object pose is read at planning time from

embodichain/lab/sim/atomic_actions/primitives/hand_over.py:117

  • HandOver is a new dual-arm primitive with non-trivial state semantics (reads held-object state from transfer arm; writes it to receive arm; plans multiple segments). There are currently no unit tests covering success paths or failure modes (missing held object, missing semantics.entity, insufficient waypoints).

Add tests to validate held-object handoff bookkeeping and that per-env failures correctly return a failure ActionResult without mutating state.

class HandOver(AtomicAction[GraspTarget]):
    """Hand an object from one arm to the other.

    The transferring arm (already holding the object) moves it to a middle
    handover pose, the receiving arm approaches and grasps a different part of

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants