Source code for embodichain.lab.sim.atomic_actions.goals

# ----------------------------------------------------------------------------
# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
#
# 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.
# ----------------------------------------------------------------------------

"""Goal contracts shared by atomic actions."""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass, fields, is_dataclass
from typing import Any, TYPE_CHECKING

import torch

if TYPE_CHECKING:
    from .core import ObjectSemantics
    from .state import PlanningContext


[docs] @dataclass(frozen=True, slots=True, eq=False) class SceneEntityPose: """Late-bound pose derived from a versioned scene entity. The semantic request remains stable while each call to :meth:`AtomicAction.plan` resolves the latest scene pose. This is the bridge used by an execution session to replan moving goals. """ entity_id: str """Stable scene entity identifier.""" relative_pose: torch.Tensor | None = None """Optional transform applied as ``entity_pose @ relative_pose``.""" minimum_confidence: float = 0.0 """Minimum accepted perception confidence.""" def __post_init__(self) -> None: if not isinstance(self.entity_id, str) or not self.entity_id.strip(): raise ValueError("entity_id must be a non-empty string.") if self.relative_pose is not None: validate_pose_tensor( self.relative_pose, "relative_pose", allow_waypoints=False, ) object.__setattr__(self, "relative_pose", self.relative_pose.clone()) if not 0.0 <= self.minimum_confidence <= 1.0: raise ValueError("minimum_confidence must be in [0, 1].")
[docs] def snapshot(self) -> SceneEntityPose: """Return an independently owned late-bound pose value. Returns: Exact scene reference with an owned relative-pose tensor. """ return SceneEntityPose( self.entity_id, relative_pose=self.relative_pose, minimum_confidence=self.minimum_confidence, )
PoseGoalValue = torch.Tensor | SceneEntityPose """Explicit pose tensor or a pose resolved from the latest scene snapshot."""
[docs] def validate_pose_tensor( value: torch.Tensor, name: str, *, allow_waypoints: bool, ) -> None: """Validate the environment-independent part of a pose goal. Args: value: Pose tensor to validate. name: Field name used in validation errors. allow_waypoints: Whether a batched waypoint dimension is accepted. Raises: TypeError: If ``value`` is not a tensor. ValueError: If the tensor shape is not a supported pose shape. """ if not isinstance(value, torch.Tensor): raise TypeError(f"{name} must be a torch.Tensor, got {type(value).__name__}.") valid_dims = {2, 3, 4} if allow_waypoints else {2, 3} if value.dim() not in valid_dims or value.shape[-2:] != (4, 4): supported = "(4, 4), (num_envs, 4, 4)" if allow_waypoints: supported += ", or (num_envs, n_waypoint, 4, 4)" raise ValueError( f"{name} must have shape {supported}, got {tuple(value.shape)}." )
[docs] def validate_pose_goal( value: PoseGoalValue, name: str, *, allow_waypoints: bool, ) -> None: """Validate an explicit or late-bound pose goal.""" if isinstance(value, SceneEntityPose): return validate_pose_tensor(value, name, allow_waypoints=allow_waypoints)
[docs] def resolve_pose_goal( value: PoseGoalValue, context: PlanningContext, *, name: str, ) -> torch.Tensor: """Resolve a pose goal against a planning context. Args: value: Explicit tensor or scene-entity reference. context: Latest observed planning context. name: Field name used in validation errors. Returns: Explicit pose tensor. Scene references always return shape ``(B, 4, 4)``. """ if isinstance(value, torch.Tensor): return value try: entity = context.scene.entities[value.entity_id] except KeyError as exc: raise KeyError( f"{name} references unknown scene entity {value.entity_id!r}." ) from exc if entity.confidence < value.minimum_confidence: raise ValueError( f"Scene entity {value.entity_id!r} confidence {entity.confidence} is " f"below {value.minimum_confidence}." ) pose = entity.pose.to(device=context.robot.qpos.device, dtype=torch.float32) if pose.shape == (4, 4): pose = pose.unsqueeze(0).expand(context.batch_size, -1, -1) elif pose.shape != (context.batch_size, 4, 4): raise ValueError( f"Scene entity {value.entity_id!r} pose must match planning batch size." ) if value.relative_pose is None: return pose.clone() relative = value.relative_pose.to(device=pose.device, dtype=pose.dtype) if relative.shape == (4, 4): relative = relative.unsqueeze(0).expand(context.batch_size, -1, -1) elif relative.shape != (context.batch_size, 4, 4): raise ValueError(f"{name}.relative_pose must match planning batch size.") return torch.bmm(pose, relative)
def _resolve_object_pose( semantics: ObjectSemantics, context: PlanningContext, *, name: str = "object", ) -> torch.Tensor: """Resolve an object's pose from the current scene snapshot.""" from .core import ObjectSemantics if not isinstance(semantics, ObjectSemantics): raise TypeError("semantics must be an ObjectSemantics instance.") return resolve_pose_goal( SceneEntityPose(semantics.entity_id), context, name=name, )
[docs] def collect_scene_dependencies(value: Any) -> tuple[str, ...]: """Collect stable scene entity identifiers referenced by a goal value.""" from .core import ObjectSemantics found: set[str] = set() def visit(item: Any) -> None: if isinstance(item, SceneEntityPose): found.add(item.entity_id) elif isinstance(item, ObjectSemantics): return elif is_dataclass(item) and not isinstance(item, type): for data_field in fields(item): visit(getattr(item, data_field.name)) elif isinstance(item, Mapping): for key, nested in item.items(): visit(key) visit(nested) elif isinstance(item, Sequence) and not isinstance( item, (str, bytes, torch.Tensor) ): for nested in item: visit(nested) visit(value) return tuple(sorted(found))
[docs] @dataclass(frozen=True, slots=True, eq=False) class ObjectActionGoal: """Shared semantic-object goal contract for object-centric skills.""" semantics: ObjectSemantics """Semantic and geometric description of the object.""" def __post_init__(self) -> None: from .core import ObjectSemantics if not isinstance(self.semantics, ObjectSemantics): raise TypeError("semantics must be an ObjectSemantics instance.")
__all__ = [ "ObjectActionGoal", "PoseGoalValue", "SceneEntityPose", "collect_scene_dependencies", "resolve_pose_goal", "validate_pose_goal", "validate_pose_tensor", ]