Skip to content
Closed
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
30 changes: 30 additions & 0 deletions docs/source/overview/sim/atomic_actions/robot_skill_profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ from embodichain.lab.sim.atomic_actions import (
FORWARD_KINEMATICS_CAPABILITY,
GRASP_CAPABILITY,
ControlPartCommandProfile,
HandOverOptions,
MotionPolicy,
PickUpOptions,
PlaceOptions,
)
from embodichain.lab.sim.skills import (
COMPOSITE_EFFECT_MONITOR_ID,
Expand Down Expand Up @@ -140,6 +143,11 @@ profile = RobotSkillProfile(
presets={
"default": SkillPolicyPreset(
preset_id="default",
action_option_templates={
"pick": PickUpOptions(),
"place": PlaceOptions(),
"hand_over": HandOverOptions(),
},
motion_policy=MotionPolicy(strategy="ik_interp"),
effect_monitors={
semantic_id: EffectMonitorRef(
Expand Down Expand Up @@ -195,6 +203,28 @@ A linked call receives an effective immutable preset snapshot with
Other presets, and scenes without dynamic collision entities, retain their
configured collision mode.

## Configure semantic action behavior with the preset

`SkillPolicyPreset.action_option_templates` is the required, typed action-
behavior table for semantic calls that can select the preset. Each key is the
exact semantic call ID (`pick`, `place`, `hand_over`, or
`operate_articulation`), and each value must be the target action's exact frozen
`ActionOptions` dataclass. Static linking rejects a missing entry, an unknown
call ID, or an options value of the wrong exact type before simulation starts.

The preset owns independent snapshots of each template. Pick and HandOver
grounding only replace their compiler-owned dynamic target fields; distances,
directions, waypoint counts, and other reusable behavior remain configuration.
A registered semantic lowerer may build a goal but cannot return replacement
options. This keeps task extensions from silently moving action parameters back
into Python code.

Pick's `downstream_object_target_poses` and HandOver's
`middle_object_pose`/`final_object_pose` are reserved for the semantic compiler
and must remain empty in a template. Planner choice, sample count, tracking,
recovery, runner timing, and effect monitors stay in their dedicated preset
fields rather than `ActionOptions`.

## Select semantic effect monitors with the preset

A {class}`SkillPolicyPreset` owns one coherent runtime choice: planning and
Expand Down
3 changes: 2 additions & 1 deletion embodichain/lab/gym/envs/expert_program/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
from .simulation import SimulationRobotSkillProfileBinding, SimulationSceneBinding
from .simulation_policies import default_simulation_settle_presets

_CATALOG_FINGERPRINT_SCHEMA_VERSION = 1
_CATALOG_FINGERPRINT_SCHEMA_VERSION = 2
_POST_POLICY_KINDS = frozenset({"wait_stable"})
_VALIDATOR_KINDS = frozenset({"object_near_target"})

Expand Down Expand Up @@ -810,6 +810,7 @@ def _profile_with_control_dt(
recovery_policy=preset.recovery_policy,
runner_cfg=preset.runner_cfg,
effect_monitors=preset.effect_monitors,
action_option_templates=preset.action_option_templates,
)
for preset_id, preset in profile.presets.items()
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,7 @@ def create_robot_skill_profile(self) -> RobotSkillProfile:
recovery_policy=preset.recovery_policy,
runner_cfg=preset.runner_cfg,
effect_monitors=preset.effect_monitors,
action_option_templates=preset.action_option_templates,
)
for preset_id, preset in profile.presets.items()
}
Expand Down
82 changes: 66 additions & 16 deletions embodichain/lab/sim/skills/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@

from abc import ABC, abstractmethod
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from copy import deepcopy
from dataclasses import dataclass, field, replace
from types import MappingProxyType
from typing import ClassVar
from typing import ClassVar, TypeVar
from uuid import uuid4

import torch
Expand All @@ -40,6 +41,7 @@
PlaceGoal,
PlaceOptions,
OperateArticulationGoal,
OperateArticulationOptions,
PlanningContext,
PoseGoalValue,
SceneArticulationOperationGeometry,
Expand Down Expand Up @@ -97,6 +99,8 @@
SceneObjectRef,
)

OptionT = TypeVar("OptionT", bound=ActionOptions)


def _validate_identifier(value: str, *, field_name: str) -> str:
"""Return one exact non-empty identifier."""
Expand Down Expand Up @@ -421,8 +425,15 @@ def lower(
*,
context: PlanningContext,
bound: BoundSemanticCall,
option_template: ActionOptions,
) -> SemanticLowering:
"""Lower one registered value to goal/options without changing policy."""
"""Lower a registered value with one owned typed option template.

The lowerer must return :class:`SemanticLowering` with
``skill_options=None``. The supplied template is an owned read-only
input for goal grounding; the selected policy preset remains the sole
owner of action options.
"""


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -1078,6 +1089,10 @@ def ground(
raise AssertionError(f"Unsupported analyzed call {type(call).__name__}.")

bound = analyzed.bound
if lowering.skill_options is None:
raise AssertionError(
"Semantic lowering must resolve a non-None action-options value."
)
invocation = ActionInvocation(
skill_id=bound.linked.descriptor.skill_id,
goal=lowering.goal,
Expand Down Expand Up @@ -1323,13 +1338,15 @@ def _lower_pick(
call.object,
affordance=grasp_ref,
)
option_template = self._action_option_template(analyzed, PickUpOptions)
return SemanticLowering(
goal=GraspGoal(semantics=semantics),
skill_options=PickUpOptions(
skill_options=replace(
option_template,
downstream_object_target_poses=tuple(
self._ground_object_target(target, context)
for target in analyzed.downstream_object_targets
)
),
),
)

Expand Down Expand Up @@ -1367,7 +1384,10 @@ def _lower_place(
xpos = self._compose_object_to_eef(
object_target, held.object_to_eef, context
)
return SemanticLowering(goal=PlaceGoal(xpos=xpos), skill_options=PlaceOptions())
return SemanticLowering(
goal=PlaceGoal(xpos=xpos),
skill_options=self._action_option_template(analyzed, PlaceOptions),
)

def _lower_handover(
self,
Expand Down Expand Up @@ -1411,9 +1431,11 @@ def _lower_handover(
else targets.final
)
final = self._ground_object_target(final_target, context)
option_template = self._action_option_template(analyzed, HandOverOptions)
return SemanticLowering(
goal=GraspGoal(semantics=semantics),
skill_options=HandOverOptions(
skill_options=replace(
option_template,
middle_object_pose=middle,
final_object_pose=final,
),
Expand Down Expand Up @@ -1546,7 +1568,11 @@ def _lower_operate_articulation(
source_position=source_position,
target_position=target,
target_displacement=displacement,
)
),
skill_options=self._action_option_template(
analyzed,
OperateArticulationOptions,
),
)

def _lower_registered(
Expand All @@ -1567,19 +1593,24 @@ def _lower_registered(
f"No lowerer is installed for {call.call_id!r}.",
tuple(self._registered_lowerers),
)
descriptor = analyzed.bound.linked.descriptor
target = descriptor.target_descriptor
assert target is not None
option_template = self._action_option_template(
analyzed,
target.options_type,
)
lowering = lowerer.lower(
call,
context=context,
bound=analyzed.bound,
option_template=deepcopy(option_template),
)
if type(lowering) is not SemanticLowering:
raise TypeError(
"RegisteredSemanticLowerer.lower() must return exactly "
"SemanticLowering."
)
descriptor = analyzed.bound.linked.descriptor
target = descriptor.target_descriptor
assert target is not None
expected_goal_types = (
target.goal_type
if isinstance(target.goal_type, tuple)
Expand All @@ -1590,13 +1621,32 @@ def _lower_registered(
f"Lowerer {call.call_id!r} produced {type(lowering.goal).__name__}; "
f"target skill {target.skill_id!r} expects {target.goal_type!r}."
)
if lowering.skill_options is not None and (
type(lowering.skill_options) is not target.options_type
):
if lowering.skill_options is not None:
raise TypeError(
f"Lowerer {call.call_id!r} produced incompatible skill options."
f"Lowerer {call.call_id!r} must not return skill_options; "
"the selected policy preset owns action options."
)
return replace(lowering, skill_options=deepcopy(option_template))

@staticmethod
def _action_option_template(
analyzed: AnalyzedSemanticCall,
expected_type: type[OptionT],
) -> OptionT:
"""Return one owned exact template selected by semantic call ID."""
semantic_id = analyzed.call.semantic_id
try:
template = analyzed.bound.preset.action_option_template(semantic_id)
except KeyError as exc: # pragma: no cover - static linking owns this check
raise AssertionError(
f"Linked call {semantic_id!r} has no action-option template."
) from exc
if type(template) is not expected_type:
raise AssertionError(
f"Linked call {semantic_id!r} has {type(template).__name__}; "
f"expected exact {expected_type.__name__}."
)
return lowering
return template

def _ground_effect_spec(
self,
Expand Down
98 changes: 98 additions & 0 deletions embodichain/lab/sim/skills/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
DynamicCollisionMode,
DisjointResourceSlots,
DisjointSlotEndpoints,
HandOverOptions,
PickUpOptions,
SkillResourceSlot,
)

Expand Down Expand Up @@ -717,6 +719,81 @@ def __post_init__(self) -> None:
tuple(self.call_catalog.descriptors),
)
)
unknown_option_ids = sorted(
set(preset.action_option_templates).difference(known_semantic_ids)
)
if unknown_option_ids:
semantic_id = unknown_option_ids[0]
raise SemanticValidationError(
SemanticDiagnostic(
"unknown_action_option_call",
(
"integration",
"robot_profile",
"presets",
preset_id,
"action_option_templates",
semantic_id,
),
f"Action-option configuration references unknown semantic "
f"call {semantic_id!r}.",
tuple(self.call_catalog.descriptors),
)
)
for semantic_id, options in preset.action_option_templates.items():
descriptor = self.call_catalog.descriptors[semantic_id]
target = descriptor.target_descriptor
assert target is not None
option_path = (
"integration",
"robot_profile",
"presets",
preset_id,
"action_option_templates",
semantic_id,
)
if type(options) is not target.options_type:
raise SemanticValidationError(
SemanticDiagnostic(
"incompatible_action_option_template",
option_path,
f"Semantic call {semantic_id!r} targets options type "
f"{target.options_type.__name__}, not "
f"{type(options).__name__}.",
(target.options_type.__name__,),
)
)
if semantic_id == Pick.call_kind:
assert type(options) is PickUpOptions
if options.downstream_object_target_poses:
raise SemanticValidationError(
SemanticDiagnostic(
"reserved_action_option_field",
(*option_path, "downstream_object_target_poses"),
"Pick downstream targets are compiler-owned and "
"the template field must be empty.",
)
)
if semantic_id == HandOver.call_kind:
assert type(options) is HandOverOptions
if options.middle_object_pose is not None:
raise SemanticValidationError(
SemanticDiagnostic(
"reserved_action_option_field",
(*option_path, "middle_object_pose"),
"HandOver middle_object_pose is compiler-owned and "
"the template field must be None.",
)
)
if options.final_object_pose is not None:
raise SemanticValidationError(
SemanticDiagnostic(
"reserved_action_option_field",
(*option_path, "final_object_pose"),
"HandOver final_object_pose is compiler-owned and "
"the template field must be None.",
)
)
if self.runtime_preset is not None:
_validate_identifier(
self.runtime_preset,
Expand Down Expand Up @@ -856,6 +933,26 @@ def link_call(
descriptor,
path=(*path, "preset"),
)
preset = self.robot_profile.presets[preset_id]
if descriptor.call_id not in preset.action_option_templates:
option_path = (
"integration",
"robot_profile",
"presets",
preset_id,
"action_option_templates",
descriptor.call_id,
)
raise SemanticValidationError(
SemanticDiagnostic(
"missing_action_option_template",
option_path,
f"Policy preset {preset_id!r} has no action-option template "
f"for semantic call {descriptor.call_id!r} selected at "
f"{_render_path(path)}.",
tuple(preset.action_option_templates),
)
)
return LinkedSemanticCall(
call=normalized_call,
descriptor=descriptor,
Expand Down Expand Up @@ -1390,6 +1487,7 @@ def link_call(
recovery_policy=preset.recovery_policy,
runner_cfg=preset.runner_cfg,
effect_monitors=preset.effect_monitors,
action_option_templates=preset.action_option_templates,
)
return BoundSemanticCall._create(
linked=linked,
Expand Down
Loading
Loading