diff --git a/.agents/skills/add-solver/SKILL.md b/.agents/skills/add-solver/SKILL.md
index eabad3e25..d6d2a1051 100644
--- a/.agents/skills/add-solver/SKILL.md
+++ b/.agents/skills/add-solver/SKILL.md
@@ -37,6 +37,10 @@ Plus two registration edits:
- Add the docs page to the toctree in
`docs/source/overview/sim/motion/solvers/index.rst`.
+Keep solver exports in the solver subpackage. The `motion` parent resolves
+subpackages lazily; do not add eager planner or workspace analyzer imports to
+the Robot initialization path.
+
## Steps
### 1. Gather Solver Requirements
diff --git a/.agents/skills/review-pr/references/review-matrix.md b/.agents/skills/review-pr/references/review-matrix.md
index 3627b79ae..5a69854a2 100644
--- a/.agents/skills/review-pr/references/review-matrix.md
+++ b/.agents/skills/review-pr/references/review-matrix.md
@@ -127,11 +127,14 @@ and the `$add-task-program` read-only deployment inspector.
## Atomic actions, motion planning, and IK
-**Paths:** `embodichain/lab/sim/atomic_actions/**`, `planners/**`,
-`solvers/**`, and grasp/workspace utilities that feed plans.
+**Paths:** `embodichain/lab/sim/atomic_actions/**`,
+`embodichain/lab/sim/motion/{planners,solvers,workspace,expansion}/**`,
+and grasp utilities that feed plans.
Check:
+- The `motion` parent and workspace analyzer exports retain lazy loading;
+ Robot initialization must not eagerly load planners or offline analyzers.
- Goal, options, affordance, requirement, binding, plan, command, effect, and
evidence types remain coherent across registration, planning, compilation,
execution, tracking, and verification.
diff --git a/AGENTS.md b/AGENTS.md
index 689fd82c7..363a83cbd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -29,6 +29,7 @@ bundles official tasks as the **`embodichain_tasks`** import package.
| Simulation world, objects, sensors, solvers, planning | `embodichain/lab/sim/` |
| Gym environments and manager functors | `embodichain/lab/gym/` |
| Task Program language, semantics, compiler, runtime and integrations | `embodichain/lab/task_program/` |
+| Fixed-scene trajectory host, rollout and persistence | `embodichain/lab/trajectory_generation/` |
| Browser visualization | `embodichain/lab/visualization/` |
| RL algorithms, policies, collectors and trainers | `embodichain/learning/rl/` |
| Real-device controllers / standalone tools | `embodichain/lab/devices/`, `embodichain/toolkits/` |
diff --git a/agent_context/MAP.yaml b/agent_context/MAP.yaml
index b670e0448..6342c2558 100644
--- a/agent_context/MAP.yaml
+++ b/agent_context/MAP.yaml
@@ -21,6 +21,12 @@ topics:
- embodichain/lab/sim/objects/articulation.py
- embodichain/lab/sim/objects/gizmo.py
- embodichain/lab/gym/envs/base_env.py
+ - embodichain/lab/sim/motion/solvers/__init__.py
+ - embodichain/lab/sim/motion/planners/__init__.py
+ - embodichain/lab/sim/motion/workspace/__init__.py
+ - embodichain/lab/sim/motion/expansion/__init__.py
+ - embodichain/lab/trajectory_generation/initial_state.py
+ - embodichain/lab/trajectory_generation/integrations/sim.py
watch_paths: [embodichain/lab/sim/sim_manager.py, embodichain/lab/sim/cfg.py, embodichain/lab/sim/objects/,
tests/sim/]
related_topics: [env-framework, robot-system, sensor-system, sim-visualization, ik-solvers, motion-planning,
@@ -28,9 +34,34 @@ topics:
status: active
- id: env-framework
title: Environment Framework
- aliases: [env framework, environment framework, task registration, list task, 环境框架, 任务环境]
- keywords: [BaseEnv, EmbodiedEnv, EnvCfg, register_env, list-task, run-env, target_control_frequency,
- sim_steps_per_control, step_dt, environment.component, embodiment.component, ControllerAction, EnvProfiler]
+ aliases:
+ - env framework
+ - environment framework
+ - task registration
+ - list task
+ - 环境框架
+ - 任务环境
+ - generation lease
+ - controlled episode preparation
+ - 受控初态准备
+ keywords:
+ - BaseEnv
+ - EmbodiedEnv
+ - EnvCfg
+ - register_env
+ - list-task
+ - run-env
+ - target_control_frequency
+ - sim_steps_per_control
+ - step_dt
+ - environment.component
+ - embodiment.component
+ - ControllerAction
+ - EnvProfiler
+ - acquire_generation_lease
+ - prepare_generation_episode
+ - generation_epoch
+ - FixedSceneHost
paths: [topics/env-framework/env-framework.md]
source_of_truth:
- embodichain/cli/main.py
@@ -43,6 +74,7 @@ topics:
- embodichain/lab/gym/envs/embodied_env.py
- embodichain/lab/gym/envs/demo.py
- embodichain/lab/gym/utils/profiler.py
+ - embodichain/lab/trajectory_generation/initial_state.py
watch_paths: [embodichain/lab/gym/envs/base_env.py, embodichain/lab/gym/envs/embodied_env.py, embodichain/lab/gym/envs/demo.py,
embodichain/lab/gym/utils/, embodichain/lab/gym/envs/task_program/, embodichain/cli/, embodichain/lab/scripts/run_env.py,
embodichain_tasks/, tests/gym/envs/]
@@ -83,20 +115,51 @@ topics:
- embodichain/lab/sim/motion/solvers/null_space_posture_task.py
- embodichain/lab/sim/utility/solver_utils.py
- embodichain/compute/kinematics/
+ - embodichain/lab/sim/cfg.py
+ - embodichain/lab/sim/motion/__init__.py
+ - embodichain/lab/sim/motion/solvers/srs_solver.py
+ - embodichain/lab/sim/motion/solvers/opw_solver.py
+ - embodichain/lab/sim/motion/solvers/pinocchio_solver.py
+ - embodichain/lab/sim/motion/solvers/differential_solver.py
watch_paths: [embodichain/lab/sim/motion/solvers/, embodichain/lab/sim/utility/solver_utils.py, embodichain/compute/kinematics/,
tests/sim/motion/solvers/, tests/compute/]
related_topics: [simulation-system, robot-system, motion-planning]
status: active
- id: robot-system
title: Robot System
- aliases: [robot system, robot config, 机器人配置, 机器人系统]
- keywords: [RobotCfg, control_parts, build_pk_serial_chain, DexforceW1Cfg, CobotMagicCfg, FrankaPandaCfg,
- URRobotCfg, DualArmRobotCfg, merge_robot_cfg, JointDrivePropertiesCfg]
+ aliases:
+ - robot system
+ - robot config
+ - 机器人配置
+ - 机器人系统
+ - robot workspace
+ - workspace analysis
+ - 机器人工作空间
+ keywords:
+ - RobotCfg
+ - control_parts
+ - build_pk_serial_chain
+ - DexforceW1Cfg
+ - CobotMagicCfg
+ - FrankaPandaCfg
+ - URRobotCfg
+ - DualArmRobotCfg
+ - merge_robot_cfg
+ - JointDrivePropertiesCfg
+ - workspace
+ - RobotWorkspaceCfg
+ - RobotWorkspace
+ - WorkspaceAnalyzer
+ - reachability
paths: [topics/robot-system/robot-system.md]
source_of_truth:
- embodichain/lab/sim/objects/robot.py
- embodichain/lab/sim/robots/
- embodichain/lab/sim/cfg.py
+ - embodichain/lab/sim/motion/workspace/__init__.py
+ - embodichain/lab/sim/motion/workspace/cfg.py
+ - embodichain/lab/sim/motion/workspace/runtime.py
+ - embodichain/lab/sim/motion/workspace/analyzer.py
watch_paths: [embodichain/lab/sim/robots/, embodichain/lab/sim/objects/robot.py, embodichain/lab/sim/cfg.py]
related_topics: [simulation-system, ik-solvers, motion-planning, sensor-system, sim-visualization, robot-workspace]
status: active
@@ -137,9 +200,26 @@ topics:
- id: motion-planning
title: Motion Planning
aliases: [motion planning, trajectory planning, motion expansion, trajectory expansion, trajectory augmentation, fixed scene trajectory augmentation, 轨迹扩增, 运动规划, 轨迹规划]
- keywords: [BasePlanner, PlanState, PlanResult, MotionGenerator, ToppraPlanner, CuroboPlanner, NeuralPlanner,
- expansion, GenerationSession, TrajectoryAugmentationCfg, CandidateTrajectoryBatch,
- collision world, compute trajectory, trajectory resampling, trajectory warping]
+ keywords:
+ - BasePlanner
+ - PlanState
+ - PlanResult
+ - MotionGenerator
+ - ToppraPlanner
+ - CuroboPlanner
+ - NeuralPlanner
+ - expansion
+ - GenerationSession
+ - TrajectoryAugmentationCfg
+ - CandidateTrajectoryBatch
+ - collision world
+ - compute trajectory
+ - trajectory resampling
+ - trajectory warping
+ - GenerationRunner
+ - FixedSceneHost
+ - QposRolloutExecutor
+ - trajectory_generation
paths: [topics/motion-planning/motion-planning.md]
source_of_truth:
- embodichain/lab/sim/motion/planners/base_planner.py
@@ -152,6 +232,25 @@ topics:
- embodichain/lab/sim/motion/expansion/
- embodichain/compute/trajectory/
- embodichain/lab/sim/utility/action_utils.py
+ - embodichain/lab/sim/motion/__init__.py
+ - embodichain/lab/sim/motion/planners/neural_planner.py
+ - embodichain/lab/sim/motion/expansion/__init__.py
+ - embodichain/lab/sim/motion/expansion/contracts.py
+ - embodichain/lab/sim/motion/expansion/cfg.py
+ - embodichain/lab/sim/motion/expansion/operators.py
+ - embodichain/lab/sim/motion/expansion/coverage.py
+ - embodichain/lab/sim/motion/expansion/session.py
+ - embodichain/lab/trajectory_generation/runner.py
+ - embodichain/lab/trajectory_generation/execution.py
+ - embodichain/lab/trajectory_generation/initial_state.py
+ - embodichain/lab/trajectory_generation/integrations/sim.py
+ - embodichain/lab/trajectory_generation/integrations/planning.py
+ - embodichain/lab/trajectory_generation/sinks.py
+ - examples/sim/motion/trajectory_generation/free_motion.py
+ - examples/sim/motion/trajectory_generation/cube_grasp_parallel.py
+ - examples/sim/motion/trajectory_generation/cube_pickup_collection.py
+ - embodichain/lab/trajectory_generation/integrations/atomic.py
+ - embodichain/lab/trajectory_generation/integrations/contact.py
watch_paths: [embodichain/lab/sim/motion/motion_generator.py, tests/sim/motion/test_motion_generator.py,
tests/sim/motion/test_motion_generator_batched.py, embodichain/lab/sim/motion/planners/, embodichain/lab/sim/motion/expansion/,
tests/sim/motion/expansion/, embodichain/lab/sim/utility/action_utils.py, embodichain/compute/trajectory/,
diff --git a/agent_context/topics/atomic-actions/atomic-actions.md b/agent_context/topics/atomic-actions/atomic-actions.md
index 27c728519..dce410679 100644
--- a/agent_context/topics/atomic-actions/atomic-actions.md
+++ b/agent_context/topics/atomic-actions/atomic-actions.md
@@ -124,3 +124,18 @@ python docs/scripts/check_api_docs.py
For public API changes also run the docs checker tests and Sphinx dummy build.
For simulator adapters add an environment-level test that exercises normal
`env.step()` consumption and safe cancellation.
+
+## Offline PickUp export for fixed-scene collection
+
+`lab/trajectory_generation/integrations/atomic.py::export_pickup_templates`
+exports a successful MoveEndEffector → PickUp compilation into protected phase
+qpos templates. It retains approach/close/lift boundaries, expands passive
+mimic geometry and appends real hold commands. Only transit permits residuals.
+`cube_pickup_collection.py` uses this source with full-state/contact validation
+and confirmed LeRobot persistence across repeated full-batch restoration.
+
+This is an offline atomic source: the qpos executor owns physical validation;
+no projected `HeldObjectState` is committed as observed evidence. It does not
+consume `initial_plan_provider` or run AtomicActionRuntime tracking/recovery.
+The runtime adapter and contact-aware Gym source/host matrix remain separate
+acceptance work. Keep the existing runtime and task-state contracts intact.
diff --git a/agent_context/topics/atomic-actions/execution.md b/agent_context/topics/atomic-actions/execution.md
index ab286e432..107566fc9 100644
--- a/agent_context/topics/atomic-actions/execution.md
+++ b/agent_context/topics/atomic-actions/execution.md
@@ -47,6 +47,16 @@ implementation `_plan(request, context)` hook.
- `ExecutionRunner` drives that session against observation, command, and clock
ports without blocking `step()`.
+For selected trajectory candidates, `engine.start(...,
+initial_plan_provider=provider)` materializes the first invocation's initial
+`ActionPlan` from the newly resolved request and current `PlanningContext`.
+The framework still binds collision options, authorizes command destinations,
+checks plan identities and scene revisions, and installs tracking and phase
+gates. The provider is consumed only during session construction; subsequent
+invocations and recovery use the registered skill planner. Rebuild the plan,
+binding-dependent effects, and session after reset instead of retaining runtime
+state from a previous rollout.
+
## PlanningContext invariants
`PlanningContext` carries robot observation, scene snapshot, symbolic
@@ -90,6 +100,11 @@ Held-object guards and phase-effect gates are observational:
Pick gates attachment before lift. Place gates detachment before retract.
HandOver owns independent source/destination transfer boundaries.
+PickUp's ragged grasp sampling uses an explicit candidate mask. Empty rows and
+padding carry a safe current FK pose and cannot win selection; an entirely
+empty batch fails without candidate IK. Failed or non-finite IK outputs retain
+the preceding valid seed before later pickup stages are screened.
+
## Row-local state
Vector environments share a synchronized call and command cursor, but success,
diff --git a/agent_context/topics/env-framework/env-framework.md b/agent_context/topics/env-framework/env-framework.md
index e9d824ab2..0b33f2c5e 100644
--- a/agent_context/topics/env-framework/env-framework.md
+++ b/agent_context/topics/env-framework/env-framework.md
@@ -27,6 +27,8 @@ component ownership, path resolution, config-owned IDs, and task listing.
Read [execution](execution.md) for hooks, bridge acceptance, reset ordering,
wrappers, and replay. Read [profiling](profiling.md) only for instrumentation.
+For fixed-scene collection, see [generation preparation and explicit demo candidates](execution.md#fixed-scene-generation-preparation-base_envpy-embodied_envpy). The host owns a full-batch generation lease and validated preparation epochs.
+
## Timing contract
`BaseEnv._configure_timing()` resolves `EnvCfg` before constructing the scene:
diff --git a/agent_context/topics/env-framework/execution.md b/agent_context/topics/env-framework/execution.md
index 5aad0290a..e78a242d3 100644
--- a/agent_context/topics/env-framework/execution.md
+++ b/agent_context/topics/env-framework/execution.md
@@ -49,6 +49,68 @@ Read this when the request needs these details. [Topic overview](env-framework.m
- A structured controller `TensorDict` may carry auxiliary fields such as
`ik_success`, but it must contain at least one supported control key.
+### Explicit demonstration candidates (`demo.py`)
+
+- `execute_demo_episode(env, segments=...)` accepts one `DemoSegment` or a lazy
+ iterable of segments. Explicit segments bypass both task planning factories;
+ combining them with planning keyword arguments raises `ValueError` before
+ recording begins. An empty iterable executes no candidate and does not fall
+ back to task planning.
+- `segments=None` preserves `resolve_demo_segments()` and its legacy
+ `create_demo_action_list()` fallback. Both paths validate segment types lazily
+ and fill missing instructions from dataset metadata without changing the
+ supplied segment.
+- Explicit candidates use the same normal action processing, per-row masks,
+ validators, cancellation/abort handshake, and recording lifecycle as task
+ plans. Auto-reset remains suspended during execution; the caller owns the
+ later commit or discard boundary. Supplying a candidate does not restore its
+ initial state.
+- Focused coverage: `tests/gym/envs/test_demo.py` and the Task Program bridge
+ and completion tests under `tests/gym/envs/task_program/`.
+
+### Fixed-scene generation preparation (`base_env.py`, `embodied_env.py`)
+
+- `BaseEnv.acquire_generation_lease(owner)` reserves the complete environment
+ batch by owner identity. Same-owner acquisition is idempotent; other owners
+ are rejected. The generation flag disables auto-reset independently of demo
+ and replay flags. Ordinary `reset()` fails before seeding or state mutation
+ while the lease is held.
+- `EmbodiedEnv.prepare_generation_episode(owner, *, prepare, restore, settle,
+ verify)` is the full-batch discard-and-prepare boundary. Freeze prior episode
+ evidence first: this method clears camera buffers, expert/trajectory counts,
+ demo annotations, success state, and the active Task Program bridge without
+ saving pending episodes.
+- Preparation runs deterministic task/controller initialization, physical
+ restoration, settling, standard manager reset, and nonempty all-passed
+ `ValidationResult` verification
+ through trusted callbacks. It does not run startup/reset/interval events or
+ rewind environment/event RNGs. The host profile owns required initialization
+ and certifies any interval events that remain active during later rollouts.
+- Observation/history, reward, and dataset managers reset before verification;
+ `get_obs()` and `get_info()` then refresh the initial observation/task state,
+ and recording seeds from that settled state. Preparation does not use Gym
+ `step()` or write a training transition. Failure leaves stepping disabled.
+- `generation_epoch` advances on lease acquisition/release, every preparation
+ attempt, and normal resets. Failed preparation invalidates previous epochs.
+ `FixedSceneHost` in `embodichain/lab/trajectory_generation/initial_state.py`
+ matches candidate bindings against this epoch and verifies physical/task
+ initial state before publishing a `PreparedBatch` or Gym first frame.
+- `BaseEnv.observe_generation_commands(owner, callback)` observes a successfully
+ submitted `_step_action` command before physics. The observer requires the
+ same generation lease and does not issue a second command or step.
+ `execute_demo_episode` exposes `step_observer(result, active_before_step)` and
+ `row_step_limits` so the qpos executor records real returned observations and
+ stops each short row's recording before batch padding/holds.
+- `FixedSceneHost.initial_observation(binding)` copies the prepared Gym first
+ frame without a second `get_obs()` or history update.
+- `release_generation_lease(owner)` restores normal reset behavior without
+ saving, resetting, or modifying demo/replay flags. Callers serialize access;
+ the host remains the sole simulation stepper while the lease is active.
+- Focused coverage: `tests/gym/envs/test_fixed_scene_preparation.py`, including
+ real Gym lifecycle methods wired to `FixedSceneHost` through fake physical
+ ports; physical adapters have separate tests under
+ `tests/lab/trajectory_generation/`.
+
### Task Program completion (`embodied_env.py`, `task_program/bridge.py`)
- `EmbodiedEnvCfg.task_program` remains opt-in. A registered task may attach
diff --git a/agent_context/topics/motion-planning/motion-planning.md b/agent_context/topics/motion-planning/motion-planning.md
index 9e8853edd..6a505e438 100644
--- a/agent_context/topics/motion-planning/motion-planning.md
+++ b/agent_context/topics/motion-planning/motion-planning.md
@@ -60,6 +60,8 @@ the object nor certifies the grasp.
Focused augmentation tests live under `tests/sim/motion/expansion/`.
+Read [fixed-scene trajectory generation](trajectory-generation.md) for host restoration, qpos rollout, offline PickUp sources, contact validation, and confirmed dataset persistence.
+
## Choose the owning layer
- `BasePlanner` and `PlanState` / `PlanResult` define planning interfaces.
diff --git a/agent_context/topics/motion-planning/trajectory-generation.md b/agent_context/topics/motion-planning/trajectory-generation.md
new file mode 100644
index 000000000..c0d1df61e
--- /dev/null
+++ b/agent_context/topics/motion-planning/trajectory-generation.md
@@ -0,0 +1,97 @@
+# Fixed-scene trajectory generation
+
+[Motion planning overview](motion-planning.md).
+
+`rotate_grasp_about_object_axis` rotates a reference TCP pose about a fixed
+object-local axis through the object origin. The caller chooses geometry-valid
+angles and replans the resulting pose candidates; the operator neither moves
+the object nor certifies the grasp. A cube's quarter-turn symmetry is used by
+`examples/sim/motion/trajectory_generation/cube_grasp_parallel.py` to compare
+0/90-degree grasp orientations crossed with reference/residual transit paths.
+Four physics rows execute together using existing MoveEndEffector/PickUp plans;
+only the pre-grasp transit allows `joint_residual`. The example saves a tiled
+camera MP4 with measured TCP trails, `rollout.npz`, and `report.json`. Physical
+acceptance checks sustained lift and TCP-relative position stability. It uses
+IK interpolation and does not claim contact-aware collision validation or
+LeRobot expert qualification; the separate `cube_pickup_collection.py` performs
+that bounded collection workflow.
+
+`lab/trajectory_generation/runner.py` owns the synchronous qpos job for
+handwritten free motion and offline atomic PickUp exports. It resolves supplied host/planner/executor/sink policy identities,
+reserves bounded rollout capacity through `GenerationSession`, and restores the
+full batch only when ready candidates need another round. The concrete
+`QposRolloutExecutor` uses normal Gym demo/controller ports or one pure-sim
+step owner, captures actual submitted targets and T+1 observations, and checks
+the manager's actual control-time increments. Measured qpos is rechecked for
+collision, dynamics, reference-relative length/duration, and task success.
+Only `LeRobotEpisodeSink` receipts confirmed after sealed readback update
+committed counts and coverage; the runner closes its host/sink and writes
+`generation_report.json` on completion or failure.
+
+`integrations/planning.py` exposes `EEFPath` and `EnvRowMotionPlanner`: logical
+candidate count C is scheduled over real physical B rows, without changing the
+backend's batch size. It supports fully annotated free paths, exact qpos checks,
+EEF IK/FK conversion and explicitly supplied solved branches. Contact, held
+objects, changing locked joints, and unavailable backend checks cannot pass.
+Joint-path collision checks use bounded sample densification, not a continuous
+collision guarantee. The cuRobo lock model cache includes actual locked-joint
+initial values and rejects runtime configuration drift until explicitly closed.
+
+Generation currently requires every physical rigid UID in both the collision
+world and its pose-update IDs, so each initial-state snapshot supplies the
+correct per-row geometry pose. Different relative layouts require a per-env
+world. The executor certifies fixed root/rigid poses at observation boundaries;
+implicit floor geometry and other fixed conditions remain the trusted profile's
+responsibility. This free-motion integration does not qualify atomic PickUp,
+attachment geometry, or the planned four source/host combinations.
+
+Focused integration tests are in `tests/lab/trajectory_generation/`; use the
+CPU tests for orchestration and resource limits, and opt into the serialized
+real cuRobo/physics smoke tests when changing those adapters.
+
+`examples/sim/motion/trajectory_generation/free_motion.py` runs the complete
+normal-gravity pure-arm UR5 pipeline and emits a LeRobot shard plus report.
+`--record-video --duration 5 --joint-displacement 0.4` also streams a 640x480,
+20 fps offscreen camera preview to `preview.mp4`. Rendering observes the actual
+rollout without stepping physics; video includes both initial and terminal
+observations and stays separate from the numeric LeRobot schema.
+`--robot panda` is a real rejection case: motion-induced hand/mimic displacement
+exceeds the locked-joint model tolerance, so actual collision verification is
+unavailable and no expert episode is committed. These two real cases are in
+`tests/lab/trajectory_generation/test_runner_real.py`; they do not replace the
+planned PickUp source/host qualification matrix.
+
+## PickUp Collection Integration
+
+`integrations/atomic.py::export_pickup_templates` accepts a successful offline
+MoveEndEffector → PickUp compilation, preserves its explicit phase boundaries,
+expands mimic coordinates, and adds real terminal hold commands. Only transit
+allows residuals. It exports qpos references, without committing projected
+symbolic effects or executing AtomicActionRuntime recovery/tracking.
+
+`integrations/contact.py::PickUpMotionValidator` is the shared Runner planner
+and executor contact validator. It uses conservative URDF collision hulls,
+full-joint FK, cuboid world geometry, an implicit floor, phase/contact-entry
+permissions, and planned held-object geometry. Actual collision checks use
+measured fingers/object poses. Native contacts are checked after every 5 ms
+CPU physics step; forbidden/unknown/cross-row/deep contacts, buffer overflow,
+missing finger contact, slipping and dropping cannot qualify. Mounting links
+must be fixed to the robot root. Two-hop self-pair exclusions match cuRobo.
+The executor exempts only this target and declared mimic joints from its
+free-motion immobility rules; all remaining root/world checks remain active.
+
+`examples/sim/motion/trajectory_generation/cube_pickup_collection.py` runs four
+rows, repeatedly restores the full initial state, and collects confirmed
+LeRobot shards with optional synchronized video. Its 0/90-degree targets use
+independent residual transit paths. Controller stiffness/opening margin are
+set before capture; collision and tracking thresholds are not relaxed per row.
+The sink flattens numeric matrices in C order, records `observation_shapes`,
+and preserves terminal matrices. `commanded_joint_indices` distinguishes active
+controller labels from full-joint passive target columns.
+
+This is CPU-physics / unscaled fixed-base URDF / cuboid-rigid support. The
+optional `trajectory-generation` dependencies supply FCL/trimesh/yourdfpy.
+It is sampled validation, not continuous collision detection. Contact-aware
+Gym, atomic runtime replay, general via points and a registry/CLI remain work.
+Focused tests: `test_contact.py`, `test_atomic_source.py`, `test_episode_sinks.py`,
+`test_pickup_collection.py` under `tests/lab/trajectory_generation/`.
diff --git a/agent_context/topics/simulation-system/simulation-system.md b/agent_context/topics/simulation-system/simulation-system.md
index c73d6262a..22d5da702 100644
--- a/agent_context/topics/simulation-system/simulation-system.md
+++ b/agent_context/topics/simulation-system/simulation-system.md
@@ -123,6 +123,21 @@ reset, or dataset I/O; those operations belong to host integrations. Its public
import follows the normal `lab/sim` lifecycle and does not promise an isolated
toolkit import. Atomic Actions remain above the motion capabilities.
+`FixedSceneHost` in `embodichain/lab/trajectory_generation/initial_state.py`
+reserves an entire simulator batch for capture/restore and publishes only
+validated `PreparedBatch` epochs. It compares both the physical adapter's
+structure signature and the trusted profile's fixed-condition signature;
+acquisition verifies the captured physical state before accepting it. Normal
+`SimulationManager.reset_objects_state()` calls fail while this host owns the
+batch. Its physical adapter restores state directly, and a Gym-backed host
+also uses the controlled generation lease/preparation boundary described in
+`env-framework`.
+
+`SimulationManager.simulation_time` is the read-only sum of successful manual
+`update()` physics steps. Generation records elapsed control time from this
+counter; preparation setters that update the native world directly are outside
+this clock and occur before the rollout timestamp origin is captured.
+
`Articulation.get_parent_joint_chain(link_name)` is the public topology query
for integrations that need link ancestry. It returns immediate-parent-first
`ArticulationJointKinematics` values containing copied names, joint type,
diff --git a/docs/design/fixed_scene_expert_trajectory_augmentation_design.md b/docs/design/fixed_scene_expert_trajectory_augmentation_design.md
new file mode 100644
index 000000000..a0b02b6b2
--- /dev/null
+++ b/docs/design/fixed_scene_expert_trajectory_augmentation_design.md
@@ -0,0 +1,578 @@
+# EmbodiChain:固定场景下的专家轨迹域扩增设计
+
+状态:设计与实施边界,更新于 2026-09-05。手写 qpos/free-motion 同步闭环已落地;完整配置、接触/持物与调度方案仍含待实现内容,当前边界见第 11 节及[实施计划](fixed_scene_expert_expansion_implementation_plan.md)。本文没有吞吐对比实测,不宣称某种模式在所有任务上最优。
+
+适用范围:原子技能、手写关节轨迹、EEF waypoint 和可信参数化工厂共享扩增能力,由 sim 或 Gym 调用。场景布局与物体初始位姿由宿主提供,不属于扩增变量。
+
+推荐架构:**按场景隔离的候选池 + 可选同场景副本池 + 分层按需扩增 + 成本感知调度**。首版保留全批执行边界,通过时长分桶、计算缓存和有界队列提高效率;逐行异步恢复、检查点分叉作为能力成熟后的选项。
+
+## 1. 目标、边界与效率指标
+
+### 1.1 固定的是每个场景条件,不是所有环境
+
+一次扩增的输入为 `(S, G, Q₀)`:
+
+| 输入 | 固定内容与允许变化 |
+|---|---|
+| 场景 S | 实体及物体初始位姿、几何、物理参数、机器人安装位姿;不由扩增器采样 |
+| 任务 G | 操作对象、目标效果、阶段依赖、接触约束与成功条件;不改变任务语义 |
+| 起点集合 Q₀ | 默认使用调用者给定的完整机器人初态;只有部署允许时才扩增关节起点 |
+
+多环境可以具有不同的 S;同一环境在外层 reset 后也可以接收新的 S。扩增器只计算各条件下的轨迹分布,不决定如何生成布局。任务执行导致物体运动属于正常任务动力学,不是场景随机化。
+
+目标覆盖“从允许起点能到达,并能继续完成任务”的状态—动作路径。状态包含关节构型、阶段与接触关系;只证明某个 EEF 点运动学可达不够。[Robot 的工作空间采样](../../embodichain/lab/sim/objects/robot.py) 可作先验,但仍需验证前缀、后缀与整条路径。
+
+### 1.2 优化目标
+
+先满足任务、碰撞、控制和专家质量门槛,再在预算内增加有效覆盖:
+
+- 已验收且已持久化的去重轨迹数/墙钟时间。
+- 实际轨迹新增的几何、构型与时间覆盖/墙钟时间。
+- 达到指定覆盖目标的总成本,包含准备、规划、物理、渲染、验收和写盘。
+
+规划成功数、种子数和时间变体数不能代替上述指标。无意义绕路、长停顿、近重复不因增加点数获得奖励。比较方案时固定硬件、传感器、任务质量门槛和数据目标,并分别报告冷启动与稳定运行成本。
+
+## 2. 分层架构与来源接入
+
+### 2.1 所有权与依赖方向
+
+| 层 | 职责 | 边界 |
+|---|---|---|
+| 公共扩增核心 | 模板、候选值对象、因子算子、去重、覆盖描述符与规划端口协议 | 算法直接依赖 tensor/math/configclass,不直接导入 Gym 或仿真 backend;公共导入遵循 lab/sim 初始化 |
+| GenerationSession | 各 case 的候选池、预算、随机流、覆盖预留及提交计数 | 只接收快照和结果,不读取活环境,不执行 step/reset |
+| 来源与规划适配 | 将原子或手写输入转为模板,调用 IK、MotionGenerator 与路径检查 | 注入服务;原子 binding/effect 不泄漏到公共核心 |
+| Runner 与宿主适配 | 执行槽管理、初态准备、命令、步进、观测、效果验证和数据提交 | 每个宿主只有一个物理步进所有者 |
+
+公共核心位于 `embodichain/lab/sim/motion/expansion/`,包含 `contracts.py`、`cfg.py`、`operators.py`、`coverage.py`、`session.py`,与 `motion/{solvers,planners,workspace}` 共属机器人运动能力域。已实现的 `initial_state.py`、`runner.py`、`execution.py`、`sinks.py` 和 `integrations/{sim,planning}.py` 位于 `embodichain/lab/trajectory_generation/`;Gym 生命周期接入现有 env,原子来源适配仍待实施。端口协议集中在 contracts,避免过早拆分。
+
+2026-09-05 根据模块归属 review,将原工具包方案调整为 `lab/sim/motion`:`motion` 父包按需加载四个子包,不在初始化时主动汇总全部求解器、规划器或分析器。当前 [lab 包初始化](../../embodichain/lab/__init__.py) 仍会加载仿真相关子包,因此不再承诺无需仿真依赖即可公共导入;纯算法不直接依赖 Gym,也不拥有 step/reset。sim/Gym 必须复用同一生成会话,不各自实现采样、预算和覆盖逻辑。求解器不反向依赖扩增会话;Gym 生命周期适配不进入 sim 运动核心。
+
+### 2.2 原子技能与手写轨迹同等接入
+
+| 来源 | 必需输入 | 适配方式 |
+|---|---|---|
+| 原子技能/Task Program | invocation、阶段约束、affordance、binding 与效果条件 | 导出模板,保留候选到规划完成;只对执行分支构造 ActionPlan |
+| 手写 `qpos + dt` | joint names/单位、工具通道、阶段、锁定端点和可变范围 | 扩增声明的自由段,保留未受控关节;未知接触段不改动 |
+| 手写 EEF waypoint | 坐标系、工具标定、阶段与路径约束 | 经注入的 IK/规划端口生成关节分支 |
+| 可信参数化工厂 | 显式快照、因子值、局部 RNG,以及模板/下一阶段输出 | 概念接口 `build(snapshot, factors, rng) -> TrajectoryTemplate` |
+
+未标注的 `qpos + dt` 默认仅支持原样执行、记录和审计。单纯 action 数组也不能自动解释为 qpos;velocity、EEF delta 等需声明控制语义及可靠转换,否则不启用几何扩增。
+
+现有 `create_demo_action_list()`/`create_demo_segments()` 可适配,但会 step 或依赖未来反馈的旧生成器,应先做受控基准采集,或按实际阶段边界生成下一段;不能在纯规划核心提前耗尽 lazy iterator。配置只引用可信注册的 source ID,不执行任意 YAML 表达式或 dotted import。
+
+### 2.3 sim/Gym 执行接入
+
+| 来源 × 宿主 | 执行路径 |
+|---|---|
+| 原子 × sim | 普通 ActionPlan → 现有原子 ExecutionSession/Runner 与仿真端口 |
+| 原子 × Gym | Task Program/Gym 命令桥 → 正常 `env.step()` |
+| 手写 × sim | 计时控制命令与工具事件 → sim 适配器;无需伪造 AtomicAction 或 Gym |
+| 手写 × Gym | 显式候选 DemoSegment → demo 执行器 → 正常 `env.step()` |
+
+Gym 尚需补充显式 segments 输入或明确消费候选的工厂入口,不能假定旧方法的 kwargs 会生效,也不临时替换环境方法。已是控制器目标的命令走 [ControllerAction](../../embodichain/lab/gym/envs/types.py),只跳过重复 raw-action pre 处理,不绕过 step 或 post 处理。
+
+Gym 的权威周期为 `env.step_dt`;直接 sim 显式提供 `control_dt`,并验证对应整数个 physics step。Gym Runner 不额外调用 sim.update;直接 sim 由适配器完成控制、物理、传感器与记录。规划使用不可变快照;后台状态改变后,受影响计划必须重新检查。
+
+## 3. 统一数据契约与身份
+
+### 3.1 核心值对象
+
+| 契约 | 关键字段与约束 |
+|---|---|
+| `SceneCase` | 场景内容签名、任务/机器人/标定契约、允许起点;不持有活环境引用 |
+| `MotionSnapshot` | case ID、完整机器人状态、根坐标系、实体状态、目标及依赖 revision;输入行到宿主实例的映射由适配器维护 |
+| `TrajectoryTemplate` | source ID/revision、template ID、控制表示、参考关节或 EEF 路径、显式时间或待参数化声明、阶段/锚点、允许算子、工具事件和 validator ID |
+| `CandidateTrajectoryBatch` | `positions: (C,N,D_full)`、`dt: (C,N)`、`valid_length: (C,)`;每行 case/初态/候选身份、实际 IK 分支、因子值、阶段事件、验证状态及诊断 |
+| `ExpertEpisode` | 实际观测、下发动作与表示、时间、机器人/接触测量、阶段、验收证据、候选谱系及提交身份 |
+
+C 为逻辑候选数,不等于物理环境数;N 可 padding,只有 valid_length 内的样本有效。适配器转换现有 PlanResult、TimedTrajectory 和 ActionPlan;这些 lab 类型不进入核心协议。失败候选可删除大张量,但必须保留轻量 audit。
+
+`GraspCandidateBatch` 仍由独立 graspkit 所有:`poses: (B,K,4,4)`、`costs: (B,K)`、`candidate_mask: (B,K)`。兼容辅助方法可包装原 ragged 接口;镜像、upright、标定及语义过滤由来源适配器派生。验证有限值及 SE(3) 合法性;空行填安全 pose 并置 mask=False,不访问不存在的第一个 grasp。
+
+### 3.2 三种身份不能混用
+
+| 身份 | 含义与生命周期 |
+|---|---|
+| `scene_case_id / initial_state_id` | 哪个固定场景与允许起点;相同 case 可服务多个副本及多次 episode |
+| `candidate_id / geometry_family_id / parent_id` | 哪条扩增方案、几何家族和父分支;重试增加 attempt ID,时间变体保留同一几何家族 |
+| `slot_id / runtime_epoch` | 本次在哪个物理实例执行,以及该槽当前的运行时版本;由宿主绑定 |
+
+`source_row_index` 仅表示输入快照行,不能当作永久 env 身份;来源 env ID 可作为宿主审计信息另存。同一候选转移到副本前要检查场景、起点、控制和坐标映射兼容性;reset 后重建运行时 binding、效果请求和碰撞依赖。
+
+离线可返回全部规划合格候选;在线兼容路径仍逐环境按可行性、质量分数及原始候选顺序稳定选择一个 winner。无解行保持起点并标记失败,不挤占其他行。不得将重复 env IDs 或 `(B,K,N,D)` 塞入现有 [TimedTrajectory](../../embodichain/lab/sim/atomic_actions/plans.py)。手写来源没有 grasp 时相应字段为空,不伪造索引。
+
+## 4. 扩增维度与分层生成
+
+### 4.1 统一因子表
+
+| 因子 | 采样变量与粒度 | 必要条件/不变量 |
+|---|---|---|
+| `contact` | 每个接触分支的 grasp 区域、合法对称变体、夹持宽度 | affordance 与可重新生成接触段的模板;保持任务和物体初态 |
+| `ik` | 每个目标/路径的肘部、腕部、冗余构型 | EEF 约束,或明确许可从 qpos 经 FK 重建;关节限位与连续可达 |
+| `approach` | 每条几何候选的接近距离、许可方向、撤离方式 | 固定接触锚点、进入方向及合法走廊 |
+| `spatial` | 自由段的少量路点、平滑残差、姿态过渡、绕障走向 | 已声明自由段、锁定端点及质量边界;不逐控制帧重采样 |
+| `timing` | 每条几何路径的分阶段速度和加减速、时长 | 统一 control_dt,动态限制与阶段连接 |
+| `contact_timing` | 每个闭合/释放/稳定事件的许可时长 | 工具通道、事件顺序、接触模式和效果 gate |
+| `start_state`,可选 | episode 准备前选择机器人起始构型 | 起点属于部署分布,场景与机器人安装位姿不变 |
+| `recovery`,后续 | 从实际偏离状态生成成功后缀 | 真实可达的前缀、后缀生成器与恢复验收 |
+
+采样域为“任务允许域 ∩ 模板允许域 ∩ job 配置 ∩ 机器人/backend 能力”。缺少启用因子的标注或交集为空时明确报错;关闭因子保持模板值。先固定起点,条件采样接触/接近方式,再联合求解路径与 IK,最后按需求产生时间变体;不展开无界 Cartesian product。
+
+接触候选先过质量门槛,再按接触区域、方向等分层选代表,不只按全局 grasp cost top-K。未标定 cost 不是成功概率。一个自由段首版选一种几何算子;多算子组合需明确顺序并重验最终路径。
+
+### 4.2 IK、几何与时间的关键约束
+
+1. **多解 IK**:解析枚举或多 seed 求解后按关节构型去重;每分支独立传播 pre-grasp → grasp → lift → downstream 的成功 seed。NaN/Inf、FK residual 越界均失败,失败 qpos 不污染后续阶段。
+2. **保持实际分支**:请求携带 `solved_joint_targets`、分支及连续性约束。现有 [EEF 插值路径](../../embodichain/lab/sim/motion/motion_generator.py) 会再次做 IK;只传 pose 可能使多分支重新合并。无法强制保持时按最终结果重新识别、去重。
+3. **低维几何变化**:自由段用少量 via points 或 `q(s)=q_ref(s)+Σ θ_j B_j(s)`,基函数保持端点及所需导数;避免逐帧独立噪声。接触段必须遵守 Cartesian/接触约束,不能只做关节直线插值。
+4. **显式时间参数化**:同一路径改变推进速度,再按 control_dt 生成变长命令;不能同时固定点数、周期又改变总时长。重新计算事件索引,检查速度、加速度及任务所需 jerk/力矩。未许可的工具与等待时长不自动缩放。
+
+同 seed、不同 seed、不同 chunk、不同点数都不自动代表不同运动模式。时间变体可增加速度覆盖,不增加同一路径的空间覆盖。
+
+### 4.3 多阶段与技能推广
+
+分支节点至少保留完整机器人状态、夹爪/接触状态、持物关系、语义任务状态、阶段、父前缀及后缀约束。不能仅因 qpos 接近就合并两个节点。
+
+若 T 表示后一个坐标系在前一个坐标系中的位姿,候选 i 的下游 EEF 目标为:
+
+$$
+T^{(i)}_{\mathrm{object}\to\mathrm{eef}}
+= T_{\mathrm{world}\to\mathrm{object}}^{-1}T^{(i)}_{\mathrm{world}\to\mathrm{eef}},
+\qquad
+T^{(i)}_{\mathrm{world}\to\mathrm{eef,target}}
+= T_{\mathrm{world}\to\mathrm{object,target}}T^{(i)}_{\mathrm{object}\to\mathrm{eef}}.
+$$
+
+只对未来可继续、质量合格的节点投入后续预算;物理执行后的续段使用实测状态,不把计划终点当作已实现状态。
+
+- PickUp:优先复用候选 IK,延后 winner;闭合、lift 和后续放置继承同一分支。
+- AxisAlign:把对齐偏好变为候选评分,两阶段及持物旋转均保留候选依赖。
+- HandOver:两侧先筛选有限兼容 pair,按 control part/assignment 分组;双臂各自可达不等于联合路径、交接时序可行。
+- Slide、OpenDoor 及手写多段轨迹复用阶段协议;接触候选来自哪个接口不应限制通用扩增。lift_height 等参数只有被定义为可变中间量时才能采样。
+
+### 4.4 避障驱动的空间扩增
+
+固定场景几何是规划条件,不是扩增变量。空间扩增分为两种模式,避免把“检查后剔除碰撞路径”等同于“主动生成不同绕障路线”:
+
+| 模式 | 生成方式 | 用途 |
+|---|---|---|
+| 局部扩增与过滤 | 在参考自由段附近采样受限残差或路点,再检查整段路径 | 低成本增加参考路线附近的覆盖 |
+| 避障重规划 | 固定任务锚点、阶段边界及声明的 IK 分支约束,采样许可路点或规划初值,调用具备避障能力的规划端口 | 探索不同的有效绕行路线,不保证不同 seed 得到不同路线 |
+
+避障重规划是候选生成方式,不仅是碰撞失败后的补救;`ik_interp` 或单纯时间参数化不能替代它。原子与手写来源复用同一规划端口,只改模板授权的自由段,不放松接触锚点、后缀可行性或专家质量门槛。
+
+- **有限修复**:碰撞候选可在预算内重规划或换路点;显式限制每候选重规划次数和每 case 规划预算,耗尽后记录失败。启用模式但缺少所需后端能力时明确拒绝,不静默退回插值。
+- **最终验收**:检查平滑、拼接、时间重采样后的实际输出路径,并更新其阶段事件与谱系。碰撞世界绑定和接触/持物规则复用第 5.2、8.1 节;能力不可用不计为通过,不能只检查修复前路径或关键帧。
+- **有效覆盖**:按最终几何路线去重;多个提议修复到同一路径时不重复计空间覆盖。安全间隙与合理路径长度作为质量门槛,不奖励无意义绕路。
+
+上述模式选择与重规划预算属于待补配置。当前 `EnvRowMotionPlanner` 主要执行给定 EEF 路径的 IK/FK 转换及 free/no-held 关节路径加密检查,尚不主动搜索替代绕障路线;接触、持物和连续碰撞的实现边界仍见第 11 节。
+
+## 5. 高效批量规划与缓存
+
+### 5.1 逻辑候选与 GPU 批次分离
+
+存储使用紧凑候选表;执行使用少量预热的固定容量桶,例如 16/32/64 行。按机器人/控制部位、阶段结构、碰撞配置和长度分组,桶内 padding 使用安全输入与显式 mask;无效行即使 backend 返回成功也不得进入候选池。
+
+每阶段压到最小 C 不一定最快:改变 batch/horizon 可能增加分配、warmup 和 CUDA Graph 成本。优先在阶段边界整理候选,控制桶数量及缓存显存,而不是为每个 C 创建 backend。cuRobo 支持容量预分配和内部 padding,项目已有 [按 batch 等条件缓存 backend](../../embodichain/lab/sim/motion/planners/curobo/curobo_planner.py) 的基础,不应重复建设。[cuRobo 批量规划说明](https://nvlabs.github.io/curobo/latest/api/curobo.batch_motion_planner.html)
+
+建议由 `generate_candidates()` facade 负责能力检查、分桶与以下映射:
+
+- 候选 → 来源 snapshot/case → 机器人根坐标与完整起始状态。
+- 候选 → 当前阶段碰撞世界、允许接触对及持物几何。
+- 规划行 → 原 candidate ID、阶段 mask、实际分支和失败原因。
+
+[BasePlanner 校验](../../embodichain/lab/sim/motion/planners/base_planner.py) 当前仍将 batch 绑定到 robot.num_instances,且 [机器人 IK](../../embodichain/lab/sim/objects/robot.py) 存在按环境读取根坐标的路径。不能仅展平 C 就假定全部 backend 可用。未完成通用适配时,必须显式选择真实环境行宽的分轮模式,一轮每个真实行至多处理一个对应候选,其余安全占位;不能悄悄放松校验或伪造实例数。
+
+### 5.2 碰撞世界与共享边界
+
+同一 robot-relative 布局可共享不变的环境几何;arena offset 由适配器变换。不同 case、不同阶段动态物体、不同持物关系不能共用错误的 obstacle pose 或可变附件状态。
+
+共享 world 只有在该规划批次所需碰撞状态一致时才成立;否则按正确来源映射 per-candidate world,或拆成兼容分组。复用 backend 时也要重新绑定当前世界,不能让另一个 case 的更新污染正在求解的批次。primitive 不直接构造 cuRobo/TOPPRA 专用 options。
+
+关闭动态碰撞只可用于声明清楚的受限规划实验,不能代替正式数据必须通过的路径验证。把多个 grasp 放进“只返回一个 winner”的 goalset 也不等于获得多条候选轨迹。
+
+### 5.3 按依赖复用,不重复完整流水线
+
+| 改变内容 | 可尝试复用 | 必须重算或重验 |
+|---|---|---|
+| 只改运动时间 | 几何路径、兼容 IK、静态几何检查结果 | 时间网格、事件映射、动态限制与物理效果 |
+| 改某个自由段路点 | 未受影响的目标/阶段与几何资源 | 该段 IK/路径、连接边界、依赖它的后缀 |
+| 改 grasp/接触模式 | 场景几何与无关模板 | 接触段、持物关系及受影响下游规划 |
+| 同 case 恢复初态或换兼容副本 | 满足同一初态契约的模板/几何候选 | 初态验证、坐标映射、runtime epoch、效果请求与碰撞绑定 |
+| 外层切换 case | 真正场景无关的模板与机器人模型 | 场景相关路径、可行性与覆盖归属 |
+
+缓存键包含来源修订、场景内容、任务、机器人/标定、起点、阶段依赖和算子参数;计时计划再包含周期和执行限制。缓存设大小上限与淘汰策略,失效不清空其他 case 的有效数据。不能把旧 ActionPlan/session 当作可跨 reset 复用的值对象。
+
+## 6. 覆盖驱动的候选池与执行调度
+
+### 6.1 分场景集合选择与按需展开
+
+对每个 case 分别统计阶段、接触家族、实际 IK 构型、EEF 位置/姿态、归一化关节状态、方向、速度和阶段转换状态。采用少量联合分桶及轨迹近邻距离,不构造完整高维网格。
+
+质量门槛通过后,集合目标可写为 `Σ w_c · min(n_c, target_c)`;一个轨迹反复经过同一区域不重复刷分。几何按阶段进度对齐去重,速度/时长另算。同一路径的时间变体归入同一 geometry family。
+
+调度使用估计的剩余价值,而非固定穷举:
+
+$$
+\mathrm{priority}(x)
+=\frac{\widehat{P}(\mathrm{accept}\mid x)\,
+ \widehat{\Delta\mathrm{coverage}}(x)}
+ {\widehat{\mathrm{remaining\ cost}}(x)+\epsilon}.
+$$
+
+这是启发式优先级,不是最优性保证。保留探索预算和各目标 case/模式的最低配额,避免只采容易成功的区域。尚未校准的成功率使用保守先验,不把 grasp cost 直接当概率。
+
+候选池不足时才继续展开接触、IK、几何及时间分支。可先对几何家族的一种时间方案回放,再根据价值与反馈决定是否增加其他时序;若失败可能由时序造成,也应保留有限替代尝试。不要为每条路径预先生成全部时间组合。
+
+规划描述符只决定优先级。正式覆盖由通过验收且确认写入的实际轨迹更新;在途候选暂时预留覆盖额度,失败或写入失败释放。预算、独立探测和覆盖增益共同决定停止;只能报告在当前预算和采样器下趋于饱和,不能宣称穷尽连续可达空间。
+
+### 6.2 场景与副本池解耦
+
+`SceneCase` 是数据任务,`ExecutionSlot` 是物理资源。一个 case 可绑定多个副本;不同 case 的候选只在兼容槽中运行。副本共享准备信息和不变计算资源,不共享运行中的物理状态。
+
+设 B 个物理槽,G 个活跃 case,每 case 分配 R_g 个副本,满足 `Σ R_g <= B`。例如 16 槽可用于 `1×16` 集中扩增一个场景,或 `4×4` 同时处理四个外部给定场景。副本数提升单场景并行度,但不意味着相同硬件的总吞吐量按倍数增长;最佳分配需实测规划、物理与渲染瓶颈。
+
+两种池模式:
+
+- `per_env_case`:接收各行现有布局,各自维护 case;不要求它们相同,也不跨行直接使用轨迹。
+- `grouped_replicas`:宿主把外部给定 case 的初态准备到多个兼容槽;只在旧 episode 收尾后重绑定,拓扑/机器人不兼容时拒绝或分到独立宿主。
+
+缺少同场景恢复/复制能力时,不得声称已批量验证同一场景的所有候选。可以只返回规划候选,或由宿主显式提供新 case 后重新规划;这些不是原 case 的剩余候选执行。
+
+### 6.3 时长分桶、屏障与逐行补位
+
+首版采用 `full_batch` 屏障,要求 Runner 独占被重置的整个宿主 batch,并优先将预计时长接近的候选安排在同一轮。完成行保持安全状态、停止增加训练帧;若效果需持续成立,提交前仍检查保持条件。真实任务等待与完成后的占位 hold 区分记录。
+
+忽略其他开销时,全批有效步利用率约为 `Σ L_i / (B × max L_i)`。例如 200/220/240/800 步的一批仅约 46%;时长分桶可以减少此类浪费,但不改变任务必需时长。
+
+逐行补位是后续能力:完成行验收后恢复并领取下一个兼容候选,其余行继续;所有动作仍由唯一宿主合并后 batched step。启用前需同时验证:
+
+- reset、速度清理、控制器、任务桥、观测历史和记录均行隔离。
+- 不因某行 settling 额外推进其他活动行;所有必要推进进入统一步进与记录。
+- 局部 reset 不清空其他行的任务状态,不使其碰撞绑定失效。
+- 每槽维持 candidate/attempt/epoch,终态证据在复用前冻结。
+
+当前 [SimulationManager.update()](../../embodichain/lab/sim/sim_manager.py) 推进整个 world;部分 [随机化事件](../../embodichain/lab/gym/envs/managers/randomization/spatial.py) 会额外 sim.update,当前 [EmbodiedEnv.reset()](../../embodichain/lab/gym/envs/embodied_env.py) 还会清除活动任务桥。因此 `reset_ids` 存在并不证明全系统已支持安全的逐行补位。独立 world/process 分片可作为替代,但需计入显存与进程成本。
+
+### 6.4 有界流水线
+
+组织为 `提议/规划 → 待执行候选池 → 执行/验收 → 待写入队列`,各队列有条数与字节上限。达到高水位停止生产,低水位补充;writer 阻塞也须向上游反馈,不能无限持有图像和轨迹张量。
+
+规划只读稳定快照,宿主独占物理步进;有状态 planner 的 world 更新与求解串行保护,或使用真正独立的 backend 实例。图捕获/warmup 在受控窗口完成,不与其他 CUDA 使用者无协调竞争。先重叠 CPU 整理/写盘与物理执行;同 GPU 的规划、物理和渲染是否并行由实测决定。
+
+[Isaac Lab Mimic](https://isaac-sim.github.io/IsaacLab/v2.3.0/source/api/lab_mimic/isaaclab_mimic.datagen.html) 的按环境 action/reset 队列可作为职责分离参考,不意味着 EmbodiChain 可直接复用其调度器。
+
+## 7. reset、初态恢复与单/多环境流程
+
+### 7.1 明确区分三种状态操作
+
+以下定义宿主协议的语义,不是普通 reset options;当前全批 `acquire_case / restore_initial` 已实现,checkpoint 仍为后续能力:
+
+| 操作 | 作用 | 候选与覆盖 |
+|---|---|---|
+| `acquire_case` | 宿主外层 reset 或读取外部指定场景,完成准备后注册 case | 按实际各行内容归属;新场景新建分区,不决定布局如何采样 |
+| `restore_initial` | 为同 case 的下一条独立轨迹恢复指定初态,不重新采样布局 | 验证兼容后可消费剩余候选;重新绑定 runtime epoch |
+| `resume_checkpoint`,后续 | 恢复任务中途的完整可续跑状态 | 只允许验证过的前缀/后缀衔接,不等同于 episode 初态重建 |
+
+固定 seed 不代表不同子环境布局相同,也不能替代初态一致性检查。复用初态需显式准备策略,保留必要的机器人/控制器初始化;不能偷偷跳过全部 events。startup/reset/interval 中会改变本 case 固定条件的随机项必须与宿主约定处理。
+
+首版 `restore_initial` 可采用“完整 episode 重置 + 按 case 重建指定初态 + 校验”,无需先实现任意接触状态 checkpoint。恢复内容至少包含任务所需的机器人/物体状态与速度、夹爪/约束、控制器和 task/manager 初始状态,并完成 settling。已有 [轨迹状态读写](../../embodichain/lab/gym/utils/trajectory_state.py) 只是其中一部分。
+
+当前已落地 [FixedSceneHost / InitialStateProfile](../../embodichain/lab/trajectory_generation/initial_state.py) 与 [SimInitialStateAdapter](../../embodichain/lab/trajectory_generation/integrations/sim.py) 的全批初态基础:一个配置所有的固定基座 robot 和全部普通刚体,保存独立物理状态并在恢复前预检,使用可信 profile 准备任务/控制器、标识固定条件和验证初态。Gym 的 generation lease/prepare 入口不调用普通 reset,且与纯 sim host 共用 simulator batch 所有权;调用者先冻结旧 episode,再丢弃旧录制、准备、恢复、settle、standard episode manager reset、verify,验证后刷新首观测并播种记录。每次准备或释放使旧 epoch binding 失效,失败不允许继续执行。已通过物理关闭/开启两种模式的真实 headless CPU backend 状态恢复 smoke,并接入手写 qpos Runner 与真实 UR5 free-motion profile。固定条件与控制历史不由物理快照自动覆盖;PickUp profile、接触与长时间 settling 验收仍需后续完成。
+
+这种“随机 reset 与指定状态 reset 分开”的接口可参考 [Isaac Lab reset_to](https://isaac-sim.github.io/IsaacLab/develop/source/api/lab/isaaclab.envs.html#isaaclab.envs.ManagerBasedEnv.reset_to),不能据此推断现有项目已支持。
+
+### 7.2 统一采集流程
+
+```mermaid
+flowchart TD
+ A["宿主提供一个或多个 case
完成初始化并取得快照"] --> B["各 case 按需规划、筛选
维护独立候选池"]
+ B --> C{"执行槽分配"}
+ C -->|"单环境"| D["每次执行一条兼容候选"]
+ C -->|"多环境"| E["按 case 分组
各副本执行不同候选"]
+ D --> F["验收、冻结证据
提交合格数据"]
+ E --> F
+ F -->|"原 case 继续"| G["恢复各槽所属 case 初态
刷新观测与运行时绑定"]
+ G --> B
+ F -->|"宿主显式切换 case"| A
+ F -->|"目标达到或预算耗尽"| H["排空在途写入
汇总实际完成与覆盖"]
+```
+
+回到候选池时优先取兼容缓存,不重新生成整个池。多环境首版按第 6.3 节的全批屏障收尾,图中的逐行验收不表示已经允许逐行 reset。
+
+| 事件 | 下一步 |
+|---|---|
+| 纯采样/IK/规划失败且宿主未变化 | 不 reset,只更新 audit、消耗规划预算并继续采样 |
+| 候选完成、失败、超时或受控基准采集结束 | 冻结验收与数据,再恢复初态;失败不进入专家数据 |
+| 中间阶段完成 | 不 reset,从实际边界状态规划/执行下一阶段 |
+| 初态恢复后不匹配 | 拒绝执行并重试准备;仍不匹配报告错误,不静默当作新 case |
+| 宿主显式提供新布局/任务/标定 | 重新注册或匹配 case,失效不兼容计划;旧覆盖保留在原分区 |
+| 预算耗尽/取消 | 停止接收新工作,安全收尾在途执行和写入,不降低验收标准 |
+
+Job 的预算、ID 序列和各 case 历史跨 reset 保留;episode 的控制游标、观测历史和记录在结束后重置。队列能否复用取决于 case/初态兼容性,不是“跨 reset 保留”就永远可执行。
+
+### 7.3 起点、观测与准备顺序
+
+全部初始化、复制和 settling 必须完成在新 episode 首帧播种之前。若准备发生在 env.reset 返回后,适配器需显式暂停准备段记录,刷新观测、重新播种首帧,再允许执行;不能 set_qpos 后继续使用旧观测。
+
+启用 start_state 时,只选择部署允许的起点。若部署始终从固定 qpos 开始,需真实执行合法连接前缀;不能把人为设置的中途状态当作策略自然起点。准备动作不作为专家标签,除非它本身是要学习且完整记录的任务前缀。
+
+采集期间抑制隐式 auto-reset,沿用 [demo 执行器](../../embodichain/lab/gym/envs/demo.py) 由外部 Collector 掌管收尾的约定。任务终态验收必须发生在状态被重置前。
+
+## 8. 验证、真实数据与提交
+
+### 8.1 统一验证链
+
+| 层 | 必检内容 |
+|---|---|
+| 输入与语义 | 有限 SE(3)、工具标定、合法因子、阶段顺序和锚点 |
+| 运动学 | IK/FK residual、关节限位、实际分支及连续性 |
+| 路径 | 自碰撞、环境/双臂碰撞、携带物体扫掠、阶段接触许可及段间运动 |
+| 时间与控制 | 周期、速度/加速度及任务要求的其他动态限制、事件顺序 |
+| 物理与任务 | 跟踪误差、接触建立、滑移/掉落、阶段效果和完整任务成功 |
+| 数据质量 | 合理路径/时长、去重、观测动作因果对齐和谱系完整性 |
+
+使用连续碰撞检测或与运动尺度适配的路径采样,不能只检查关键帧。允许手指在接触阶段接触目标,不意味着整段忽略目标;持物阶段检查对象随候选运动的几何。
+
+每项记录 `not_run / passed / failed / unavailable`、失败阶段及连续度量。缺少必需检查的候选只能留在规划库;规划成功、效果投影和命令完成均不等于物理成功。原始“专家”参考也需验收;质量 ratio 与同起点、兼容任务/模式的基准比较。
+
+### 8.2 记录实际因果序列
+
+记录 `observation_t → action_t → observation_{t+1}`,包含时间、实际机器人/接触状态及阶段。明确源动作、训练标签和下发命令的表示与转换 profile;不能将计划 qpos 或状态差自动当作动作。
+
+每个执行分支重新采集图像;不混用其他分支或另一次执行的观测。真实等待计入实际时长,padding 和完成后的占位不成为训练帧;计划时长与实际时长分别记录。[LeRobotRecorder](../../embodichain/lab/gym/envs/managers/datasets.py) 已有因果配对通路可复用。
+
+### 8.3 Gym reset 的保存顺序
+
+当前同步持久化基础位于 [LeRobotEpisodeSink](../../embodichain/lab/trajectory_generation/sinks.py):每个已验收 `ExpertEpisode` 写入独立 LeRobot shard,T 个训练帧与末观测/T+1 实测时间 sidecar 一同封存并读回,谱系/验证 metadata 和 manifest 也必须读回成功才给 confirmed receipt。相同 commit ID 的相同 payload 可在当前进程内幂等重试;已确认提交只读核验、不重写。输入或时钟/容量不支持时拒绝,写入失败返回失败回执。该 sink 无活环境引用;`QposRolloutExecutor` 已负责冻结实际命令与观测,`GenerationRunner` 接入实测验收、Session 和保存回执。manifest 未列出的片不能作为正式专家数据消费。
+
+当前 [BaseEnv.reset](../../embodichain/lab/gym/envs/base_env.py) 与 [EmbodiedEnv](../../embodichain/lab/gym/envs/embodied_env.py) 的顺序为:
+
+```text
+Collector 先完成旧 episode 验收并冻结终态证据
+→ BaseEnv 缓存旧任务成功状态
+→ sim.reset_objects_state(reset_ids)
+→ DatasetManager 消费旧 buffer
+→ 清空旧记录和 metadata
+→ reset events 与 managers
+→ 获取新观测,EmbodiedEnv 播种首帧
+```
+
+物理状态先于 DatasetManager 保存被重置,因此保存阶段不得重新读取现场来判定旧 episode。异步写入需在 buffer 清空前取得独立 payload。
+
+现有全量 reset 的选择性 dataset 提交可写为:
+
+```python
+# accepted_ids 来自 reset 前完成的任务、质量与去重验收。
+env.reset(options={"save_data": False, "commit_env_ids": accepted_ids})
+```
+
+这仅展示已有提交语义,**不等于实现 restore_initial**。该调用仍可能运行布局随机化;新适配器必须把同 case 准备与首帧播种按第 7 节接好,不能直接执行旧候选。
+
+commit_env_ids 是被 reset 行的无重复子集,只指定 dataset 保存行,不指定物理 reset 范围;显式提交会绕过默认成功过滤,调用者必须先验收。save_data=False 下其他 camera/trajectory 自动产物需要单独明确提交策略。[现有收集入口](../../embodichain/lab/scripts/run_env.py)
+
+### 8.4 提交确认与训练使用
+
+分别维护 `proposed / planned_valid / rollout_attempted / validated_accepted / pending_write / committed`。只有实际动作才计 rollout;只有收到持久化确认才增加 committed 和正式覆盖。回执按 episode/commit ID 去重并归属原候选,重试写入不重复计数,不能按当前 slot 绑定解释旧回执。最后一批先预留剩余配额,按稳定候选顺序提交,避免超额;失败不靠放宽门槛凑数。
+
+Gym 可复用 DatasetManager 及 [异步 recorder](../../embodichain/lab/gym/envs/managers/async_datasets.py),但需向会话补齐结果确认。直接 sim 使用宿主无关 EpisodeSink,或抽取通用写入器,不伪造现有 Gym recorder 所需 env。Runner 负责 finalize/drain 与错误回传,不依赖 env.close 隐式提交当前 episode。
+
+训练按轨迹家族和父分支分组,时间变体不跨 train/validation;限制近重复、公共前缀和慢速版本的采样权重。完整任务与成功片段分别标识。多模态动作宜使用可表达模式且具时间一致性的策略,或部署时可得的条件;candidate ID、规划 seed 和未来成功标志不能作策略输入。[Diffusion Policy](https://diffusion-policy.cs.columbia.edu/)
+
+## 9. 超参配置与可复现性
+
+### 9.1 配置所有权
+
+公共 `TrajectoryAugmentationCfg` 管理因子、去重和覆盖;`TrajectoryGenerationJobCfg` 包含前者,并管理来源、规划预算、池调度、reset、验证和 sink。均使用项目 `@configclass`。
+
+采用独立 `generation.yaml`,由启动器显式加载;不向现有 env.yaml、Task Program 或 EventManager 暗加语义。模板与执行 profile 拥有硬约束,job 只能收窄范围。长度用米、角度用弧度、时间用秒;关节偏移按各关节有效范围归一化。control_dt 从宿主取得,不由 job 静默覆盖。
+
+### 9.2 配置示例
+
+以下为**待实现 schema**,不能直接交给当前 config_to_cfg。示例输入是阶段标注齐全的手写 EEF 模板;数值是启动实验值,不是验证过的最优默认值。
+
+```yaml
+source:
+ kind: handwritten
+ source_id: fixed_pick_place_waypoints
+ template_id: reference_0
+
+augmentation:
+ seed: 20260905
+ start_state: {mode: provided}
+ factors:
+ contact: {enabled: false}
+ ik:
+ enabled: true
+ max_seeds: 8
+ max_solutions_per_target: 4
+ allow_branch_switch: false
+ approach: {enabled: false}
+ spatial:
+ enabled: true
+ method: via_points
+ variants_per_branch: 2
+ max_via_points: 2
+ position_offset_norm_m: [0.0, 0.03]
+ rotation_offset_norm_rad: [0.0, 0.15]
+ preserve_phase_endpoints: true
+ timing: {enabled: true, duration_scales: [0.8, 1.0, 1.2]}
+ contact_timing: {enabled: false}
+ recovery: {enabled: false}
+ phase_overrides:
+ transit: {operators: [via_points, retime]}
+ approach: {operators: [retime]}
+ grasp: {operators: []}
+ transfer: {operators: [via_points, retime]}
+ release: {operators: []}
+ coverage:
+ descriptor: phase_joint_eef
+ selection: value_per_cost
+ position_bin_m: 0.03
+ rotation_bin_rad: 0.17
+ joint_dedup_normalized_tol: 0.01
+ geometry_samples_per_phase: 32
+ target_per_cell: 4
+ exploration_fraction: 0.2
+ saturation_min_proposals: 512
+ saturation_patience_rounds: 8
+ min_new_cells_per_round: 1
+
+planning:
+ batch_mode: candidate_buckets
+ batch_buckets: [16, 32]
+ cache_budget_mb: 1024
+
+execution:
+ pool_mode: grouped_replicas
+ rollout_slots: 4
+ active_case_limit: 1
+ replicas_per_case: 4
+ scheduler: full_batch
+ duration_bucket_edges_s: [2.0, 4.0, 8.0]
+ ready_low_watermark: 8
+ ready_high_watermark: 32
+ ready_max_bytes: 268435456
+ overlap_planning_and_physics: false
+
+reset:
+ outer_mode: provided
+ inner_mode: restore_initial
+ prepare_profile_id: fixed_scene_initial_state
+ initial_state_tolerances_profile_id: fixed_scene_tolerances
+ on_initial_state_mismatch: error
+
+validation:
+ validator_id: fixed_pick_place_success
+ profile_id: fixed_pick_place_verified
+ motion_limits_profile_id: robot_execution_limits
+ require_path_collision: true
+ require_task_success: true
+ path_length_ratio_max: 1.5
+ duration_ratio_max: 1.5
+
+collection:
+ target_committed_episodes: 256
+ max_proposals: 8192
+ max_rollout_attempts: 2048
+ max_attempts_per_candidate: 1
+ max_wall_time_s: 3600
+
+persistence:
+ sink: lerobot
+ accepted_only: true
+ async_write: false
+ pending_episode_limit: 8
+ pending_max_bytes: 536870912
+ save_audit: true
+ split_unit: trajectory_family
+```
+
+配置语义:
+
+- phase_overrides 只控制许可的局部几何/时间算子;不能启用全局关闭的因子或放宽模板。grasp 的空 operators 不妨碍对固定接触目标求 IK。
+- 偏移在模板声明坐标系内采样;duration_scale > 1 表示延长允许 retime 的阶段,不自动缩放接触等待。
+- 原子来源可开启有声明的 contact;纯 qpos 来源默认不开 IK,除非显式支持 FK 后重新求解。两类来源共享后续配置。
+- `candidate_buckets` 必须有第 5.1 节的候选适配;受限 backend 需显式选 `env_rows`,不能忽略配置。grouped_replicas 必须通过初态复制/恢复能力检查。
+- `outer_mode: provided` 接收调用者给定 case,不自行触发布局采样。若选择 `host_reset`,环境 RNG/布局仍由宿主拥有,每次结果重新归属 case;内层 restore 不推进该布局随机流。
+- 时长桶最后含超出最大阈值的溢出桶;水位、缓存与字节限制同时约束内存。单个请求/episode 超限时明确拒绝或使用预先声明的流式 sink,不突破上限。
+- 多 case 时增加 active_case_limit 并约束总副本数不超过可用槽;各 case 的覆盖、去重独立,job 目标可全局累计,但不能用其他 case 完成声明的局部配额。
+
+### 9.3 校验、随机流与调度重现
+
+加载时拒绝未知字段、非法范围/单位、未注册 source/validator/profile、无效 phase、非正预算、不递增桶边界、不合法水位及超过宿主槽数的副本配置。初态 profile 明确实体位置/旋转、机器人关节/速度和控制器检查;运动 profile 明确每关节限制。仅有名称没有定义时不能启动。
+
+使用显式局部 Generator 和固定 hash 算法派生种子:
+
+```text
+H(job_seed, source_id, source_revision, scene_case_id,
+ candidate_ordinal, operator_id, attempt_id)
+```
+
+不使用 Python 进程随机 hash、物理 env ID 或全局环境 RNG 作为候选身份。纯因子提议应不受 slot 分配与 chunk 改变影响;backend 批大小可能影响数值求解,因此记录实际桶、版本、参数及输出,不承诺逐位一致。
+
+默认按逻辑轮次和 candidate ID 合并反馈,便于对照实验。将来吞吐优先异步模式可按完成顺序更新,但必须记录调度事件用于审计,不能同时承诺与同步模式完全相同的候选序列。预算/历史不因 restore 清零。
+
+## 10. 可选的高级优化
+
+| 模式 | 适用条件 | 必须守住的边界 |
+|---|---|---|
+| 公共规划前缀缓存 | 多候选的前段约束与起始状态相同 | 复用计算不等于复用物理成功证据 |
+| 检查点分叉执行 | 前缀长、分支发生晚,且完整状态可恢复 | 物理、控制器、任务、接触/约束及观测历史一致;验证真实前缀/后缀连接,不只恢复 qpos |
+| 低成本物理预筛,再完整采集 | 失败率高且渲染占主要成本 | 计入二次执行成本;正式采集重新验收,图像与动作必须来自同一次正式执行 |
+| 实际偏离状态的专家恢复 | 需要闭环纠偏且有可靠后缀专家 | 通过真实前缀到达偏离状态;致偏离动作不标为专家动作,恢复边界不混入连续训练窗口 |
+
+[DemoExecutionCfg](../../embodichain/lab/gym/envs/demo.py) 当前明确未提供 checkpoint/resume 协议;片段保存或轨迹状态回放不代表该能力。首版不依赖上述高级模式,尤其不把刚性初态重建和任意接触中途恢复混为一谈。
+
+若公共前缀被多个后缀引用,数据存储和采样要记录共享谱系,避免把重复前缀算作新增独立覆盖。恢复数据可针对学习策略真实访问的状态追加专家监督,借鉴 [DAgger](https://proceedings.mlr.press/v15/ross11a.html) 的分布适配思路;任意随机扰动不具有相同保证。
+
+## 11. 实现缺口、交付顺序与验证
+
+### 11.1 现有基础与待补模块
+
+以下记录当前实现与剩余设计范围。真实验证已包括初态恢复、Panda 动态障碍采样检查,以及普通重力下 UR5 自由运动的执行、实测验收、LeRobot 写入/读回。Panda 手指漂移负例被锁定关节模型检查拒绝。真实 Runner 验证为 B=1、direct-sim、20 个命令,不代表 PickUp、真实 Gym 采集或四组合 M1 已完成;详细命令和验证记录见[实施计划](fixed_scene_expert_expansion_implementation_plan.md)。
+
+| 模块 | 已有基础 | 仍需补齐 |
+|---|---|---|
+| 公共核心与来源 | qpos 模板/候选/配置、严格能力校验、demo 候选输入、原子初始计划供应入口 | EEF via-point 因子、完整原子来源导出与候选消费 |
+| IK 与规划 | MotionGenerator、cuRobo、真实 env_rows 分轮、EEF 显式样本 IK 与已解分支/FK 保留 | 多解枚举/去重、主动绕障路线生成与有界修复、独立候选容量桶与更广 backend 能力 |
+| 路径验证 | free/no-held qpos 加密采样、实测路径重验、运动限值与质量独立 gate | 接触/持物/夹爪变化语义、连续碰撞与任务级 PickUp 验收 |
+| SceneCase 与副本池 | 全批物理初态捕获/恢复/校验、可信 profile、独占 host/epoch、Gym 观测重播种 | 同 case 副本重绑定、接触任务 profile 与长期 settling 验收 |
+| 会话与调度 | 按 case 分区、局部 RNG、几何去重/覆盖、提议/执行/写入配额及条数/字节上限 | 依赖缓存、时长调度、有界异步 pipeline 与成本调度 |
+| sim/Gym 数据闭环 | 手写 qpos Runner、实际命令/观测冻结、同步 LeRobot 封存/读回/确认,真实 UR5 direct-sim 正例 | 真实 Gym/PickUp、原子来源、统一注册/CLI 及四组合 M1 验收 |
+| 逐行异步/分叉 | 部分行级接口、自然片段保存 | 全链路行隔离、独立任务上下文、完整 checkpoint 及连续性协议 |
+
+### 11.2 交付顺序
+
+1. **正确闭环**:公共契约、case/slot 身份、合法初态准备与校验、手写 EEF 模板和 PickUp、sim/Gym 执行记录;全批屏障、明确失败与提交确认。
+2. **优先效率项**:同 case 副本池、候选适配、稳定容量桶、依赖缓存、时长分桶、有界流水线与覆盖预算;与第一步做同预算对照。
+3. **扩大运动覆盖**:多 grasp/多 IK 分支、手写多段和 PickUp → Place,再推广 AxisAlign、Slide、OpenDoor、HandOver 的特定约束。
+4. **按瓶颈选高级项**:行隔离成熟后逐行补位;长公共前缀再考虑分叉;需要闭环泛化时加入真实恢复数据。
+
+首版必须验证四个组合:原子 × sim、原子 × Gym、手写 × sim、手写 × Gym。复用核心不要求四条路径使用相同执行封装。
+
+### 11.3 聚焦回归
+
+| 测试面 | 关键用例 |
+|---|---|
+| 核心边界 | 算法不直接导入 Gym/backend,motion 父包按需加载;缺标注、非法控制表示、无能力配置明确失败 |
+| 候选正确性 | 空 grasp、混合 IK 失败、失败 seed 隔离、实际分支保持/去重、索引 round-trip |
+| 避障扩增 | 固定场景与锚点、不同有效绕行路线、修复后路线去重、最终输出重验、预算耗尽及能力缺失拒绝 |
+| 批量规划 | C ≠ B、固定桶 padding、受限 backend、重复来源行、根坐标与碰撞世界不串用 |
+| 场景与恢复 | 多行不同布局、同 case 多副本、arena offset、恢复失配拒绝、旧 epoch 不执行 |
+| 时间与接触 | 变长 timing/事件一致、时间变体不刷空间覆盖、携物碰撞与阶段接触许可 |
+| 宿主与记录 | 唯一步进、ControllerAction 不重复处理、准备后首帧刷新、占位不写训练帧 |
+| 提交与队列 | reset 前证据冻结、仅提交合格行、writer 失败不计 committed、队列满反压、尾批不超额 |
+| 并发隔离 | planner world 绑定互不污染;一行 reset/清速度/清桥不影响其他行;失败时禁用异步模式 |
+| 学习兼容 | 规划成功物理失败不入专家集,家族 split 无近重复泄漏,恢复/片段边界清楚 |
+| 旧行为 | 默认在线逐环境单 winner、唯一 env IDs、ActionPlan 效果与恢复协议不变 |
+
+聚焦验证可从 [Atomic Actions](../../tests/sim/atomic_actions/)、[MotionGenerator](../../tests/sim/motion/planners/test_motion_generator_batched.py)、[BasePlanner](../../tests/sim/motion/planners/test_base_planner.py)、[Demo 执行](../../tests/gym/envs/test_demo.py)、[Dataset recorder](../../tests/gym/envs/managers/test_dataset_functors.py) 扩展。纯张量单测之后,再做无 GUI 的真实执行验证。
+
+### 11.4 效率与学习效果验收
+
+在同一组外部给定 case、相同副本总数、相同传感器/控制周期、质量门槛和每 case 数据目标下,逐项比较:基础全批 → 缓存/容量桶 → 同场景分组/时长分桶 → 流水线 → 可选逐行补位。不要用改变 case 数量或降低验收标准制造吞吐提升。
+
+记录规划有效率、物理接受率、几何/时间去重率、committed/min、覆盖增益/min、首次达到覆盖目标的时间;同时拆分 reset/settling、IK/规划、物理、渲染、拷贝/写盘、屏障等待、warmup 和显存峰值。固定形状与最小压紧、单 GPU 串行与重叠都需做消融,先优化实测瓶颈。
+
+最终在相同数据预算下比较模仿学习成功率,并保留未见过的起点/运动参数组合进行评估。固定场景实验只能证明相应运动分布上的收益,不据此宣称布局泛化或连续可达空间已完全覆盖。
diff --git a/docs/design/fixed_scene_expert_trajectory_augmentation_implementation_plan.md b/docs/design/fixed_scene_expert_trajectory_augmentation_implementation_plan.md
new file mode 100644
index 000000000..3be40342e
--- /dev/null
+++ b/docs/design/fixed_scene_expert_trajectory_augmentation_implementation_plan.md
@@ -0,0 +1,351 @@
+# 固定场景专家轨迹扩增:实施计划
+
+- 状态:实施中;手写自由运动与离线原子 PickUp 的 direct-sim 采集闭环已落地,完整 Atomic Runtime/Gym/M1 仍待实现与验收。
+- 依据:[设计文档](fixed_scene_expert_expansion_design.md),2026-09-05。
+- 代码核对基线:`fb7228e2`。首批实现基于该版本,已运行 CPU 行为测试;尚未完成四组合真实仿真和性能验收。
+- 交付原则:先完成可验收、可持久化的四条执行路径,再优化吞吐,最后扩大运动覆盖。
+
+## 当前实施进度(2026-09-05)
+
+| 计划项 | 已实现 | 尚待实现与验收 |
+|---|---|---|
+| PR 1 | `lab.sim.motion.expansion`:qpos 值对象、nested 配置严格校验、GenerationSession、局部 RNG、候选与提交身份、条数/字节预算、实际关节几何去重与写入配额 | EEF 原始模板和宿主能力注册装配随实际适配器接入 |
+| PR 2 的初态基础 | 全批初态复制、恢复、验证与独占 host;Gym 准备/重播种;UR5 自由运动 profile;新增四行 PickUp profile 和抓取结束后的整批循环恢复 | 任意接触 checkpoint、逐行恢复和复杂 settling 仍不支持;Gym PickUp 待验收 |
+| PR 4 的基础算子与自由段适配 | 显式自由段 `joint_residual`、retime 与阶段索引重映射、采样速度/加速度检查;`EEFPath / EnvRowMotionPlanner` 的真实 env_rows 分轮、显式 EEF 样本 IK、已解分支保留/FK 验证、自由 qpos 分段加密碰撞检查 | EEF via-point 因子生成、接触/持物/夹爪变化的完整碰撞语义与真实 backend 任务验收;离散路径检查不代表连续碰撞证明或物理成功 |
+| PR 5 的手写 qpos/free-motion 闭环 | `GenerationRunner / MotionLimitsProfile / QposRolloutExecutor` 连接 profile 身份、全批初态、sim/Gym 实际命令/观测冻结、规划与实测验收、Session 配额和 sink 确认;真实 UR5 direct-sim 正例 committed 1,Panda 锁定关节漂移负例 committed 0 | 手写 EEF/PickUp、真实 Gym 任务采集及多行/多 episode 任务验收;当前结果不代表 PR 5 全部原定验收或 M1 完成 |
+| PR 6 的候选输入与离线模板 | 既有 `initial_plan_provider` 入口;新增 `export_pickup_templates`,从 MoveEndEffector → PickUp 离线编译导出受保护阶段、mimic 几何和实测保持段;接入 Runner 的离线 qpos 采集 | 候选到新的运行时 ActionPlan 的适配、Atomic Runtime tracking/recovery 和 Task Program/Gym 四组合接线;离线导出不代表这些完成 |
+| 新增 PickUp 接触闭环 | `PickUpMotionValidator`:完整 URDF 碰撞凸包、mimic 与持物几何、分阶段接触规则、CPU 物理子步接触证据、抬升到保持的相对漂移和双指接触验收;`cube_pickup_collection.py` 四行多轮采集,保存视频和确认后的 LeRobot 数据 | 仅固定基座未缩放 URDF + 盒状刚体 + CPU 物理;通用 mesh 场景、连续碰撞、Gym 子步接口另行扩展 |
+| PR 3 的同步保存基础 | `ExpertEpisode` 与 `CommitReceipt`;Session 预留/确认/失败重试;LeRobot 分片封存/真实回读;新增数值矩阵展开及原始 shape/终态保留;确认提交只读幂等核验 | 重启恢复、异步 pipeline 等后续范围;完整四组合真实采集仍需 PR 6 验收 |
+
+当前 `restore_initial` 使用显式全批物理状态适配与可信 profile,不调用普通 reset;同步 sink 只有封存并读回真实文件才确认提交。手写 qpos 的宿主证据冻结、实际控制标签、Runner/Session/sink 已接通,并通过普通重力下 UR5 自由运动真实采集。新增 direct-sim PickUp 的受限接触/持物检查和任务 profile;统一配置注册/CLI、完整原子运行时来源和 M1 四组合发布门槛仍未完成。当前示例显式构造可信服务;配置 ID 本身不代表服务已经注册。
+
+新增 API、测试和现有行为变更已同步对应文档及 agent context。下一步接入 Atomic Runtime 的候选消费与等价物理子步证据,再推进 Gym PickUp、via-point 因子和统一配置启动器;当前离线回放不扩大为完整原子运行时或 M1 四组合能力声明。
+
+模块归属 review 后,`solvers`、`planners`、`workspace` 与 `expansion` 统一归入 `embodichain.lab.sim.motion`。公共 API、测试、示例和文档使用新路径,不提供旧路径兼容包。`motion` 父包仅按需加载子包;扩增算法保持无直接 Gym/仿真 backend 依赖,但其公共导入不再承诺绕过 `lab/sim` 初始化。该调整不改变下述 M0/M1 验收要求或未完成状态。
+
+首批验证记录:核心、原子动作与 Gym/Task Program 组合回归 `1006 passed, 1 skipped, 3 deselected`;补完前置写入容量预留和数值边界后,最终核心测试 `138 passed`。全仓 Black 检查通过,API 文档覆盖 `1718/1718`。Sphinx dummy 构建成功,本次新增模块无相关告警;其他模块仍有文档告警。这些是代码契约验证,不替代 M0/M1 的真实采集验收。
+
+motion 重组回归:`1246 passed, 113 deselected`(排除 requires_sim/gpu/slow),独立进程的新导入和配置解析测试 `3 passed`。PR 2 物理适配 CPU 契约测试 `35 passed`;真实 headless CPU backend 的物理关闭/开启两种模式 smoke `2 passed`,以单关节 robot 与 cube 检查当前状态/drive target/速度/effort 的捕获和恢复,不覆盖接触抓取或长时间 settling。Gym 与宿主集成测试 `119 passed`;相邻 `root_articulation` getter 的 `root_link_name` 修复及回归中,no_sim 集合 `7 passed`。这些集合分别记录,不相加为去重总数;参考 UR5 PickUp 的四组合真实采集验收仍未完成。
+
+PR 3 同步 sink 测试 `17 passed`:真实 LeRobot 数值/RGB/scalar 与 float64 精度读回、终态/T+1 时间 sidecar、writer/evidence/manifest/verify 失败重试、partial 重建、confirmed duplicate 只读以及时钟/容量拒绝。PR 4 首轮 env_rows/IK/碰撞适配 CPU 契约测试 `14 passed`;扩展后的 planning + cuRobo planner CPU 回归为 `82 passed, 1 skipped, 3 deselected`,覆盖分轮 scratch、float64 保留、锁定关节 cache 失效及实测路径重验。真实单 Panda、CPU physics + CUDA cuRobo 0.8.0 smoke `1 passed`:相同 backend 对远处动态方块的短 free 路径返回 passed,将方块移至 TCP 后返回 failed。这不包含接触抓取、持物扫掠或 M1 四组合验收。
+
+PR 5 组合回归 `1379 passed, 1 skipped, 118 deselected`;补充有界 `last_failures` 审计后,Runner/executor 联合测试 `70 passed, 1 skipped, 1 deselected`。真实物理开启的 CPU 单关节 robot + static cube 执行器 smoke `1 passed`;真实 Runner 测试 `2 passed`,分别验证 UR5 接受并确认 1 条、Panda 漂移拒绝且提交 0 条。示例命令独立运行 exit 0,UR5 在 1 秒内记录 21 个实测观测/20 个命令,全部 gate 通过并读回 LeRobot。全仓 Black 检查通过(867 个文件)。以上集合有重叠,不相加;真实验证范围仍为单行 direct-sim free motion。
+
+上一轮文档校验:公共导出覆盖 `1751/1751`,checker 测试 `8 passed`,Sphinx dummy 构建成功。本次新增 API/指南没有相关告警;构建仍报告其他既有文档的 46 条告警。`git diff --check -- docs` 通过。
+
+### 本轮 PickUp 实施范围
+
+- 来源通过 `AtomicActionEngine.compile` 复用现有 MoveEndEffector/PickUp 规划;`export_pickup_templates` 保留接近/闭合/抬升边界,展开 mimic 坐标,并加入 30 个真实保持命令。只增强 transit,不改变接触时序。
+- 碰撞适配使用 URDF **碰撞**形状的保守凸包和完整关节 FK,包含指尖、mimic、随 TCP 移动的目标盒体和隐式地面;实测重验使用真实目标位姿。两条关节边以内的结构自碰撞排除沿用 cuRobo 规则。验证有界采样,不声明连续碰撞保证。
+- CPU 每个 5 ms 子步读取原生接触,限定指尖/目标/支撑/固定安装部位的合法组合与接近段进入距离,拒绝未知/跨行/非法/过深接触和数据容量溢出。保持阶段双指真实接触比例至少 95%,抬升至少 12 cm;从抬升到保持检查 TCP 相对位置/转角漂移,避免把掉落或滑移数据提交。
+- 例子固定机械臂刚度 200000、手指打开目标距下限 1 mm,保留 0.05/0.08 rad 的末端/路径跟踪门槛。普通重力下并行执行 0°/90° cube 抓取,整批恢复后继续;达到 confirmed committed 目标才成功退出。
+- LeRobot 新增数值矩阵支持,训练帧按 C order 展开为向量,`observation_shapes` 保存原始形状,终态仍是矩阵;`commanded_joint_indices` 标记实际下发的 active 列。每轮视频包含真实初态与终态,拒绝的尝试也保留在视频和审计中。
+- 本轮没有消费 `initial_plan_provider`、提交预测符号效果或替代 Atomic Runtime 的 tracking/recovery。后续需将这些运行时契约与新增物理验收接通,才能完成 PR 6 的完整目标及 M1 四组合。
+
+本轮同步 main 的显式物理步进 API 后,motion、原子动作、采集、Gym 初态与 demo、仿真管理器及 gizmo 组合回归为 `1542 passed, 1 skipped, 168 deselected`;真实并行采集、异常退出清理和原有视频示例为 `3 passed, 1 deselected`。四行两轮共 proposed/attempted/committed `8/8/8`,每条保持 1.5 秒、双指接触覆盖 100%,最小抬升 17.90–17.92 cm,相对位置最大漂移 0.35–0.73 mm;LeRobot 和 544 帧 H.264 视频均已真实回读。全仓 Black、`git diff --check` 通过;API 覆盖 `1761/1761`、checker 测试 `8 passed`,Sphinx dummy 构建完成,仍有文档告警。这些测试集与前述记录有重叠,不相加。
+
+当前已实现部分合并为一个 draft PR 提交 review。下文的 PR 编号保留为实施计划的工作划分,不表示这些 PR 已独立创建或全部验收。
+
+## 1. 实施范围与里程碑
+
+| 项目 | 首阶段 M1 | 后续阶段 |
+|---|---|---|
+| 参考任务 | 单臂、刚体 cube 的 PickUp:接近、闭合、抬升及保持;手写 EEF 和原子来源使用同一任务条件 | PickUp → Place、手写多段及其他技能 |
+| 参考 embodiment | 建议复用 UR5 + DH PGI 配置,先实测基准与碰撞能力 | Franka 用于第二 embodiment 回归;双臂独立扩展 |
+| 来源 × 宿主 | 手写 × sim、手写 × Gym、原子 × sim、原子 × Gym,四个组合全部通过 | 参数化工厂和更复杂的 Task Program |
+| 扩增 | 已标注自由段的少量 via points、合法 retime;固定接触目标,先保留一条有效 IK 分支 | 多 grasp、多 IK、approach、contact timing |
+| 起点与 reset | `provided` 起点;无中途接触约束的 episode 初态重建、校验;布局由宿主提供 | 部署允许的多起点;checkpoint 与 recovery 另立项 |
+| 执行与规划 | 先 `B=1`,再多行不同 case;`full_batch` + 显式 `env_rows` 分轮 | 同 case 副本、`candidate_buckets`、时长调度 |
+| 保存 | 同步 sink、逐提交确认、仅接受合格 episode;从第一版限制队列条数和字节 | 有界异步写入及 CPU 整理与执行重叠 |
+
+M1 的 PickUp 成功表示完成所声明的抓取任务,不表示已经实现完整 pick-and-place。现有 repeated_pick_place 部署可提供场景与 embodiment 参考,但需要单独准备验收 fixture,不能把现有 open-loop 执行结果直接视为专家质量基准。
+
+设计第 9.2 节的完整 YAML 包含 M2/M3 能力。实施时分别提供各里程碑可运行的 `generation.yaml`;未实现的模式和启用因子必须在启动前报错,不能静默降级。
+
+## 2. 实施前代码基线与实施影响
+
+| 已核对事实 | 实施影响与主要落点 |
+|---|---|
+| `lab/__init__.py` 会导入 sim/Gym 等子包 | 公共核心归属 `lab/sim/motion/expansion/`;验证算法无直接 Gym/backend 依赖,以及 motion 父包按需加载的边界 |
+| `configclass.validate()` 主要检查 `MISSING` | 配置加载还需显式校验未知字段、范围、单位、交叉约束和宿主能力;不修改全局 configclass 语义 |
+| [PickUp](../../embodichain/lab/sim/atomic_actions/primitives/pick_up.py) 提前筛掉 grasp 分支,预筛 IK 结果未直接用于后续轨迹 | 候选保留与已解关节目标传递需要新增接口;空 grasp 行也要覆盖 |
+| [BasePlanner](../../embodichain/lab/sim/motion/planners/base_planner.py) 批量校验绑定 `robot.num_instances` | 首版使用 `env_rows`;C 与 B 解耦由独立适配层完成,不能仅展平张量 |
+| [ExecutionSession](../../embodichain/lab/sim/atomic_actions/execution.py) 启动时重新规划 | 需要正式的候选消费入口,并保留既有 tracking、效果验证和 recovery 协议 |
+| [execute_demo_episode](../../embodichain/lab/gym/envs/demo.py) 没有显式候选 segments 参数 | 新增明确输入,保留旧 hook 调用;不临时替换环境方法 |
+| [BaseEnv.reset](../../embodichain/lab/gym/envs/base_env.py) 先恢复物理对象再调用 dataset 保存;[EmbodiedEnv.reset](../../embodichain/lab/gym/envs/embodied_env.py) 清空活动任务桥并播种首帧 | 旧 episode 验收证据必须先冻结;初态准备与观测重播种需要独立生命周期入口 |
+| [AsyncLeRobotRecorder](../../embodichain/lab/gym/envs/managers/async_datasets.py) 队列无界,没有逐 commit 回执 | 同步提交协议先行;之后补队列反压、完成回执和错误传播 |
+| 本机 LeRobot 0.4.4 的 `save_episode()` 写数据后仍保持 Parquet writer 打开,`finalize()` 才完成 footer 等收尾;项目 recorder finalize 后不能继续复用 | 提交确认需要明确封存屏障与读回检查;不能将 episode 写入函数返回作为已持久化 |
+| CLI 实际注册位于 [cli/main.py](../../embodichain/cli/main.py),`__main__.py` 仅转发 | 新启动器在现有 dispatcher 注册,保持根命令的懒加载 |
+
+## 3. 模块与契约归属
+
+按模块归属 review 采用以下布局;未标为已实现的生成宿主部分仍为后续实施计划,只有职责变复杂时再拆文件:
+
+```text
+embodichain/lab/sim/motion/
+ __init__.py # 按需暴露四个子包,不汇总其类和函数
+ solvers/ # FK、IK、微分运动学
+ planners/ # 路径、碰撞约束、时间参数化
+ workspace/ # 离线可达性分析、缓存与运行时采样
+
+embodichain/lab/sim/motion/expansion/
+ contracts.py # case/snapshot/template/candidate/episode/receipt 与端口协议
+ cfg.py # 两类 @configclass;只持配置值及注册 ID
+ operators.py # 因子提议、自由段几何和时间变体
+ coverage.py # 几何家族、去重、覆盖计算
+ session.py # case 分区、预算、候选池、配额预留、结果归账
+
+embodichain/lab/trajectory_generation/
+ initial_state.py # 已实现:可信 profile、全批 host、PreparedBatch epoch
+ runner.py # 已实现:手写 qpos/free-motion 同步编排与 generation_report
+ execution.py # 已实现:sim/Gym qpos 执行、实际标签与因果证据冻结
+ config.py # generation.yaml 严格加载、可信注册与能力检查
+ integrations/
+ planning.py # 已实现 free/no-held EEF/qpos 的 env_rows 适配与采样检查
+ handwritten.py # 显式模板与 DemoSegment 适配
+ atomic.py # 已实现:离线 PickUp 阶段模板;运行时 ActionPlan 适配另行落地
+ contact.py # 已实现:PickUp 完整几何和 CPU 物理子步接触验收
+ sim.py # 已实现物理初态适配;rollout 控制/计时/观测归 execution.py
+ gym.py # Gym 初态、正常 env.step、demo/Task Program 桥
+ validation.py # 注入路径检查与实测任务/质量验证
+ sinks.py # 已实现同步 LeRobotEpisodeSink,已接入真实自由运动采集
+
+embodichain/lab/scripts/generate_trajectories.py # 拟新增启动器
+```
+
+接口 review 重点:
+
+1. `SceneCase / initial_state_id`、`candidate / family / attempt`、`slot / runtime_epoch` 三组身份独立;arena 平移通过坐标映射处理,不能进入候选随机种子。
+2. 快照及候选不持有活环境或可变 backend;明确张量所有权,避免表面只读而底层数据仍被宿主更新。
+3. `CandidateTrajectoryBatch` 使用 `(C,N,D_full)`、逐行有效长度和显式时间。固定首点时间、到达间隔、padding 与阶段事件区间语义;工具事件、阶段、失败状态与候选索引一同映射。
+4. Session 只接收快照与结果;Runner 管生命周期;sim/Gym 各有一个步进所有者。Gym 的控制周期取 `env.step_dt`。
+5. 原子候选执行建议采用受限的“初始计划供应端口”:绑定新的 request/context 后消费指定候选的完整初始计划;后续 recovery 保留既有机制。具体签名在 PR 1 固定,不能通过写私有 session 字段实现。
+6. `EpisodeSink` 的提交输入有稳定 episode/commit ID;回执携带原候选身份。定义何时主数据、必需媒体及谱系均完成持久化,enqueue 或单个 writer 函数返回均不自动算 committed。
+7. source、validator、prepare、tolerance、motion-limit profile 使用可信注册 ID;YAML 不执行任意 dotted import。模板和 profile 的约束只能被 job 收窄。
+
+能力声明分别描述多 seed、all-solutions、关节分支保持、精确路径验证、独立候选 batch、初态复制和持久化屏障,不能用一个通用 `supports_batch` 布尔值替代。M1 先支持明确的 qpos 控制/标签 profile 和工具事件;其他表示需先实现并验证转换。
+
+## 4. 实施顺序与 PR 划分
+
+以下编号用于 review 和依赖规划,不表示本次创建 PR。每个 PR 随实现补对应测试与公共 API 文档。
+
+### M0:建立可执行基准与能力清单
+
+**交付内容**
+
+- 固定一个外部给定的 cube case、机器人初态、工具标定、控制周期和传感器设置。
+- 定义真实成功判据:物体按任务要求抬升并保持、抓持关系有效、无掉落;容差、持续时间、跟踪误差和质量上限均有数值及单位。
+- 验收未扩增的手写 EEF 基准与原子 PickUp。记录路径长度和实际时长,建立同起点、同任务/模式的 ratio 比较基准。
+- 检查路径碰撞、阶段接触许可、持物几何、初态恢复和 writer 持久化的实际能力。缺项列为 M1 必须补齐的工作。
+- 加入基础计时,保存规划、reset/settling、物理、渲染、写盘时间,后续优化沿用同一对照条件。
+
+**退出条件**:基准能重复运行;未支持的必需验证有明确实现方案。不能用关闭检查的方式进入专家数据采集。
+
+### PR 1:公共契约、配置和最小 Session
+
+**依赖**:M0 的任务与 profile 定义。
+
+- 新增公共值对象、端口、两类配置及严格解码;固定状态/时间/动作表示、候选消费和提交确认协议。
+- 实现 case 分区、稳定 ID、本地 RNG、规划/rollout/时间预算及基本有界候选池。状态区分 `proposed / planned_valid / rollout_attempted / validated_accepted / pending_write / committed`。
+- 实现最小的家族归属、去重与配额预留;同一路径的 timing 变体共用 geometry family,不增加空间覆盖。复杂价值调度留到 PR 9。恢复初态不清空 job 历史。
+
+**验收**:纯 CPU/张量测试;算法无直接 Gym/backend 依赖,motion 父包按需加载子包;slot/chunk 改变不改变纯因子提议;时间变体不刷空间覆盖;重复回执、失败及尾批不会重复计数或超额;未知/无能力配置拒绝启动。
+
+### PR 2:sim/Gym 初态准备与恢复生命周期
+
+**依赖**:PR 1。
+
+**当前落地**:`SimInitialStateAdapter` 严格支持一个配置所有的固定基座 robot 及完整批次普通刚体,保存关节/根/物体状态、速度、drive target 与关节 effort,并在任何写入前检查完整 schema、结构签名、SE(3) 和关节限位。`FixedSceneHost` 以调用者提供的各行 case 建立初态,恢复前使旧 binding 失效,经过可信 profile 准备、物理恢复、settling 和验证才发布新 epoch。profile 还必须覆盖物理快照之外的控制历史、任务状态与固定物理/视觉/传感器条件。
+
+Gym 接口增加全批 generation lease,租约内拒绝普通 reset 并抑制自动 reset;Gym 和纯 sim host 登记同一 simulator batch 所有权,不允许两个不同 owner 并存。`prepare_generation_episode()` 先丢弃旧录制,再依次准备、恢复、settle、清空 standard episode manager 历史、verify,确保 validator 看到新 episode 的 manager 状态;验证通过后才获取首观测并播种记录。失败时禁止继续 step。调用者必须在进入该生命周期前取得旧 episode 的独立证据。该接口不自动保存数据,不调用 reset event 或重播种环境 RNG,也不声明任意 controller 已被默认覆盖。
+
+物理根位姿 setter 可能推进整个 world,因此恢复先写根,再恢复所有其余状态;异常不会伪装成成功。已通过物理关闭/开启两种模式的真实 CPU backend 状态恢复 smoke;该测试与 mock 生命周期验证均不替代参考任务在接触和长时间 settling 下重复恢复的验收。
+
+- 实现 `acquire_case / restore_initial / verify_initial` 与 slot epoch。先完成单槽,再覆盖全批中每行不同 case。
+- 启动检查要求 Runner 独占整个被 reset 的宿主 batch,并在采集期间关闭隐式 auto-reset;不能让外部活动行被本 job 的恢复连带重置。
+- 明确恢复 robot/rigid-object 状态和速度、夹爪与控制目标、任务/manager 初态;经过 settling 后验证。首版不支持的实体或初始约束明确报错。
+- Gym 增加受控准备入口:旧证据冻结与提交载荷取得 → episode 清理 → 所需确定性初始化/状态恢复 → settling → standard episode manager reset → 验证 → 刷新观测 → 播种首帧 → 新 binding。
+- 显式区分改变布局的随机事件与必需初始化。inner restore 不消耗外层布局 RNG;运行中改变 case 固定条件的事件须受宿主策略约束。
+
+**验收**:多次恢复后物体/关节/速度/控制状态符合 profile;观测首帧对应恢复后的现场;准备段不写训练帧;失配和旧 epoch 拒绝执行;全批 reset 不影响其他独立宿主。
+
+### PR 3:episode 冻结、同步 sink 与提交确认
+
+**依赖**:PR 1;与 PR 2 可并行开发。
+
+**当前落地**:`LeRobotEpisodeSink` 接收已冻结且验收通过的 `ExpertEpisode`,每条独立封存一个本地 LeRobot dataset shard。主数据保留 T 个因果训练帧,`terminal.npz` 保留末次观测和全部 T+1 个实测时间,`episode.json` 保存谱系、动作表示、验证、阶段和 metadata;collection manifest 才是已提交片的目录。同步写入完成后重新打开并读取每帧/图像、全部必需 sidecar 与 manifest,成功后返回 confirmed receipt。
+
+首版只接受固定整数 fps、支持的数值观测向量与 uint8 RGB、float32/float64 动作;实测时间必须在绝对容差内符合宿主固定时钟。单条 payload 有 raw tensor + metadata 字节上限。输入错误在 episode 写入前抛出;持久化失败返回原身份的失败回执。相同 commit ID 必须携带相同 payload:已 confirmed 时只读核验,不重写产物;尚未确认时可复用已封存可读片,未提交且不完整的片在原路径重建,不新增逻辑 episode。数值读回检查 Parquet 原始精度,RGB 通过 LeRobot 解码。`drain()` 没有延迟回执,`close()` 不隐式追加保存。仅支持新/空输出目录和当前进程的单写入者,不声明重启恢复、跨文件事务或断电持久性。
+
+`QposRolloutExecutor` 已冻结真实 rollout 的 T+1 观测、T 个实际 qpos 命令和实测时间;`GenerationRunner` 完成后续实测验收、Session 归账和 sink 提交。sink 只消费调用者提供的验证,不重新计算碰撞或任务成功。真实 UR5 正例已完成整个写入/封存/读回链路。
+
+- 定义统一 `ExpertEpisode`、候选谱系和实际动作转换 profile;复用已有因果配对与有效帧标记。
+- Gym 使用 DatasetManager/LeRobotRecorder 适配;sim 使用宿主无关 sink。优先抽取可复用的序列化/写入部分,避免模拟 Gym env 对象。
+- 补逐 episode 的提交结果。主记录、媒体和 metadata/sidecar 部分成功时能识别已写结果,重复提交同 commit ID 不生成第二条逻辑 episode。
+- 回执按提交身份归账;只有持久化确认后更新正式覆盖。Runner 明确调用 drain/finalize 并传播错误。
+- 建议 LeRobot 从同步版本就采用有界分片:达到 pending 条数/字节上限或本轮剩余配额后停止接收,封存当前 recorder,读回主数据/媒体/sidecar,再发回执;需要继续时创建下一分片。job manifest 保存 commit 到分片/episode 的映射,封存期间 pending reservation 不释放。收尾失败时保留未完成状态并回传错误,不重复写主记录。
+- 首版持久化语义固定为所有必需产物已关闭、可重读并完成错误检查;进程崩溃恢复、断电持久性和跨文件事务另行定义,不暗含在普通回执中。
+
+**验收**:reset 后仍能保存旧 episode 的冻结证据及末次 observation;拒绝行不写专家集;覆盖只取已提交的实际轨迹;注入 writer/sidecar 失败后不误计数;重复提交与迟到回执可正确处理;真实 LeRobot 完成一次写入→封存→重新打开并读取的验证。大于单片容量的 job 能持续推进,pending 满额不死锁。
+
+### PR 4:候选规划、时间与验证的最小公共路径
+
+**依赖**:PR 1;与 PR 2/3 可并行开发。
+
+**当前落地**:`EEFPath` 保存 local arena-frame TCP 的显式样本、初始锚点、arrival intervals、来源行及阶段;可提供 control-part 顺序的 solved qpos,保持分支并经 FK 验收,不重复 IK。`EnvRowMotionPlanner` 把 C 个候选按真实 B 行分轮,每轮同一来源行至多处理一个候选;返回原序且来源身份对齐的 full-joint 候选与逐候选验证。自由 qpos 路径按 joint step 上限在相邻样本和阶段边界间加密,交给支持精确关节样本验证的 backend 检查。
+
+当前只支持完整 `free` 标注、显式无持物,且全部未受控关节保持 snapshot 与 `robot.cfg.init_qpos` 的初值。contact/hold/未标注路径、持物、夹爪等锁定关节变化、无 backend 能力或采样超过容量返回 `unavailable`;碰撞和非法路径返回 `failed`。结果仅提供 `path_collision` 证据;实际速度/加速度、任务成功、接触与物理质量仍是独立 gate,不把离散加密解释为连续碰撞证明。
+
+- 实现显式 `env_rows`:任意 C 按真实 B 分轮,一轮每个物理来源行至多处理一个候选,其余安全占位;维护候选到 snapshot/root/world 的映射。
+- 手写 EEF 走注入的 IK/规划端口;支持固定已解关节目标及分支连续性约束。未标注 qpos 默认仅原样执行;未受控关节保持原值。
+- 首版每个自由段只用一种 via-point 算子;retime 后按宿主周期重新采样,重算事件索引、速度/加速度与阶段连接。
+- 实现所选任务必需的路径检查:自碰撞、环境、段间路径、阶段接触许可、持物扫掠;复用 `MotionGenerator.validate_joint_trajectory()`,补足其不覆盖的部分。
+- 记录 `not_run / passed / failed / unavailable` 和连续指标;必需项 unavailable 的候选只能进入规划库。
+
+**验收**:C≠B 映射往返、空输入、失败 seed、NaN/Inf、padding 隔离、固定端点、实际 IK 分支、变长时间和事件顺序均正确;故意碰撞或携物扫掠碰撞被拒绝。
+
+### PR 5:共享 Runner 与手写来源端到端闭环
+
+**依赖**:PR 2、3、4。
+
+**当前状态:handwritten qpos/free-motion 同步闭环已落地。** `GenerationRunner` 与 `MotionLimitsProfile` 要求显式传入现有 host、planner、executor、sink 和可信限速配置,对齐 source/template/profile/validator/tolerance ID 与 control_dt/fps。运行入口接收每个物理行的固定 case 和 qpos reference,提议允许的残差/retime,规划检查后先预留配额与容量,再执行。`QposRolloutExecutor` 采集实际命令与实测观测/时间,冻结证据后由 Runner 重验实际碰撞、速度/加速度、路径/时长质量和任务成功,再提交。`generation_report.json` 记录配置、包版本、计数、audit 与目标达成状态;`last_failures` 将无完整 transition 等失败原因有界保留到 audit。Runner 为 single-use,并在退出时关闭传入的 host/sink。
+
+自由运动路径使用原 `EnvRowMotionPlanner` 的锁定关节约束。新增 PickUp 路径必须将同一个 `PickUpMotionValidator` 传入 Runner 和 executor,才能允许目标物体与 mimic 运动并采集物理子步证据。它接收离线原子模板;完整 Atomic Runtime、Gym PickUp、M1 和统一 CLI 仍未完成。
+
+真实正例使用纯 arm UR5、普通重力、CPU physics + CUDA cuRobo、B=1:1 秒内 21 个观测/20 个命令,实际位移约 `0.078021 rad`、终点误差 `0.003212 rad`、最大跟踪误差 `0.010872 rad`,全部 gate 通过且 LeRobot 读回 confirmed,`committed=1`。Panda 默认重力负例中 task/quality/dynamics 通过,但最大手指漂移约 `3.7853e-5` 超过 locked model 的 `1e-6` 比较阈值,实测路径验收 unavailable,`committed=0`。候选不发出夹爪变化命令不能代替实测锁定关节一致性。两项真实测试均通过;未据此验收真实 Gym 采集、多行或连续多 episode。
+
+在仓库根目录、已安装仿真/CUDA/cuRobo/LeRobot 依赖时运行:
+
+```bash
+python examples/sim/motion/trajectory_generation/free_motion.py --output /tmp/ur5-free-motion
+pytest tests/lab/trajectory_generation/test_runner_real.py --run-gpu -m gpu -q
+```
+
+输出目录必须新建或为空;示例显式构造与隐式地面匹配的 `ground_proxy`,全部普通刚体均动态更新碰撞 pose。成功输出 `generation_report.json`、已提交 `manifest.json` 和每条 episode 的 LeRobot dataset/`terminal.npz`/`episode.json`。`--robot panda` 可在另一个空目录运行上述拒绝案例;未达目标返回 exit 1。这是具体示例脚本,统一 generation CLI 仍待 PR 6。
+
+Runner 要求全部普通刚体的物理 UID 同时存在于 backend 的 collision-world 与 dynamic-pose ID 集合,每轮以捕获的各行初态更新;这里 dynamic 表示 backend 可更新 pose,不要求物理刚体为动态。仅烘焙静态 world 无法保证准备后姿态或多行不同 case 一致;B>1 且相对布局不同时需 per-environment backend world。语义 aliases 当前不接入该 Runner,隐式平面等未进入刚体注册表的几何由可信 profile 认证碰撞覆盖。executor 的逐 episode 字节上限必须不超过 sink 与 pending 上限。
+
+- 在 demo executor 增加显式候选 `segments` 输入,与旧 demo hook 路径做明确互斥/选择;支持 iterator 按需消费,不提前耗尽含反馈的旧生成器。
+- Runner 编排规划、准备、执行、验收、提交;实现 `full_batch`,完成行安全 hold,停止生成训练帧。纯规划失败不 reset。
+- 手写 × Gym 使用正常 `env.step()`;控制器目标走 `ControllerAction`,不重复 action pre 处理。手写 × sim 独立完成控制、整数 physics substeps、传感器和记录。
+- 候选结束前采集任务/质量证据;需要持续成立的效果在批次收尾时再检查。终态证据冻结之前不得触发自动或外部 reset。超时和取消关闭新工作并收尾在途数据。
+
+**验收**:两条手写路径执行的是指定候选;记录满足 `observation_t → action_t → observation_{t+1}`;无重复步进、旧首帧、padding/hold 污染;即使 done/truncated 也先冻结再 reset;无法取得全批生命周期所有权时拒绝启动;连续采集多条已确认 episode。
+
+### PR 6:原子 PickUp 接入、启动器与 M1 发布验收
+
+**依赖**:PR 4;端到端合并验收依赖 PR 5。
+
+- 原子适配器导出 PickUp 阶段模板,首版固定合法 contact、保留一条有效分支;候选绑定到当前 case/slot 后才构造新的请求和 ActionPlan。
+- 从 PickUp 抽出候选生成与计划物化的共享逻辑;空 grasp、padding 与失败 seed 隔离在首批实现中修复,不复制另一套 PickUp 算法到扩增器。
+- 为现有原子执行增加正式的候选消费端口;sim 复用 ExecutionSession/Runner,Gym 经 Task Program 命令桥下发,保持效果验证和命令语义。
+- Gym 保留桥的正常耗尽、post-policy、validator 和 abort handshake;命令数组执行完不能独立替代任务验收。
+- M1 不采集恢复专家数据:若既有 engine recovery 被触发,沿原协议安全收尾,只保留 audit 并拒绝该 episode 进入专家集。专用恢复数据留待具备边界隔离和标签协议后扩展;在线默认逐环境选一个 winner 的调用保持原行为。
+- 新增独立启动器,建议命令名 `generate-trajectories`,显式加载宿主配置与 `generation.yaml`;注册到 `cli/main.py`,产出已解析配置、版本、audit 与最终计数。
+- 提供 M1 四组合 fixture 与可运行示例;同步公共 API 文档和受影响项目上下文。
+
+**M1 验收门槛**:四组合都用无 GUI 真实物理运行;建议每组合提交 8 条合格 episode,另覆盖规划失败、物理失败、恢复失配、写入失败和尾批场景。8 条仅为工程烟测规模,不作为数据效果结论。不得以预算耗尽或命令完成替代成功标准。
+
+### PR 7:同 case 副本池
+
+**依赖**:M1。
+
+- 实现 `grouped_replicas`、副本容量检查、case 分配及跨 arena 坐标变换;只在整批 episode 收尾后重绑定。
+- 同 case 副本共享不变输入,物理状态和运行时 binding 独立;每轮复制/恢复后重新校验初态。
+
+**验收**:至少覆盖 `1 case × 4 slots` 与 `2 cases × 2 slots`;同候选换兼容副本后映射正确;不同 case 的 world、覆盖和数据不串用。
+
+### PR 8:逻辑候选批量适配与稳定容量桶
+
+**依赖**:PR 4、M1;可与 PR 7 并行开发。
+
+- 新增 `generate_candidates()` facade,以能力声明区分 `candidate_buckets` 与 `env_rows`。
+- 支持候选来源重复行、显式 robot-root/初态映射、shared/per-candidate collision world 和安全 padding。复用现有 cuRobo backend 缓存,不伪造 `num_instances`。
+- 首先适配一个明确支持的 backend;其他 backend 保留显式 `env_rows` 路径。限制桶数量、warmup 与显存占用。
+
+**验收**:例如 C=11、B=4、桶容量 16 的索引与失败 mask 正确;改变桶不能串世界或丢候选;与 M1 同预算比较规划成本及端到端收益。
+
+### PR 9:依赖缓存、覆盖驱动选择与时长分桶
+
+**依赖**:M1;整体验收包含 PR 7/8。
+
+- 缓存键纳入 source revision、case、任务/标定/起点、阶段依赖和执行限制;只缓存可复用值,不缓存旧 session/ActionPlan。
+- 分离几何与时间家族,按阶段进度做近重复判断;正式覆盖由实际且 committed 的轨迹更新,在途预留失败后释放。
+- 实现低/高水位补池、按需分层展开、时长桶含溢出桶、价值/剩余成本排序、探索比例与每 case 最低配额。
+- 加入独立探测、预算和饱和停止条件;所有反馈按稳定逻辑顺序合并。
+
+**验收**:纯 timing 变化不刷空间覆盖;case/起点/标定变化正确失效;旧回执不归入新 slot;缓存和池满足条数/字节上限;尾批不超额。
+
+### PR 10:有界异步写入与流水线
+
+**依赖**:PR 3、9。
+
+- Async recorder 接入相同 commit 协议;enqueue 前取得独立 payload,按条数和字节限流。
+- writer 阻塞向候选生产传播反压;单请求超限明确拒绝或走事先声明的流式 sink。
+- 先重叠 CPU 整理/写盘;planner 可变 world 与求解受串行保护。GPU 规划和物理/渲染默认不重叠,只有实测收益及正确性通过后才启用。
+
+**M2 验收门槛**:同任务、相同 case/槽数/质量门槛/每 case 数据目标下,逐项消融 PR 7–10;报告 committed/min、覆盖增益/min、到达目标时间和内存峰值。没有实测收益的优化不设为默认。
+
+### M3:扩大运动覆盖,按来源/技能拆 PR
+
+**依赖**:M1 的验证和候选协议;批量路径复用 M2。
+
+1. **多 grasp / 多 IK**:graspkit 增加 masked batch 辅助协议并保留旧 ragged 接口;解析多解/多 seed 构型去重,逐分支传播成功 seed;PickUp 推迟 winner,保持实际关节分支到 lift。验证空 grasp、失败 seed 隔离和最终分支去重。
+2. **手写多段与 PickUp → Place**:接入父前缀/持物变换及后缀可行性;从实际执行边界状态生成续段;中间阶段不 reset。完整任务与成功片段独立标识。
+3. **逐技能推广**:AxisAlign、Slide、OpenDoor、HandOver 分别补特定约束与真实验收;HandOver 单独覆盖双臂联合碰撞和交接时序,不合并为一个大 PR。
+4. **数据使用与学习验收**:基于 job manifest 汇集已封存分片,按 trajectory family/parent 分组 train/validation,限制时间近重复和公共前缀权重;同数据预算比较模仿学习成功率,保留未见起点/运动参数组合。候选 ID、seed、未来成功标记只作 audit。
+
+**M3 验收门槛**:增加的是通过物理/质量验收的运动覆盖;学习收益以实测报告为准。固定场景结果不宣称布局泛化。
+
+### M4:按实测瓶颈选择后续工作
+
+逐行补位必须先证明 reset、速度清理、控制器、任务桥、观测历史、记录和碰撞绑定全链路行隔离;所有 settling 仍由唯一 batched step 推进。checkpoint 分叉必须另行建立完整状态恢复与前后缀连续性协议。真实 recovery 数据需要可达前缀和可靠后缀专家。这些能力均不作为 M1–M3 的交付依赖。
+
+## 5. 依赖关系与并行组织
+
+```mermaid
+flowchart LR
+ A[M0 基准与能力] --> B[PR 1 契约与 Session]
+ B --> C[PR 2 初态恢复]
+ B --> D[PR 3 提交协议]
+ B --> E[PR 4 规划与验证]
+ C --> F[PR 5 手写闭环]
+ D --> F
+ E --> F
+ E --> G[PR 6 原子适配]
+ F --> H[M1 四组合验收与启动器]
+ G --> H
+ H --> I[PR 7 副本池]
+ H --> J[PR 8 候选批量]
+ I --> K[PR 9 缓存与调度]
+ J --> K
+ K --> L[PR 10 有界流水线 / M2]
+ H --> M[M3 多分支与多阶段]
+ L -. 推荐先完成效率验证并复用其能力 .-> M
+```
+
+PR 1 合入后可并行推进宿主生命周期、sink 和规划验证三条线。PR 6 的来源适配可与 PR 5 并行,最终由同一四组合矩阵验收。排期应以 M0 暴露的碰撞/持物验证和持久化缺口为依据,暂不对未实测工作承诺固定工期。
+
+## 6. 验证与交付物
+
+| 验证层 | 建议落点及重点 |
+|---|---|
+| 纯张量核心 | 新增 `tests/sim/motion/expansion/`:schema、身份、RNG、去重、覆盖预留、预算和队列;独立检查 motion 懒加载与算法直接依赖边界 |
+| 初态准备 | [物理状态适配](../../tests/lab/trajectory_generation/test_sim_initial_state.py) 与 [宿主生命周期](../../tests/lab/trajectory_generation/test_initial_state_host.py):独立快照、全批预检、固定条件漂移、恢复误差、失败和旧 epoch 拒绝 |
+| 规划/原子回归 | 扩展 [atomic_actions 测试](../../tests/sim/atomic_actions/)、[MotionGenerator](../../tests/sim/motion/planners/test_motion_generator_batched.py)、[BasePlanner](../../tests/sim/motion/planners/test_base_planner.py)、[graspkit](../../tests/toolkits/test_grasp_pose_generator.py) |
+| 宿主/记录回归 | 扩展 [Demo](../../tests/gym/envs/test_demo.py)、[trajectory_state](../../tests/gym/utils/test_trajectory_state.py)、[DatasetManager](../../tests/gym/envs/managers/test_dataset_manager.py)、[同步 recorder](../../tests/gym/envs/managers/test_dataset_functors.py)、[异步 recorder](../../tests/gym/envs/managers/test_async_dataset_functors.py) |
+| 集成与失败注入 | 扩展 `tests/lab/trajectory_generation/`:fake-host 契约测试、四组合真实仿真、唯一 step、恢复失配、终态冻结、延迟/重复/失败回执 |
+| CLI 与兼容 | 扩展 [tests/test_main.py](../../tests/test_main.py);旧 run-env/demo 调用、逐环境单 winner、唯一 env IDs 和 ActionPlan 效果协议保持有效 |
+| 性能与数据效果 | 按项目 benchmark 规范新增 generation benchmark;M0 保留基线,每个效率 PR 附同条件对照,M3 再增加学习评估 |
+
+每个里程碑提交:已解析配置与能力清单、代码/backend 版本、固定 case 与 profile、候选/episode/commit 谱系、失败统计、计时与内存报告、可读的数据样本和复现实验命令。公共导出变动同时补 API 文档;按改动范围更新 env-framework、manager-functor、atomic-actions、motion-planning 上下文。
+
+实施时按项目技能完成测试与 PR 检查:`add-test`、`pre-commit-check`;涉及公共 API 用 `update-api-docs`,新增 benchmark 用 `benchmark`。每个 PR 做比例适当的验证;四条真实路径和效率对照属于里程碑发布门槛,不能仅由 mock 测试替代。
+
+## 7. 实施约束与后续验收重点
+
+1. M1 以 **UR5 单物体 PickUp + 手写 EEF** 作为共同 fixture,要求四组合全部跑通;当前纯 arm 自由运动示例只验收其支持的子集。
+2. 按 **先 env_rows/full_batch/同步写入,再副本池与候选容量桶** 的顺序交付。
+3. **初态恢复、指定候选消费、阶段碰撞/持物验证、逐提交确认** 都是 M1 前置条件,不按吞吐优化项后移。
+4. 候选通过原子执行的受限计划供应端口接入,保持原有执行、效果验证和 recovery 所有权。
+5. M0 实测后再固定成功容差、碰撞 backend 和工期;吞吐和学习增益按同预算实验验收。
diff --git a/docs/source/api_reference/embodichain/embodichain.lab.gym.envs.rst b/docs/source/api_reference/embodichain/embodichain.lab.gym.envs.rst
index 79d1b3614..bc8269a21 100644
--- a/docs/source/api_reference/embodichain/embodichain.lab.gym.envs.rst
+++ b/docs/source/api_reference/embodichain/embodichain.lab.gym.envs.rst
@@ -56,6 +56,36 @@ Environment Classes
:members:
:exclude-members: __init__, class_type
+Generation Preparation
+----------------------
+
+``BaseEnv.acquire_generation_lease(owner)`` reserves the complete environment
+batch, suppresses automatic resets, and rejects ordinary resets until the same
+owner calls ``release_generation_lease``. ``generation_epoch`` invalidates old
+runtime bindings when ownership or episode preparation changes. A lease is an
+ownership contract; callers must still serialize host access. Gym leases and
+direct simulation generation hosts register the same simulator-batch ownership,
+so they cannot reserve that batch for different owners.
+
+``EmbodiedEnv.prepare_generation_episode`` requires the active lease and four
+explicit preparation, restoration, settling, and verification callbacks. It
+discards buffered recordings, so the caller must freeze the old episode first.
+After settling it resets episode managers before verification, so validation
+observes the new episode's state. It obtains the initial observation and seeds
+recording history only after verification succeeds. Failed preparation leaves
+stepping disabled; normal reset events and RNG reseeding are not part of this path.
+
+The shared
+:class:`~embodichain.lab.trajectory_generation.initial_state.FixedSceneHost`
+assembles this lifecycle with physical state restoration and a trusted task
+profile. See :doc:`/overview/trajectory_generation` for usage and limitations.
+
+``BaseEnv.observe_generation_commands(owner, observer)`` installs one serialized
+callback for the active lease. It runs after controller submission and before
+physics, allowing the collector to copy actual controller targets. An exception
+occurs after the command was submitted, so the attempt still counts and the
+collector must stop safely.
+
Controller-ready Actions
------------------------
@@ -77,6 +107,13 @@ containing one or more semantic subtasks. Segment action iterables may be lazy,
and the common executor records per-environment lengths, terminal status, and
segment spans.
+``execute_demo_episode`` accepts ``step_observer`` to consume the existing
+``env.step`` result and its active-row mask before terminal handling, without
+querying observations again. With explicit segments, ``row_step_limits`` provides
+one command count per row; zero skips a row and finished rows receive holds while
+remaining rows finish. Their post-completion holds are not recorded as training
+frames. Omitting these arguments preserves ordinary segment execution.
+
.. currentmodule:: embodichain.lab.gym.envs.demo
.. autoclass:: DemoExecutionCfg
diff --git a/docs/source/api_reference/embodichain/embodichain.lab.gym.rst b/docs/source/api_reference/embodichain/embodichain.lab.gym.rst
index 3fefee099..0a48c04ec 100644
--- a/docs/source/api_reference/embodichain/embodichain.lab.gym.rst
+++ b/docs/source/api_reference/embodichain/embodichain.lab.gym.rst
@@ -39,6 +39,7 @@ Base Environment Classes
:members:
:inherited-members:
:show-inheritance:
+ :exclude-members: acquire_generation_lease, release_generation_lease, generation_epoch, observe_generation_commands
The foundational environment class that provides the core functionality for all EmbodiChain RL environments.
This class extends the Gymnasium ``Env`` interface with multi-environment support and robotic-specific features.
@@ -56,6 +57,7 @@ Embodied Environment Classes
:members:
:inherited-members:
:show-inheritance:
+ :exclude-members: acquire_generation_lease, release_generation_lease, generation_epoch, prepare_generation_episode, observe_generation_commands
An advanced environment class that provides additional features for embodied AI research, including
sophisticated observation management, event handling, and multi-modal sensor integration.
@@ -67,6 +69,10 @@ Embodied Environment Classes
Configuration class for embodied environments with extended settings for lighting, observation management,
and advanced simulation features.
+Generation lease and preparation methods are documented with the owning
+:doc:`environment APIs ` and the
+:doc:`fixed-scene generation guide `.
+
Utilities Module (utils)
-------------------------
@@ -134,4 +140,3 @@ Miscellaneous Utilities
.. automodule:: embodichain.lab.gym.utils.misc
Miscellaneous utility functions for environment development and debugging.
-
diff --git a/docs/source/api_reference/embodichain/embodichain.lab.rst b/docs/source/api_reference/embodichain/embodichain.lab.rst
index 43cd72639..2e40118d9 100644
--- a/docs/source/api_reference/embodichain/embodichain.lab.rst
+++ b/docs/source/api_reference/embodichain/embodichain.lab.rst
@@ -10,7 +10,8 @@ The ``lab`` package is EmbodiChain's robotics laboratory. It owns the
provider-independent Task Program language (including its Semantic Call
contracts), the simulation core (``sim``), the Gymnasium-compatible
environment framework (``gym``), real-device controllers (``devices``), and
-browser visualization (``visualization``).
+browser visualization (``visualization``), and fixed-scene generation host
+integration (``trajectory_generation``).
.. rubric:: Submodules
@@ -21,6 +22,15 @@ browser visualization (``visualization``).
gym
sim
visualization
+ trajectory_generation
+
+Trajectory Generation
+---------------------
+
+.. toctree::
+ :maxdepth: 1
+
+ embodichain.lab.trajectory_generation
Browser Visualization
---------------------
diff --git a/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.expansion.rst b/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.expansion.rst
index 187e126b2..ee41c8bec 100644
--- a/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.expansion.rst
+++ b/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.expansion.rst
@@ -8,11 +8,13 @@ not directly import Gym or own simulation stepping. Public imports pass through
``embodichain.lab`` and ``embodichain.lab.sim`` initialization and therefore
require the normal simulation dependencies.
-This is the motion core for fixed-scene expert generation. Physical
-initial-state restoration, planning, rollout execution, task validation, and
-episode persistence must be supplied by separate host integrations. Those
-integrations provide actual observations and commands, validation evidence,
-and persistence confirmations; this package does not instantiate them.
+This is the motion core for fixed-scene expert generation. Full-batch physical
+initial-state restoration, supported free-motion EEF/qpos checks, actual qpos
+execution, the synchronous runner, and episode persistence are provided by the
+separate :doc:`generation host API `.
+That layer supplies actual observations and commands, validation evidence, and
+persistence confirmations. The low-level candidate execution entry points in the
+simulation and Gym layers are separate from this value-only API.
.. currentmodule:: embodichain.lab.sim.motion.expansion
diff --git a/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.planners.rst b/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.planners.rst
index 92a22519e..4e1244155 100644
--- a/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.planners.rst
+++ b/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.planners.rst
@@ -1,4 +1,5 @@
embodichain.lab.sim.motion.planners
+===================================
==========================================
.. automodule:: embodichain.lab.sim.motion.planners
diff --git a/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.solvers.rst b/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.solvers.rst
index bc70ae02e..f91e75214 100644
--- a/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.solvers.rst
+++ b/docs/source/api_reference/embodichain/embodichain.lab.sim.motion.solvers.rst
@@ -1,4 +1,5 @@
embodichain.lab.sim.motion.solvers
+==================================
==========================================
.. automodule:: embodichain.lab.sim.motion.solvers
diff --git a/docs/source/api_reference/embodichain/embodichain.lab.sim.sim_manager.rst b/docs/source/api_reference/embodichain/embodichain.lab.sim.sim_manager.rst
index 1131a77f8..0f7e065b2 100644
--- a/docs/source/api_reference/embodichain/embodichain.lab.sim.sim_manager.rst
+++ b/docs/source/api_reference/embodichain/embodichain.lab.sim.sim_manager.rst
@@ -29,6 +29,11 @@ not write drive targets. Set this field to ``None`` to opt out or select
prevents automatic recreation. ``GizmoCfg(ik_start_enabled=True)`` activates
native IK on the first update with an open window, as used by the robot tutorial.
+``simulation_time`` reports seconds successfully advanced through ``update``
+since manager construction. Collection hosts can use differences of this clock
+for observation timestamps. Direct backend steps outside the manager, including
+some object preparation setters, are not included.
+
.. rubric:: Classes
.. autosummary::
diff --git a/docs/source/api_reference/embodichain/embodichain.lab.trajectory_generation.rst b/docs/source/api_reference/embodichain/embodichain.lab.trajectory_generation.rst
new file mode 100644
index 000000000..abcff3f74
--- /dev/null
+++ b/docs/source/api_reference/embodichain/embodichain.lab.trajectory_generation.rst
@@ -0,0 +1,390 @@
+embodichain.lab.trajectory_generation
+=====================================
+
+.. automodule:: embodichain.lab.trajectory_generation
+
+The trajectory-generation integration layer prepares caller-provided fixed
+scenes for repeated expert rollouts. It currently provides full-batch initial
+state capture, restoration, verification, exclusive host ownership, qpos rollout
+execution, a synchronous runner, and a LeRobot episode sink. Candidate operators
+and generation accounting live in
+:mod:`embodichain.lab.sim.motion.expansion`.
+The planning adapter converts explicit EEF samples and validates supported free
+qpos paths against the real environment batch.
+
+The synchronous runner supports free-motion references and an explicitly
+bounded CPU-physics PickUp integration. The latter exports an offline atomic
+compilation, checks full gripper and held-object geometry, records native
+contacts at each physics substep, and persists accepted measured episodes.
+AtomicActionRuntime recovery/feedback execution, contact-aware Gym collection,
+a configuration registry, and the unified generation CLI remain separate work.
+See :doc:`/overview/trajectory_generation` for the host lifecycle and integration
+requirements.
+
+Initial-State Host
+------------------
+
+.. currentmodule:: embodichain.lab.trajectory_generation.initial_state
+
+.. autosummary::
+ :nosignatures:
+
+ InitialStateProfile
+ PreparedBatch
+ FixedSceneHost
+
+``InitialStateProfile`` supplies trusted Python callbacks for deterministic
+task/controller preparation, fixed-condition identity, and task-specific initial
+verification. A profile must cover controller history and task state beyond the
+standard episode managers, and fixed physics, geometry, visual, sensor, and
+control conditions beyond the physical snapshot. Profile IDs are descriptive;
+they do not load callbacks from YAML or certify the implementation.
+
+``FixedSceneHost`` owns an entire simulator batch, with one ordered ``SceneCase``
+per physical row. ``acquire_case`` prepares and captures the initial states;
+``restore_initial`` reconstructs them before a later rollout. ``PreparedBatch``
+identifies the current host epoch. Call ``assert_current`` before executing a
+candidate; earlier or foreign bindings are rejected. Bindings do not transfer
+old action plans, Task Program bridges, or other runtime state into a new epoch.
+``snapshots(binding)`` copies planning inputs from the captured initial batch in
+physical row order. ``initial_observation(binding)`` returns a copy of the Gym
+first frame produced during preparation without querying observations again;
+it returns ``None`` for a direct simulation host.
+
+When an ``EmbodiedEnv`` is supplied, the host uses its generation lease and
+preparation API. Direct simulation and Gym ownership are mutually exclusive for
+the same simulator batch. Gym resets the standard episode managers after
+settling and before verification; initial observations and recording seeds are
+published only after verification succeeds. The caller must freeze old episode
+data and validation evidence before preparation discards its buffers. Closing the
+host releases ownership;
+it does not save or restore an episode.
+
+.. autoclass:: InitialStateProfile
+ :members:
+ :exclude-members: __init__, copy, replace, to_dict, validate
+
+.. autoclass:: PreparedBatch
+ :members:
+
+.. autoclass:: FixedSceneHost
+ :members:
+
+Simulation State Adapter
+------------------------
+
+.. currentmodule:: embodichain.lab.trajectory_generation.integrations.sim
+
+.. autosummary::
+ :nosignatures:
+
+ SimInitialState
+ SimInitialStateAdapter
+
+``SimInitialStateAdapter`` supports one configuration-owned fixed-base robot
+and ordinary rigid objects spanning the full batch. The owned snapshot includes
+local root/object poses, velocities, every movable joint in robot joint order,
+position/velocity drive targets, and joint effort state. Tensor inputs are
+detached and cloned; consumers must treat those owned tensors as read-only.
+
+The adapter checks topology, field names, shapes, finite values, SE(3) poses,
+and joint limits before physical writes. Verification compares every supported
+field with absolute tolerance ``atol`` and zero relative tolerance. It does not
+snapshot fixed physical/visual properties, task/controller memory, contact
+solver state, or pending external forces. Those boundaries require the trusted
+profile and a supported episode initial state.
+``tolerances_profile_id`` names this explicit tolerance policy for runner/job
+matching; supplying the ID does not configure a tolerance or load a profile.
+
+Additional articulations, rigid-object groups, deformables, rigid constraints,
+and robots whose articulation properties come from USD are rejected. Restoring
+the root uses the existing articulation setter, which can advance the world by
+1 ms; remaining joint and object writes follow that operation. A backend failure
+can leave partial physical changes, so the owning host invalidates the binding
+before restoration and only publishes a replacement after settling and
+verification succeed.
+
+.. autoclass:: SimInitialState
+ :members:
+
+.. autoclass:: SimInitialStateAdapter
+ :members:
+
+Environment-Row Planning
+------------------------
+
+.. currentmodule:: embodichain.lab.trajectory_generation.integrations.planning
+
+.. autosummary::
+ :nosignatures:
+
+ EEFPath
+ EnvRowMotionPlanner
+
+``EEFPath`` owns explicit TCP poses ``(N,4,4)`` in the source row's local arena
+frame, arrival intervals ``dt`` with an initial zero, candidate identity, source
+row, and phase annotations. Optional ``solved_joint_targets`` have shape
+``(N,D_control)`` in the selected control-part joint order. These samples preserve
+a chosen IK branch; they are checked by FK without solving them again.
+Float64 arrival intervals and supplied joint samples retain their precision.
+
+``EnvRowMotionPlanner`` requires one complete ``MotionSnapshot`` per real robot
+row. It schedules candidates in rounds with at most one candidate per source row
+and keeps backend calls at the real robot batch size. Input and output candidate
+order, source-row identity, phase annotations, and arrival intervals stay aligned.
+``plan_eef`` solves explicit EEF samples, propagates valid seeds, checks TCP FK
+residuals, and returns a full-joint candidate batch plus per-candidate validation.
+Failed rows hold their initial state and remain rejected by that paired result.
+
+``validate_qpos`` leaves the original path unchanged and densifies its segments
+for collision checks, including phase boundaries. ``max_joint_step`` bounds the
+sample spacing in joint coordinates and ``max_validation_samples`` caps samples
+per candidate. Passing evidence is a sampled check, not continuous collision
+detection, dynamic feasibility, or task success.
+Collision-check scratch is allocated per real-batch round, bounded by ``B`` times
+``max_validation_samples``, rather than retaining every candidate's dense path.
+
+Only fully annotated ``free`` motion with explicitly empty ``held_object_ids``
+is supported. Uncontrolled joints must stay at both the snapshot values and
+robot configuration's initial values, matching the backend locked-joint model.
+This requirement also applies when checking measured trajectories: a gripper
+can drift under physics even if the candidate commands no gripper motion.
+Measured locked-joint drift beyond the current ``1e-6`` comparison makes that
+model unavailable for acceptance.
+Contact/hold phases, unannotated samples, held-object sweeps, changing locked
+joints, absent backend validation,
+or excess sampling requirements return ``unavailable``. Invalid trajectories or
+detected collisions return ``failed``. The caller maintains the same live robot
+roots and collision world as the snapshots and supplies canonical dynamic
+obstacle IDs; this adapter does not step or restore the scene.
+
+Measured qpos can be checked after execution while live root frames remain
+fixed. Its first sample must match the snapshot within ``1e-6``. The obstacle
+input represents one constant world for the whole path; the caller must
+establish that it applies to the rollout. A single initial or final obstacle
+pose does not reconstruct obstacle motion during execution.
+
+.. autoclass:: EEFPath
+ :members:
+
+.. autoclass:: EnvRowMotionPlanner
+ :members:
+
+Qpos Rollout Execution
+----------------------
+
+.. currentmodule:: embodichain.lab.trajectory_generation.execution
+
+.. autosummary::
+ :nosignatures:
+
+ QposRolloutExecutor
+
+``QposRolloutExecutor`` executes at most one candidate per physical row, with
+``None`` for idle rows. A candidate's first sample is the initial state;
+``valid_length - 1`` later samples are controller targets. The executor checks
+case/epoch identity, full-joint order, initial state, active-joint permissions,
+limits, the fixed clock, and estimated payload capacity before commands.
+The payload limit includes tensors and metadata: execution reserves 64 KiB of
+metadata capacity before commands and limits its own lineage, observation-key,
+and validation metadata to 32 KiB, leaving room for later runner/storage evidence.
+Oversized initial observations and changed schemas are rejected before the
+executor creates its owned observation copy.
+
+Gym execution uses ``ControllerAction`` through the common demonstration loop.
+The command observer copies actual full-joint position targets after controller
+submission and before physics. The step observer consumes the returned Gym
+observation without querying it again. Direct simulation uses the supplied
+full-batch observation callback and an integer number of physics substeps per
+command. Gym uses the authoritative ``env.step_dt``; both paths record differences
+of ``SimulationManager.simulation_time`` and reject inconsistent clock advances.
+
+Returned ``ExpertEpisode`` values contain commands actually submitted and their
+complete observation transitions. ``joint_positions`` always contains actual
+full-joint measurements. Finished rows stop collecting frames while safe holds
+allow other rows to finish; holds and padding are excluded from episode data.
+Installing a hold clears velocity targets and joint effort without adding a
+physics step. Execution remains serialized through task validation and evidence
+freezing, including calls made from user-supplied validators.
+The trusted task validator receives owned observations, actions, and timestamps
+at the final batch boundary and must return its declared validation check.
+
+The executor records ``execution_complete`` and ``fixed_collision_world`` checks.
+The latter compares robot roots and all rigid-object poses against the initial
+snapshots at observation boundaries; it does not reconstruct motion between
+those boundaries. Interrupted rollouts, target disagreement, changed observation
+schemas, or runtime errors cannot produce accepted evidence. Rows without a
+complete transition return ``None``. ``on_started`` fires after the first command
+submission even if physics or observation then fails, so the rollout still
+counts. Failure to install the final safe hold raises and stops collection.
+``last_failures`` exposes an owned read-only mapping from candidate ID to the
+most recent batch's runtime failure reason, including rows returning ``None``.
+It resets on the next execution and retains at most one 512-character reason per
+physical row, allowing the runner to preserve physics, observation, or cancellation
+diagnostics without retaining rollout payloads.
+
+.. autoclass:: QposRolloutExecutor
+ :members:
+
+Synchronous Handwritten Runner
+------------------------------
+
+.. currentmodule:: embodichain.lab.trajectory_generation.runner
+
+.. autosummary::
+ :nosignatures:
+
+ MotionLimitsProfile
+ GenerationRunner
+
+``MotionLimitsProfile`` owns positive full-joint speed and acceleration vectors,
+copied to CPU float64 in the robot's complete joint order. Its profile ID must
+match the requested job policy.
+
+``GenerationRunner`` combines an explicit job configuration, ``FixedSceneHost``,
+``EnvRowMotionPlanner``, qpos executor, synchronous LeRobot sink, and motion
+limits. It checks integration IDs, robot ownership, joint layout, enabled
+operators, and the host/sink clock before collection. ``run`` accepts one fixed
+case and qpos template per physical row; the free-motion implementation
+requires distinct case/initial-state pairs and the ``env_rows`` / ``full_batch``
+execution modes.
+
+With ``EnvRowMotionPlanner``, every host rigid-object physical UID must appear in both
+``collision_world_entity_ids`` and ``dynamic_collision_entity_ids`` so the backend
+receives each row's captured initial pose. Here dynamic means that backend poses
+can be updated; a simulation body may still be static. A baked static world alone
+cannot establish the prepared poses or different cases across rows. Different
+relative layouts in a multi-row batch require per-environment backend worlds.
+Semantic aliases are not connected in this runner. The trusted profile must
+also certify collision representation for implicit
+planes and other relevant geometry outside that rigid-object registry.
+``executor.max_episode_bytes`` must fit both the sink limit and the job's pending
+byte limit before rollout allocation.
+
+The runner proposes configured joint residuals and retiming, validates planned
+paths and sampled motion limits, reserves episode capacity before rollout, and
+restores the fixed initial batch between executed rounds. It rechecks measured
+joint paths against the fixed collision snapshot and evaluates actual motion,
+duration, task evidence, and motion limits before submitting an
+accepted episode. Only confirmed sink receipts increase committed counts and
+coverage. Failed writes can retry the same frozen episode within the configured
+submission limit.
+
+Final evidence retains planning checks under ``planned_*`` IDs. Measured-path
+collision results, ``actual_motion_limits``, and ``motion_quality`` are separate
+acceptance checks; a passing planned path cannot replace their results.
+
+The runner is single-use and closes its supplied host and sink when ``run``
+exits. It returns a report with counters, coverage, audit, resolved settings,
+package version, and ``target_reached``, and writes ``generation_report.json``
+under the sink root. Budget exhaustion can return without reaching the requested
+target. Required integration, restoration, or persistence failures raise after
+cleanup and report generation.
+
+``EnvRowMotionPlanner`` supports explicitly free, unloaded motion. The separate
+``PickUpMotionValidator`` below adds changing gripper geometry, held-object
+paths and physical contact gates for offline atomic qpos replay. The public
+runner interface does not constitute full M1 runtime/source/host acceptance.
+
+.. autoclass:: MotionLimitsProfile
+ :members:
+ :exclude-members: __init__, copy, replace, to_dict, validate
+
+.. autoclass:: GenerationRunner
+ :members:
+
+Synchronous Episode Persistence
+-------------------------------
+
+.. currentmodule:: embodichain.lab.trajectory_generation.sinks
+
+.. autosummary::
+ :nosignatures:
+
+ LeRobotEpisodeSink
+
+``LeRobotEpisodeSink`` writes each accepted ``ExpertEpisode`` into its own local
+LeRobot dataset shard. Each shard contains exactly ``T`` causal training frames;
+required sidecars retain the terminal observation, all ``T+1`` measured
+timestamps, identity, validation, phases, and metadata. A collection manifest
+maps committed episode IDs to their shards.
+
+``submit`` owns a CPU copy, writes and finalizes the dataset, reopens every frame
+and required image, verifies sidecars, then replaces and reads back the manifest
+before returning a confirmed ``CommitReceipt``. Invalid inputs raise before
+episode writes; persistence failures return an unconfirmed receipt. Reusing a
+confirmed commit ID with an identical payload only verifies existing artifacts;
+it does not rewrite them or create another logical episode.
+Retries can reuse readable sealed data, while incomplete uncommitted data is
+rebuilt at the same shard path. Changed payloads under a known commit ID are
+rejected.
+
+The sink accepts fixed-rate float32/float64 action vectors, supported numeric
+observation vectors/matrices, and uint8 RGB images. Matrices flatten to C-order
+vectors in LeRobot, retaining their source shape in ``observation_shapes`` and
+original terminal matrix in the sidecar. Training timestamps use LeRobot's
+relative fixed clock; the sidecar retains exact measured times and their origin.
+Numeric readback checks the stored Parquet precision, while image readback uses
+the LeRobot image decoder.
+``max_episode_bytes`` limits raw tensor and metadata payload bytes for each
+episode, not total process memory or collection disk size. Submission is
+synchronous, so ``drain`` returns no deferred receipts and ``close`` releases the
+writer lease after already completed writes.
+
+The sink requires a new or empty directory and one serialized caller. A confirmed
+receipt means required artifacts were closed and successfully read back; process
+restart and power-loss recovery are not implemented. It does not evaluate the
+physical truth of supplied validation evidence or upload anything to the Hub.
+
+.. autoclass:: LeRobotEpisodeSink
+ :members:
+
+PickUp Source and Contact Validation
+------------------------------------
+
+.. currentmodule:: embodichain.lab.trajectory_generation.integrations.atomic
+
+.. autosummary::
+ :nosignatures:
+
+ export_pickup_templates
+
+The export accepts exactly a successful ``MoveEndEffector`` → ``PickUp``
+compilation. It preserves the atomic phase boundaries, expands passive mimic
+geometry, makes the shared action boundary an explicit control-period hold,
+and appends terminal hold commands. Only transit permits joint residuals.
+It produces qpos replay templates; it does not execute AtomicActionRuntime or
+commit the compilation's hypothetical symbolic effects.
+
+.. autofunction:: export_pickup_templates
+
+.. currentmodule:: embodichain.lab.trajectory_generation.integrations.contact
+
+.. autosummary::
+ :nosignatures:
+
+ PickUpContactProfile
+ PickUpMotionValidator
+
+Use the same ``PickUpMotionValidator`` as the Runner planner and the executor's
+``contact_validator``. Supported geometry is an unscaled fixed-base URDF robot,
+cuboid rigid objects, and the simulator's implicit ground. Planning uses
+conservative hulls of URDF collision shapes and full-joint FK; actual validation
+uses measured joints and measured object poses. The two-hop URDF self-collision
+exclusions match the existing cuRobo structural policy. This remains sampled
+validation, with additional native contact evidence at each CPU physics substep.
+
+The profile declares the target, support, finger links, fixed mounting links,
+TCP, limited finger-contact entry region, penetration tolerance, and physical
+hold thresholds. Unknown bodies, cross-row contact, buffer overflow, forbidden
+contacts, missing finger contact, slipping and falling fail acceptance. The
+profile does not enable contact or timing augmentation. Gym and arbitrary
+mesh-shaped rigid objects are not supported by this integration.
+
+.. autoclass:: PickUpContactProfile
+ :members:
+ :show-inheritance:
+
+.. autoclass:: PickUpMotionValidator
+ :members:
+ :show-inheritance:
diff --git a/docs/source/api_reference/embodichain/embodichain.toolkits.rst b/docs/source/api_reference/embodichain/embodichain.toolkits.rst
index e412f66a4..ba7513b41 100644
--- a/docs/source/api_reference/embodichain/embodichain.toolkits.rst
+++ b/docs/source/api_reference/embodichain/embodichain.toolkits.rst
@@ -1,8 +1,9 @@
embodichain.toolkits
====================
-The :mod:`embodichain.toolkits` package contains asset-preparation and
-manipulation utilities that can be used independently of the simulation loop.
+The :mod:`embodichain.toolkits` package contains standalone asset-preparation
+and manipulation utilities. Robot motion capabilities are documented under
+:mod:`embodichain.lab.sim.motion`.
.. automodule:: embodichain.toolkits
diff --git a/docs/source/api_reference/public_api.rst b/docs/source/api_reference/public_api.rst
index 253938d7a..3e8aa8dc8 100644
--- a/docs/source/api_reference/public_api.rst
+++ b/docs/source/api_reference/public_api.rst
@@ -612,21 +612,6 @@ embodichain.lab.sim.atomic_actions.execution
ExecutionStatus
ExecutionTick
-embodichain.lab.sim.atomic_actions.verification
-------------------------------------------------
-
-.. currentmodule:: embodichain.lab.sim.atomic_actions.verification
-
-.. autosummary::
-
- EffectExpectationResult
- EffectVerificationRequest
- EffectVerificationResult
- HeldObjectGuardRequest
- HeldObjectGuardResult
- PhaseEffectGateRequest
- PhaseEffectGateResult
-
embodichain.lab.sim.atomic_actions.goals
----------------------------------------
@@ -829,94 +814,20 @@ embodichain.lab.sim.atomic_actions.transports
EndpointCommandRouter
EndpointCommandTransport
-embodichain.lab.sim.objects.articulation
-----------------------------------------
-
-.. currentmodule:: embodichain.lab.sim.objects.articulation
-
-.. autosummary::
-
- ArticulationData
- Articulation
- ArticulationJointKinematics
-
-embodichain.lab.sim.objects.cloth_object
-----------------------------------------
-
-.. currentmodule:: embodichain.lab.sim.objects.cloth_object
-
-.. autosummary::
-
- ClothBodyData
- ClothObject
- ClothObjectCfg
-
-embodichain.lab.sim.objects.constraint
---------------------------------------
-
-.. currentmodule:: embodichain.lab.sim.objects.constraint
-
-.. autosummary::
-
- RigidConstraint
-
-embodichain.lab.sim.objects.gizmo
----------------------------------
-
-.. currentmodule:: embodichain.lab.sim.objects.gizmo
-
-Native robot targets use DexSim's controller with Newton IK by default.
-Set ``GizmoCfg.ik_solver="embodichain"`` to reuse the robot control part's
-configured solver, including PinkSolver; Viser uses the same solver adapter.
-
-.. autosummary::
-
- Gizmo
- GizmoCfg
- create_robot_ik_gizmo_controller
-
-embodichain.lab.sim.objects.rigid_object
-----------------------------------------
-
-.. currentmodule:: embodichain.lab.sim.objects.rigid_object
-
-.. autosummary::
-
- RigidBodyData
- RigidObject
- RigidObjectCfg
-
-embodichain.lab.sim.objects.rigid_object_group
-----------------------------------------------
-
-.. currentmodule:: embodichain.lab.sim.objects.rigid_object_group
-
-.. autosummary::
-
- RigidBodyGroupData
- RigidObjectGroup
- RigidObjectGroupCfg
-
-embodichain.lab.sim.objects.robot
----------------------------------
-
-.. currentmodule:: embodichain.lab.sim.objects.robot
-
-.. autosummary::
-
- ControlGroup
- Robot
-
-embodichain.lab.sim.objects.soft_object
----------------------------------------
+embodichain.lab.sim.atomic_actions.verification
+------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.objects.soft_object
+.. currentmodule:: embodichain.lab.sim.atomic_actions.verification
.. autosummary::
- SoftBodyData
- SoftObject
- SoftObjectCfg
+ EffectExpectationResult
+ EffectVerificationRequest
+ EffectVerificationResult
+ HeldObjectGuardRequest
+ HeldObjectGuardResult
+ PhaseEffectGateRequest
+ PhaseEffectGateResult
embodichain.lab.sim.motion.planners.base_planner
------------------------------------------------
@@ -954,6 +865,17 @@ embodichain.lab.sim.motion.planners.curobo.curobo_yaml
generate_curobo_robot_yaml
generate_curobo_world_yaml
+embodichain.lab.sim.motion.motion_generator
+----------------------------------------------------
+
+.. currentmodule:: embodichain.lab.sim.motion.motion_generator
+
+.. autosummary::
+
+ MotionGenerator
+ MotionGenCfg
+ MotionGenOptions
+
embodichain.lab.sim.motion.planners.neural_planner
--------------------------------------------------
@@ -993,457 +915,546 @@ embodichain.lab.sim.motion.planners.utils
interpolate_xpos
interpolate_xpos_batched
-embodichain.lab.sim.robots.cobotmagic
--------------------------------------
+embodichain.lab.sim.motion.solvers.neural_ik_solver
+---------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.robots.cobotmagic
+.. currentmodule:: embodichain.lab.sim.motion.solvers.neural_ik_solver
.. autosummary::
- CobotMagicCfg
+ NeuralIKSolverCfg
+ NeuralIKSolver
-embodichain.lab.sim.robots.dexforce_w1.hand_specs
--------------------------------------------------
+embodichain.lab.sim.motion.solvers.null_space_posture_task
+----------------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.robots.dexforce_w1.hand_specs
+.. currentmodule:: embodichain.lab.sim.motion.solvers.null_space_posture_task
.. autosummary::
- W1HandSideSpec
- W1HandSpec
- get_default_w1_hand_version
- get_w1_hand_spec
- normalize_w1_hand_mappings
+ NullSpacePostureTask
-embodichain.lab.sim.robots.dexforce_w1.specs
---------------------------------------------
+embodichain.lab.sim.motion.solvers.pink_solver
+----------------------------------------------
-.. currentmodule:: embodichain.lab.sim.robots.dexforce_w1.specs
+.. currentmodule:: embodichain.lab.sim.motion.solvers.pink_solver
.. autosummary::
- W1VersionSpec
- get_w1_version_spec
+ PinkSolver
+ PinkSolverCfg
-embodichain.lab.sim.robots.dexforce_w1.types
---------------------------------------------
+embodichain.lab.sim.motion.solvers.srs_solver
+---------------------------------------------
-.. currentmodule:: embodichain.lab.sim.robots.dexforce_w1.types
+.. currentmodule:: embodichain.lab.sim.motion.solvers.srs_solver
.. autosummary::
- DexforceW1Version
- DexforceW1HandVersion
- DexforceW1ArmSide
- DexforceW1Type
- DexforceW1HandBrand
+ SRSSolver
+ SRSSolverCfg
-embodichain.lab.sim.robots.dexforce_w1.utils
---------------------------------------------
+embodichain.lab.sim.motion.workspace.caches.cache_utils
+-------------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.robots.dexforce_w1.utils
+.. currentmodule:: embodichain.lab.sim.motion.workspace.caches.cache_utils
.. autosummary::
- ChassisManager
- TorsoManager
- HeadManager
- ArmManager
- HandManager
- EyesManager
- build_dexforce_w1_assembly_urdf_cfg
+ clean_all_sessions
+ clean_session
+ format_size
+ get_cache_root
+ get_dir_size
+ list_sessions
+ main
+ show_session_info
+ show_total_size
-embodichain.lab.sim.robots.dual_arm
------------------------------------
+embodichain.lab.sim.motion.workspace.caches.results_cache
+---------------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.robots.dual_arm
+.. currentmodule:: embodichain.lab.sim.motion.workspace.caches.results_cache
.. autosummary::
- DualArmRobotCfg
- build_dual_arm_cfg
- resolve_mounts
+ DEFAULT_RESULTS_CACHE_DIR
+ ResultsCache
+ compute_cache_key
+ serialize_results
+ deserialize_results
-embodichain.lab.sim.robots.franka_panda
----------------------------------------
+embodichain.lab.sim.motion.workspace.constraints.base_constraint
+----------------------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.robots.franka_panda
+.. currentmodule:: embodichain.lab.sim.motion.workspace.constraints.base_constraint
.. autosummary::
- FrankaPandaCfg
+ IConstraintChecker
+ BaseConstraintChecker
-embodichain.lab.sim.robots.ur_robot
------------------------------------
+embodichain.lab.sim.motion.workspace.constraints.workspace_constraint
+---------------------------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.robots.ur_robot
+.. currentmodule:: embodichain.lab.sim.motion.workspace.constraints.workspace_constraint
.. autosummary::
- URRobotCfg
+ WorkspaceConstraintChecker
-embodichain.lab.sim.sensors.camera
-----------------------------------
+embodichain.lab.sim.motion.workspace.samplers.base_sampler
+----------------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.sensors.camera
+.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.base_sampler
.. autosummary::
- Camera
- CameraCfg
+ ISampler
+ BaseSampler
-embodichain.lab.sim.sim_manager
--------------------------------
+embodichain.lab.sim.motion.workspace.samplers.gaussian_sampler
+--------------------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.sim_manager
+.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.gaussian_sampler
.. autosummary::
- SIM_CACHE_DIR
- MATERIAL_CACHE_DIR
- CONVEX_DECOMP_DIR
- REACHABLE_XPOS_DIR
+ GaussianSampler
-embodichain.lab.task_program.semantics.calls
-------------------------------------------------
+embodichain.lab.sim.motion.workspace.samplers.halton_sampler
+------------------------------------------------------------
-.. currentmodule:: embodichain.lab.task_program.semantics.calls
+.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.halton_sampler
.. autosummary::
- DeclarativeValue
- HandOver
- Pick
- Place
- PlaceRelationTarget
- RegisteredSemanticCall
- SemanticCallCatalog
- SemanticCallDescriptor
- SemanticCallSpec
- SemanticPose
- builtin_semantic_call_catalog
+ HaltonSampler
-.. automodule:: embodichain.lab.task_program.semantics.calls
- :members:
- :no-index:
+embodichain.lab.sim.motion.workspace.samplers.importance_sampler
+----------------------------------------------------------------
-embodichain.lab.task_program.semantics.integration
-------------------------------------------------------
+.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.importance_sampler
-.. currentmodule:: embodichain.lab.task_program.semantics.integration
+.. autosummary::
+
+ ImportanceSampler
+
+embodichain.lab.sim.motion.workspace.samplers.iniform_sampler
+-------------------------------------------------------------
+
+.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.iniform_sampler
.. autosummary::
- BoundSemanticCall
- LinkedSemanticCall
- PathPart
- SceneEntityManifest
- SceneManifest
- SemanticDiagnostic
- SemanticIntegrationManifest
- SemanticValidationError
+ UniformSampler
-.. automodule:: embodichain.lab.task_program.semantics.integration
- :members:
- :no-index:
+embodichain.lab.sim.motion.workspace.samplers.lhs_sampler
+---------------------------------------------------------
-embodichain.lab.task_program.semantics.effects
---------------------------------------------------
+.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.lhs_sampler
-.. automodule:: embodichain.lab.task_program.semantics.effects
- :members:
- :no-index:
+.. autosummary::
-embodichain.lab.task_program.semantics.evidence
----------------------------------------------------
+ LatinHypercubeSampler
-.. automodule:: embodichain.lab.task_program.semantics.evidence
- :members:
- :no-index:
+embodichain.lab.sim.motion.workspace.samplers.random_sampler
+------------------------------------------------------------
-embodichain.lab.task_program.semantics.profiles
----------------------------------------------------
+.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.random_sampler
-.. currentmodule:: embodichain.lab.task_program.semantics.profiles
+.. autosummary::
+
+ RandomSampler
+
+embodichain.lab.sim.motion.workspace.samplers.sobol_sampler
+-----------------------------------------------------------
+
+.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.sobol_sampler
.. autosummary::
- AmbiguousSkillBindingError
- BoundRobotSkillProfile
- ControlPartEndpoint
- ControlPartEndpointAdapter
- EffectAssurance
- EndpointResolution
- ProfileValidationError
- ResourceEndpoint
- ResourceEndpointAdapter
- ResolvedRobotResource
- ResolvedResourceEndpoint
- ResolvedSkillBinding
- ResourceBinding
- ResourceClaim
- RobotResource
- RobotSkillProfile
- SkillPolicyPreset
- UnsupportedSkillError
- WorkflowRecoveryPolicy
+ SobolSampler
-embodichain.lab.task_program.semantics.scene
-------------------------------------------------
+embodichain.lab.sim.motion.workspace.visualizers.axis_visualizer
+----------------------------------------------------------------
-.. currentmodule:: embodichain.lab.task_program.semantics.scene
+.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.axis_visualizer
.. autosummary::
- AmbiguousSceneAffordanceError
- GRASP_AFFORDANCE_CAPABILITY
- PLACE_IN_AFFORDANCE_CAPABILITY
- PLACE_ON_AFFORDANCE_CAPABILITY
- RegistrySceneProvider
- SceneAffordanceRef
- SceneArticulationRef
- SceneCollisionRole
- SceneCollisionWorldMode
- SceneDynamics
- SceneEntityRef
- SceneEntityMetadata
- SceneEntityRegistration
- SceneEntityStateProvider
- SceneGeometryProvider
- SceneLinkRef
- SceneObjectRef
- SceneRegistry
- UnsupportedSceneAffordanceError
+ AxisVisualizer
-.. automodule:: embodichain.lab.task_program.semantics.scene
- :members:
- :no-index:
+embodichain.lab.sim.motion.workspace.visualizers.base_visualizer
+----------------------------------------------------------------
-embodichain.lab.sim.motion.solvers.neural_ik_solver
----------------------------------------------------
+.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.base_visualizer
-.. currentmodule:: embodichain.lab.sim.motion.solvers.neural_ik_solver
+.. autosummary::
+
+ IVisualizer
+ BaseVisualizer
+
+embodichain.lab.sim.motion.workspace.visualizers.point_cloud_visualizer
+-----------------------------------------------------------------------
+
+.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.point_cloud_visualizer
.. autosummary::
- NeuralIKSolverCfg
- NeuralIKSolver
+ PointCloudVisualizer
-embodichain.lab.sim.motion.solvers.null_space_posture_task
-----------------------------------------------------------
+embodichain.lab.sim.motion.workspace.visualizers.sphere_visualizer
+------------------------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.solvers.null_space_posture_task
+.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.sphere_visualizer
.. autosummary::
- NullSpacePostureTask
+ SphereVisualizer
-embodichain.lab.sim.motion.solvers.pink_solver
-----------------------------------------------
+embodichain.lab.sim.motion.workspace.visualizers.visualizer_factory
+-------------------------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.solvers.pink_solver
+.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.visualizer_factory
.. autosummary::
- PinkSolver
- PinkSolverCfg
+ VisualizerFactory
+ create_visualizer
-embodichain.lab.sim.motion.solvers.srs_solver
----------------------------------------------
+embodichain.lab.sim.motion.workspace.visualizers.voxel_visualizer
+-----------------------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.solvers.srs_solver
+.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.voxel_visualizer
.. autosummary::
- SRSSolver
- SRSSolverCfg
+ VoxelVisualizer
-embodichain.lab.sim.utility.render_utils
+embodichain.lab.sim.objects.articulation
----------------------------------------
-.. currentmodule:: embodichain.lab.sim.utility.render_utils
+.. currentmodule:: embodichain.lab.sim.objects.articulation
.. autosummary::
- select_default_renderer
+ ArticulationData
+ Articulation
+ ArticulationJointKinematics
-embodichain.lab.sim.motion.workspace.caches.cache_utils
--------------------------------------------------------
+embodichain.lab.sim.objects.cloth_object
+----------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.caches.cache_utils
+.. currentmodule:: embodichain.lab.sim.objects.cloth_object
.. autosummary::
- clean_all_sessions
- clean_session
- format_size
- get_cache_root
- get_dir_size
- list_sessions
- main
- show_session_info
- show_total_size
+ ClothBodyData
+ ClothObject
+ ClothObjectCfg
-embodichain.lab.sim.motion.workspace.caches.results_cache
----------------------------------------------------------
+embodichain.lab.sim.objects.constraint
+--------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.caches.results_cache
+.. currentmodule:: embodichain.lab.sim.objects.constraint
.. autosummary::
- DEFAULT_RESULTS_CACHE_DIR
- ResultsCache
- compute_cache_key
- serialize_results
- deserialize_results
+ RigidConstraint
-embodichain.lab.sim.motion.workspace.constraints.base_constraint
-----------------------------------------------------------------
+embodichain.lab.sim.objects.gizmo
+---------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.constraints.base_constraint
+.. currentmodule:: embodichain.lab.sim.objects.gizmo
+
+Native robot targets use DexSim's controller with Newton IK by default.
+Set ``GizmoCfg.ik_solver="embodichain"`` to reuse the robot control part's
+configured solver, including PinkSolver; Viser uses the same solver adapter.
.. autosummary::
- IConstraintChecker
- BaseConstraintChecker
+ Gizmo
+ GizmoCfg
+ create_robot_ik_gizmo_controller
-embodichain.lab.sim.motion.workspace.constraints.workspace_constraint
----------------------------------------------------------------------
+embodichain.lab.sim.objects.rigid_object
+----------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.constraints.workspace_constraint
+.. currentmodule:: embodichain.lab.sim.objects.rigid_object
.. autosummary::
- WorkspaceConstraintChecker
+ RigidBodyData
+ RigidObject
+ RigidObjectCfg
-embodichain.lab.sim.motion.workspace.samplers.base_sampler
-----------------------------------------------------------
+embodichain.lab.sim.objects.rigid_object_group
+----------------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.base_sampler
+.. currentmodule:: embodichain.lab.sim.objects.rigid_object_group
.. autosummary::
- ISampler
- BaseSampler
+ RigidBodyGroupData
+ RigidObjectGroup
+ RigidObjectGroupCfg
-embodichain.lab.sim.motion.workspace.samplers.gaussian_sampler
---------------------------------------------------------------
+embodichain.lab.sim.objects.robot
+---------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.gaussian_sampler
+.. currentmodule:: embodichain.lab.sim.objects.robot
.. autosummary::
- GaussianSampler
+ ControlGroup
+ Robot
-embodichain.lab.sim.motion.workspace.samplers.halton_sampler
-------------------------------------------------------------
+embodichain.lab.sim.objects.soft_object
+---------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.halton_sampler
+.. currentmodule:: embodichain.lab.sim.objects.soft_object
.. autosummary::
- HaltonSampler
+ SoftBodyData
+ SoftObject
+ SoftObjectCfg
-embodichain.lab.sim.motion.workspace.samplers.importance_sampler
-----------------------------------------------------------------
+embodichain.lab.sim.robots.cobotmagic
+-------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.importance_sampler
+.. currentmodule:: embodichain.lab.sim.robots.cobotmagic
.. autosummary::
- ImportanceSampler
+ CobotMagicCfg
-embodichain.lab.sim.motion.workspace.samplers.iniform_sampler
--------------------------------------------------------------
+embodichain.lab.sim.robots.dexforce_w1.hand_specs
+-------------------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.iniform_sampler
+.. currentmodule:: embodichain.lab.sim.robots.dexforce_w1.hand_specs
.. autosummary::
- UniformSampler
+ W1HandSideSpec
+ W1HandSpec
+ get_default_w1_hand_version
+ get_w1_hand_spec
+ normalize_w1_hand_mappings
-embodichain.lab.sim.motion.workspace.samplers.lhs_sampler
----------------------------------------------------------
+embodichain.lab.sim.robots.dexforce_w1.specs
+--------------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.lhs_sampler
+.. currentmodule:: embodichain.lab.sim.robots.dexforce_w1.specs
.. autosummary::
- LatinHypercubeSampler
+ W1VersionSpec
+ get_w1_version_spec
-embodichain.lab.sim.motion.workspace.samplers.random_sampler
-------------------------------------------------------------
+embodichain.lab.sim.robots.dexforce_w1.types
+--------------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.random_sampler
+.. currentmodule:: embodichain.lab.sim.robots.dexforce_w1.types
.. autosummary::
- RandomSampler
+ DexforceW1Version
+ DexforceW1HandVersion
+ DexforceW1ArmSide
+ DexforceW1Type
+ DexforceW1HandBrand
-embodichain.lab.sim.motion.workspace.samplers.sobol_sampler
------------------------------------------------------------
+embodichain.lab.sim.robots.dexforce_w1.utils
+--------------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.samplers.sobol_sampler
+.. currentmodule:: embodichain.lab.sim.robots.dexforce_w1.utils
.. autosummary::
- SobolSampler
+ ChassisManager
+ TorsoManager
+ HeadManager
+ ArmManager
+ HandManager
+ EyesManager
+ build_dexforce_w1_assembly_urdf_cfg
-embodichain.lab.sim.motion.workspace.visualizers.axis_visualizer
-----------------------------------------------------------------
+embodichain.lab.sim.robots.dual_arm
+-----------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.axis_visualizer
+.. currentmodule:: embodichain.lab.sim.robots.dual_arm
.. autosummary::
- AxisVisualizer
+ DualArmRobotCfg
+ build_dual_arm_cfg
+ resolve_mounts
-embodichain.lab.sim.motion.workspace.visualizers.base_visualizer
-----------------------------------------------------------------
+embodichain.lab.sim.robots.franka_panda
+---------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.base_visualizer
+.. currentmodule:: embodichain.lab.sim.robots.franka_panda
.. autosummary::
- IVisualizer
- BaseVisualizer
+ FrankaPandaCfg
-embodichain.lab.sim.motion.workspace.visualizers.point_cloud_visualizer
------------------------------------------------------------------------
+embodichain.lab.sim.robots.ur_robot
+-----------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.point_cloud_visualizer
+.. currentmodule:: embodichain.lab.sim.robots.ur_robot
.. autosummary::
- PointCloudVisualizer
+ URRobotCfg
-embodichain.lab.sim.motion.workspace.visualizers.sphere_visualizer
-------------------------------------------------------------------
+embodichain.lab.sim.sensors.camera
+----------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.sphere_visualizer
+.. currentmodule:: embodichain.lab.sim.sensors.camera
.. autosummary::
- SphereVisualizer
+ Camera
+ CameraCfg
-embodichain.lab.sim.motion.workspace.visualizers.visualizer_factory
--------------------------------------------------------------------
+embodichain.lab.sim.sim_manager
+-------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.visualizer_factory
+.. currentmodule:: embodichain.lab.sim.sim_manager
.. autosummary::
- VisualizerFactory
- create_visualizer
+ SIM_CACHE_DIR
+ MATERIAL_CACHE_DIR
+ CONVEX_DECOMP_DIR
+ REACHABLE_XPOS_DIR
-embodichain.lab.sim.motion.workspace.visualizers.voxel_visualizer
------------------------------------------------------------------
+embodichain.lab.sim.utility.render_utils
+----------------------------------------
-.. currentmodule:: embodichain.lab.sim.motion.workspace.visualizers.voxel_visualizer
+.. currentmodule:: embodichain.lab.sim.utility.render_utils
.. autosummary::
- VoxelVisualizer
+ select_default_renderer
+
+embodichain.lab.task_program.semantics.calls
+------------------------------------------------
+
+.. currentmodule:: embodichain.lab.task_program.semantics.calls
+
+.. autosummary::
+
+ DeclarativeValue
+ HandOver
+ Pick
+ Place
+ PlaceRelationTarget
+ RegisteredSemanticCall
+ SemanticCallCatalog
+ SemanticCallDescriptor
+ SemanticCallSpec
+ SemanticPose
+ builtin_semantic_call_catalog
+
+.. automodule:: embodichain.lab.task_program.semantics.calls
+ :members:
+ :no-index:
+
+embodichain.lab.task_program.semantics.effects
+--------------------------------------------------
+
+.. automodule:: embodichain.lab.task_program.semantics.effects
+ :members:
+ :no-index:
+
+embodichain.lab.task_program.semantics.evidence
+---------------------------------------------------
+
+.. automodule:: embodichain.lab.task_program.semantics.evidence
+ :members:
+ :no-index:
+
+embodichain.lab.task_program.semantics.integration
+------------------------------------------------------
+
+.. currentmodule:: embodichain.lab.task_program.semantics.integration
+
+.. autosummary::
+
+ BoundSemanticCall
+ LinkedSemanticCall
+ PathPart
+ SceneEntityManifest
+ SceneManifest
+ SemanticDiagnostic
+ SemanticIntegrationManifest
+ SemanticValidationError
+
+.. automodule:: embodichain.lab.task_program.semantics.integration
+ :members:
+ :no-index:
+
+embodichain.lab.task_program.semantics.profiles
+---------------------------------------------------
+
+.. currentmodule:: embodichain.lab.task_program.semantics.profiles
+
+.. autosummary::
+
+ AmbiguousSkillBindingError
+ BoundRobotSkillProfile
+ ControlPartEndpoint
+ ControlPartEndpointAdapter
+ EffectAssurance
+ EndpointResolution
+ ProfileValidationError
+ ResourceEndpoint
+ ResourceEndpointAdapter
+ ResolvedRobotResource
+ ResolvedResourceEndpoint
+ ResolvedSkillBinding
+ ResourceBinding
+ ResourceClaim
+ RobotResource
+ RobotSkillProfile
+ SkillPolicyPreset
+ UnsupportedSkillError
+ WorkflowRecoveryPolicy
+
+embodichain.lab.task_program.semantics.scene
+------------------------------------------------
+
+.. currentmodule:: embodichain.lab.task_program.semantics.scene
+
+.. autosummary::
+
+ AmbiguousSceneAffordanceError
+ GRASP_AFFORDANCE_CAPABILITY
+ PLACE_IN_AFFORDANCE_CAPABILITY
+ PLACE_ON_AFFORDANCE_CAPABILITY
+ RegistrySceneProvider
+ SceneAffordanceRef
+ SceneArticulationRef
+ SceneCollisionRole
+ SceneCollisionWorldMode
+ SceneDynamics
+ SceneEntityRef
+ SceneEntityMetadata
+ SceneEntityRegistration
+ SceneEntityStateProvider
+ SceneGeometryProvider
+ SceneLinkRef
+ SceneObjectRef
+ SceneRegistry
+ UnsupportedSceneAffordanceError
+
+.. automodule:: embodichain.lab.task_program.semantics.scene
+ :members:
+ :no-index:
embodichain.lab.visualization.backends
--------------------------------------
diff --git a/docs/source/index.rst b/docs/source/index.rst
index b1a80b366..e886e7146 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -37,6 +37,7 @@ Table of Contents
overview/sim/index
overview/task_program/index
overview/gym/index
+ overview/trajectory_generation
overview/rl/index
.. toctree::
diff --git a/docs/source/overview/gym/index.rst b/docs/source/overview/gym/index.rst
index 439445cfd..48db66eca 100644
--- a/docs/source/overview/gym/index.rst
+++ b/docs/source/overview/gym/index.rst
@@ -196,6 +196,8 @@ Choosing Where to Start
- Use :doc:`reward_functors` when composing RL reward terms.
- Use :doc:`dataset_functors` when recording demonstrations or exporting
datasets. Use this page for structured episode data, not debug video capture.
+- Use :doc:`/overview/trajectory_generation` when repeating candidate rollouts
+ from a caller-provided fixed initial state with exclusive batch ownership.
Documentation Quality Notes
---------------------------
diff --git a/docs/source/overview/sim/motion/index.rst b/docs/source/overview/sim/motion/index.rst
index 2d8a22438..6a5785790 100644
--- a/docs/source/overview/sim/motion/index.rst
+++ b/docs/source/overview/sim/motion/index.rst
@@ -27,12 +27,12 @@ public imports still use the normal ``lab`` and ``sim`` initialization path.
- Explicit qpos templates and candidates, constrained geometric and timing
variation, measured coverage, and bounded generation accounting.
-Trajectory augmentation provides the core contracts and operators. Physical
-initial-state restoration, planning, rollout execution, task validation, and
-dataset persistence must be supplied by host integrations. The core accepts
-their evidence and persistence receipts without creating those services. See the
+Trajectory augmentation provides the core contracts and operators. The
+:doc:`generation host layer ` supplies
+full-batch restoration, qpos execution, measured validation and confirmed
+persistence for free motion and offline atomic PickUp sources. See the
:doc:`augmentation API `
-for the implemented boundaries.
+for the core boundaries.
Migrating Existing Imports
--------------------------
@@ -55,7 +55,8 @@ imports, string-based module references in configuration, and custom extensions:
Their nested modules follow the same mapping. Solver ``class_type`` names such
as ``URSolver`` still resolve through ``RobotCfg.from_dict()``. Existing tests
and examples now live under ``tests/sim/motion/`` and ``examples/sim/motion/``.
-Warp kinematics kernels remain at ``embodichain.utils.warp.kinematics``.
+Warp kinematics kernels live under ``embodichain.compute.kinematics._warp``;
+``embodichain.utils.warp.kinematics`` retains compatibility exports.
.. toctree::
:maxdepth: 1
diff --git a/docs/source/overview/sim/motion/planners/curobo_planner.md b/docs/source/overview/sim/motion/planners/curobo_planner.md
index eefd3066a..6133b1884 100644
--- a/docs/source/overview/sim/motion/planners/curobo_planner.md
+++ b/docs/source/overview/sim/motion/planners/curobo_planner.md
@@ -292,13 +292,20 @@ robot's URDF and solver, so nothing robot-specific needs to be hardcoded:
The generated YAML is cached on disk (default `$XDG_CACHE_HOME/embodichain_curobo`
or `~/.cache/embodichain_curobo`) keyed by the URDF path, URDF content, control
-part, tool frame, and fit parameters, so editing the URDF or changing the fit
-settings regenerates automatically and subsequent inits reuse the cache. Tune the
+part, tool frame, non-control initial joint values, and fit parameters. The
+non-control values determine the generated `lock_joints` geometry, including the
+gripper opening. Editing those values, the URDF, or fit settings regenerates the
+corresponding cached profile; subsequent matching inits reuse it. Tune the
fit with `CuroboPlannerCfg.auto_gen` (`fit_type="voxel"` by default for fast
first-generation; `"morphit"` for best quality; `force=True` to bypass the cache).
The default `sphere_density=0.1` keeps the per-link sphere count low (~80 for a
Panda) so planning stays fast; raise it for tighter collision coverage.
+If non-control initial joint values change while a backend is active, cached
+backend reuse is rejected. Call `planner.close()` before rebuilding against the
+new locked-joint configuration. Changing only controlled-joint initial values
+does not invalidate the locked collision model.
+
## Generate a motion
MotionGenerator passes start_qpos and control_part to the cuRobo backend. For
diff --git a/docs/source/overview/trajectory_generation.rst b/docs/source/overview/trajectory_generation.rst
new file mode 100644
index 000000000..f0f94e5a6
--- /dev/null
+++ b/docs/source/overview/trajectory_generation.rst
@@ -0,0 +1,566 @@
+Fixed-Scene Trajectory Generation
+==================================
+
+Repeated expert rollouts need the same declared scene and initial state before
+each candidate executes. The :mod:`embodichain.lab.trajectory_generation` layer
+provides that preparation lifecycle for an entire simulator batch, either
+directly or through an ``EmbodiedEnv``. Its synchronous qpos runner connects
+candidate planning, physical execution, measured validation, and
+persistence of accepted episodes. It consumes a scene that the caller already
+created; it does not sample layouts.
+
+The :mod:`embodichain.lab.sim.motion.expansion` package provides
+qpos candidates, constrained variation, coverage, and generation accounting.
+Two collection paths are available: free motion through the existing locked-joint
+planner, and a bounded CPU-physics PickUp integration with full-joint geometry
+and physical contact validation. PickUp consumes offline atomic compilations;
+AtomicActionRuntime recovery/feedback execution, contact-aware Gym collection,
+and the unified generation CLI remain unimplemented. Initial-state preparation
+alone does not certify a candidate trajectory or confirm that an episode was saved.
+
+Supported Initial States
+------------------------
+
+The physical adapter supports one configuration-owned fixed-base robot and all
+ordinary rigid objects in the simulator. Every entity must cover every simulator
+row. Cases are supplied in physical row order, so one batch can retain different
+fixed cases in its rows. Partial-row restoration and replica rebinding are not
+implemented.
+
+Snapshots own local poses, rigid velocities, complete joint positions and
+velocities, drive targets, and joint effort state. They include mimic and gripper
+joints. Additional articulations, object groups, deformables, rigid constraints,
+and robots using USD articulation properties are rejected. The initial state
+must not depend on pending external forces or an active contact constraint;
+this is episode initialization, not a mid-contact physics checkpoint.
+
+The physical adapter has been exercised against a real headless CPU backend
+with physics both enabled and disabled, using one robot and one cube. Those
+checks cover backend state reads, writes, and restoration. The PickUp collection
+example below additionally exercises repeated restoration after real grasps;
+it does not restore a contact solver checkpoint.
+
+A Trusted Preparation Profile
+-----------------------------
+
+The caller supplies an
+:class:`~embodichain.lab.trajectory_generation.initial_state.InitialStateProfile`
+implemented for the task, controller, and scene configuration:
+
+* ``prepare`` deterministically initializes task state and controller history,
+ including state outside the standard Gym episode managers. It must preserve
+ the fixed scene conditions.
+* ``signature`` identifies fixed geometry, physics, materials, visuals, sensors,
+ and control conditions. It excludes evolving poses and joint positions and
+ must change when a condition relevant to the case changes.
+* ``verify`` returns nonempty, passing ``ValidationResult`` evidence for every
+ declared case after settling. It must check the task-specific initial
+ conditions; an unconditional passing result is not a preparation policy.
+* ``physics_dt`` declares the settling clock and must match ``env.physics_dt``
+ for Gym. ``settling_steps`` controls preparation-only simulation steps.
+* ``allowed_interval_events`` names Gym interval events explicitly checked to
+ preserve the fixed conditions. Any active interval event outside this list
+ prevents host acquisition or reuse.
+
+Callbacks come from trusted integration code. There is no generic safe default
+profile and no loading of profile callbacks from job YAML strings. The physical
+adapter checks scene structure and numeric state; it cannot infer the fixed
+condition identity or reset controller-specific memory for the profile.
+
+Preparation and Rollout Order
+-----------------------------
+
+:class:`~embodichain.lab.trajectory_generation.initial_state.FixedSceneHost`
+owns the complete simulator batch. With Gym it also acquires an environment
+generation lease, which suppresses automatic resets and rejects ordinary
+``env.reset()`` until ownership is released. The owner must serialize all host
+access and remain the sole physical stepper. Direct simulation hosts and Gym
+leases share ownership of the same simulator batch; a second owner through
+either interface is rejected.
+
+The first ``acquire_case(cases)`` performs deterministic preparation, settling,
+and verification, then captures the prepared physical states. Later
+``restore_initial()`` calls reconstruct those captured states. In Gym, each
+preparation follows this order:
+
+1. Invalidate the previous epoch and active Task Program bridge; disable
+ stepping while preparation is incomplete.
+2. Discard pending camera and episode recording state. The caller must already
+ own copies of the previous episode and its validation evidence.
+3. Run deterministic profile preparation, restore the captured physical state
+ when one exists, then perform the explicit settling steps.
+4. Reset observation history, reward, and dataset managers, then verify task
+ initial conditions and, for restoration, the captured physical fields. The
+ validator observes the new episode's manager state. A failed attempt publishes
+ no execution binding.
+5. Obtain the initial observation and seed enabled recorders from the verified
+ state.
+
+Preparation does not run reset/interval events, reseed the environment RNG, or
+call normal Gym ``step``. Profile callbacks must not call ``env.step`` or
+``env.reset``. Settling and the articulation root setter may advance physics;
+those preparation updates are outside the recorded rollout.
+
+``PreparedBatch`` is an execution binding for one host and epoch. Check it with
+``host.assert_current(binding)`` before issuing candidate commands. Restoration,
+failed preparation, and ownership release invalidate previous bindings. A new
+binding requires new runtime requests and Task Program bridges where applicable.
+
+``host.snapshots(binding)`` returns independent planning snapshots of the
+captured initial batch. For Gym, ``host.initial_observation(binding)`` copies the
+first observation already produced by preparation, preserving observation-history
+semantics without another ``get_obs`` call. It returns ``None`` for direct sim.
+
+Integrating an Existing Gym Scene
+---------------------------------
+
+The following helper assumes an already-created unwrapped ``EmbodiedEnv``, one
+``SceneCase`` per row, a task-specific trusted profile, and explicit execution
+and evidence-copy callbacks. ``execute_candidate`` uses the normal environment
+step path; ``freeze_episode`` copies all data needed by later validation and
+persistence before the next restoration discards the live buffers.
+
+.. code-block:: python
+
+ from embodichain.lab.trajectory_generation.initial_state import FixedSceneHost
+ from embodichain.lab.trajectory_generation.integrations.sim import (
+ SimInitialStateAdapter,
+ )
+
+ def run_two_candidates(env, cases, profile, execute_candidate, freeze_episode):
+ adapter = SimInitialStateAdapter(env.sim, env.robot)
+ episodes = []
+ with FixedSceneHost(adapter, profile, env=env) as host:
+ binding = host.acquire_case(cases)
+ for candidate_index in range(2):
+ if candidate_index:
+ binding = host.restore_initial()
+ host.assert_current(binding)
+ execute_candidate(env, binding, candidate_index)
+ episodes.append(freeze_episode(env, binding))
+ return episodes
+
+For direct simulation, construct the same host without ``env=``. The caller
+then owns observation refresh, rollout timing, recording, and task execution.
+Closing the host releases ownership and invalidates bindings without implicitly
+saving or resetting the final episode.
+
+Checking Free-Motion Candidates
+-------------------------------
+
+The
+:class:`~embodichain.lab.trajectory_generation.integrations.planning.EnvRowMotionPlanner`
+adapter connects explicit EEF or qpos candidates to a configured
+``MotionGenerator`` and the actual robot batch. Logical candidate count ``C`` can
+exceed physical row count ``B``: candidates are checked in rounds,
+with at most one candidate assigned to its source row in each round. Snapshots
+must cover the complete batch, and backend calls retain size ``B``.
+
+For EEF input,
+:class:`~embodichain.lab.trajectory_generation.integrations.planning.EEFPath`
+declares local-arena TCP samples, a source row, identity, explicit arrival
+intervals, and phases. ``plan_eef`` propagates successful IK seeds between
+samples and checks FK position/orientation residuals. Supplying solved joint
+samples preserves that branch without another IK solve. The output is a
+full-joint ``CandidateTrajectoryBatch`` and one validation result per candidate.
+
+For qpos input, ``validate_qpos`` checks the original candidate paths. It inserts
+bounded joint-space samples between every adjacent pair, including phase
+boundaries, before asking the backend to check self/environment collision.
+Sampling changes only the check inputs. Original candidate positions and timing
+remain intact.
+
+This initial adapter supports fully annotated free motion, explicitly declared
+empty ``held_object_ids``, and unchanged uncontrolled joints at the configured
+initial values. Contact/hold phases, unannotated paths, held-object sweeps, and
+changing locked joints
+such as gripper closure are outside this capability. Missing collision support
+or a path requiring more than ``max_validation_samples`` also returns
+``unavailable``. Such candidates cannot enter accepted rollout generation.
+
+An unchanged gripper command does not guarantee unchanged measured finger
+positions. The measured path must also match the backend's locked-joint values
+within ``1e-6``. A real Panda run under ordinary gravity exceeded this bound
+through finger drift and was rejected with no committed episode, even though its
+task, motion-quality, and speed/acceleration checks passed. This is an unsupported
+locked-model state for measured collision validation.
+
+The host must keep robot roots and canonical-ID collision obstacles synchronized
+with the snapshots. A passing sampled collision result does not provide a
+continuous collision guarantee, controller speed/acceleration validation, or
+task success. Those checks remain explicit later gates; this adapter is not yet
+a contact-aware PickUp planner.
+
+The adapter's real cuRobo smoke uses one Panda with CPU physics and CUDA collision
+checking. A short free path passes with a distant dynamic cube and fails after
+the cube is moved to the TCP, reusing the same backend. This checks dynamic-world
+collision updates for that sampled path; held-object and contact semantics remain
+outside the supported scope.
+
+Executing Qpos Candidates
+-------------------------
+
+:class:`~embodichain.lab.trajectory_generation.execution.QposRolloutExecutor`
+accepts a prepared host binding, one single-row candidate or ``None`` per
+physical row, and stable episode/commit IDs. Each candidate contains an initial
+sample followed by commands on the host's fixed control clock. Inactive and
+mimic joints must remain unchanged; the executor checks initial state, joint
+limits, layout, and payload size before submitting a command.
+
+The executor's byte limit includes tensors plus a 64 KiB metadata reserve.
+Executor metadata is limited to 32 KiB so the runner and sink can add their
+evidence within that reserve. Initial observation size and later schema changes
+are checked before the executor copies observation payloads.
+
+Gym execution uses the existing demo loop and ``ControllerAction``. It reads the
+actual controller position targets before physics and consumes the existing
+``env.step`` observation afterward. Direct sim uses explicit integer physics
+substeps and a full-batch observation callback. A Gym encoder can transform the
+prepared/step observations; its default flattens all tensor fields without
+dropping channels. The executor always includes measured full-joint
+``joint_positions``.
+The default encoder requires nonempty string keys without embedded dots and
+tensor leaves; other observation structures require an explicit encoder.
+
+Timestamps are differences of ``SimulationManager.simulation_time`` and each
+transition must advance exactly one control period within tolerance. That clock
+counts manager-owned updates; external direct backend updates are outside it and
+must not occur during a rollout owned by this executor.
+
+Completed rows freeze their causal frames while other rows finish. Idle,
+finished, or terminated rows receive holds, and those holds do not enter the
+returned training sequence. Installing holds clears velocity/effort targets
+without advancing physics. A row with no complete transition returns ``None``;
+an interrupted row with some complete transitions returns rejected evidence.
+``executor.last_failures`` keeps bounded candidate-specific runtime reasons even
+when no complete transition exists. It is read-only and resets at the next call;
+the runner records these reasons when releasing failed candidates.
+The final task validator consumes owned row observations, actions, and times.
+It must implement the task's real success conditions and include its configured
+check ID.
+
+The executor checks root and rigid-object pose stability at every observation
+boundary and adds ``fixed_collision_world`` evidence. The runner uses that
+evidence when rechecking the measured qpos path against the initial collision
+snapshot. Boundary checks and sampled collision checks do not establish
+continuous collision freedom or support contact/held-object tasks.
+
+Assembling the Handwritten Runner
+---------------------------------
+
+:class:`~embodichain.lab.trajectory_generation.runner.GenerationRunner` combines
+the explicit host, planning, execution, and persistence interfaces documented
+here. The caller supplies qualified references, initial-state and validation
+profiles, and full-joint limits through
+:class:`~embodichain.lab.trajectory_generation.runner.MotionLimitsProfile`.
+
+Every host rigid-object physical UID must appear in the backend's collision-world
+and dynamic-pose entity IDs. This lets the runner supply the captured initial
+poses for all rows after preparation. Dynamic here means updateable collision
+poses, including physically static bodies. A baked static world is insufficient;
+different relative layouts across rows require per-environment backend worlds.
+Semantic aliases are not yet connected. The trusted profile must also establish
+coverage of relevant implicit planes and other geometry outside the rigid
+registry. The executor's per-row byte limit must not exceed the sink or job
+pending-byte limit.
+
+.. code-block:: python
+
+ from embodichain.lab.trajectory_generation.runner import GenerationRunner
+
+ # All ports are explicitly constructed for the same scene, robot, and clock.
+ # host owns the batch, but has not yet called acquire_case().
+ runner = GenerationRunner(
+ cfg, host, planner, executor, sink, motion_limits=motion_limits
+ )
+ report = runner.run(cases, templates)
+
+``cases`` and ``templates`` contain one item per physical row in matching order.
+The runner verifies profile/source/template IDs and clock compatibility, proposes
+allowed residual and retiming variants, validates their paths and motion limits,
+then reserves capacity before each rollout. Measured paths, task evidence,
+speed/acceleration, and path-length/duration quality gates determine which
+episodes reach the sink. Write retries reuse frozen evidence.
+
+Planning evidence is retained as ``planned_*`` checks. The measured-path collision
+check, ``actual_motion_limits``, and ``motion_quality`` remain separate gates;
+planned feasibility alone cannot qualify the saved episode.
+
+``run`` is single-use and closes the supplied host and sink on exit. It returns
+counts, coverage, audit, resolved configuration, and ``target_reached`` and writes
+``generation_report.json`` under the sink root. Reaching a proposal, rollout, or
+wall-time budget can finish without reaching the collection target. The PickUp
+adapter below adds contact-aware offline atomic replay; a unified configuration
+registry and generation CLI remain subsequent work.
+
+Running the Real Free-Motion Example
+-------------------------------------
+
+From the repository root, with the simulation dependencies, CUDA, cuRobo, and
+LeRobot installed, use a new or empty output directory:
+
+.. code-block:: bash
+
+ python examples/sim/motion/trajectory_generation/free_motion.py --output /tmp/ur5-free-motion
+
+The example constructs its own fixed scene and trusted preparation/validation
+profiles. It uses a pure-arm UR5 with ordinary gravity, one CPU physics row,
+CUDA collision checking, and no desktop window. The default run has no camera.
+A registered ground proxy
+matches the simulator's implicit floor so the collision world covers that
+geometry. ``--cuda-device`` selects the GPU, defaulting to ``0``.
+
+One second of motion produces 21 measured observations and 20 actual commands.
+The verified run committed one episode after planned and measured collision,
+execution, task, quality, and motion-limit checks passed. It measured about
+``0.078021`` rad of movement, ``0.003212`` rad endpoint error, and ``0.010872`` rad
+maximum tracking error. These are results of this short example, not a PickUp
+or throughput acceptance result.
+
+The script prints counts, ``target_reached``, and the report path. The output
+contains ``generation_report.json``, a committed-shard ``manifest.json``, and
+the LeRobot dataset plus required evidence files described below. A run that
+does not reach its target exits with status ``1``. Only manifest-listed shards
+are committed training data.
+
+To save an offscreen video of a larger, five-second motion, install
+``imageio-ffmpeg`` in the same environment and run:
+
+.. code-block:: bash
+
+ python examples/sim/motion/trajectory_generation/free_motion.py --output /tmp/ur5-video --record-video --duration 5 --joint-displacement 0.4
+
+``--record-video`` adds a 640 x 480 RGB camera and streams its actual execution
+frames to ``preview.mp4`` using H.264 at 20 fps. It includes the initial frame
+and each subsequent observation, including the terminal frame: five seconds of
+physical motion produces 101 frames, so the video lasts 5.05 seconds. Numeric
+LeRobot observations retain their existing schema; this preview is a separate
+visual record. A video may also show a rejected attempt, so use
+``generation_report.json`` and the committed manifest to assess whether the
+episode qualified as expert data.
+
+``--duration`` accepts 0.05 through 30 seconds in multiples of 0.05; its default
+is 1 second. ``--joint-displacement`` controls the first arm joint's positive
+displacement, up to 0.5 rad, and defaults to 0.08 rad. Changing these values
+preserves the validation gates and does not guarantee acceptance.
+
+Using ``--robot panda`` with a separate empty output directory runs the rejection
+example: ordinary-gravity finger drift exceeds the locked-model tolerance, so
+measured collision validation is unavailable and no episode is committed.
+The real integration test covers both outcomes:
+
+.. code-block:: bash
+
+ pytest tests/lab/trajectory_generation/test_runner_real.py --run-gpu -m gpu -q
+
+Both cases passed. The UR5 case also decodes the saved H.264 preview and checks
+that frame timestamps match all measured observations, including the terminal
+state, without extending the physical rollout. Real acceptance currently covers
+this one-row direct-sim free-motion example; Gym lifecycle/execution contracts
+have separate CPU tests. PickUp has a separate collection example below. The
+full source/runtime and sim/Gym qualification matrix remains pending.
+
+Parallel Cube-Grasp Augmentation Preview
+----------------------------------------
+
+The standalone ``cube_grasp_parallel.py`` example compares grasp-pose and
+transit-path augmentation with four synchronized physical rows:
+
+.. code-block:: bash
+
+ python examples/sim/motion/trajectory_generation/cube_grasp_parallel.py --output /tmp/cube-grasp-parallel
+
+Each row has the same initial UR5/PGI configuration and the same 5 cm cube on a
+bench, with normal gravity. The rows cross two object-relative grasp
+orientations (0 and 90 degrees) with reference and augmented transit paths.
+``rotate_grasp_about_object_axis`` uses the cube's quarter-turn symmetry to
+derive TCP goals. Existing ``MoveEndEffector`` and ``PickUp`` Atomic Skills
+replan each goal using the UR IK solver and ``ik_interp`` strategy.
+``joint_residual`` changes only the transit before the pre-grasp corridor,
+preserving its endpoints and the later approach, close, and lift commands.
+
+Four offscreen cameras capture the same physics tick and stream a 1280 x 1056,
+20 fps four-panel ``preview.mp4``. Colored trails show measured TCP motion;
+labels show grasp orientation, phase, time, and measured cube lift. The
+``--seed`` option selects the local residual random stream (default ``13``),
+and ``--cuda-device`` selects the renderer GPU. Simulation dependencies, the
+UR5/PGI assets, TOPPRA, and ``imageio[ffmpeg]`` are required; this example does
+not initialize a cuRobo collision backend. Use a new or empty output directory.
+
+``rollout.npz`` stores reference and commanded qpos, measured full-joint qpos,
+TCP/cube poses, grasp goals, and measured timestamps. ``report.json`` records
+augmentation factors, phase boundaries, path separation, and per-row outcomes.
+The final hold requires at least 12 cm of sustained cube lift, at most 1 cm of
+TCP-relative position drift, and a cube-to-TCP distance below 6 cm. A run exits
+with status 1 if any row fails. Cubes are moved through physical gripper contact;
+no attachment constraint or pose update supplies the lift.
+
+The default run passed all four grasp checks, with approximately 17 cm of
+maximum separation between paired transit paths. Its 13.55 seconds of physical
+motion produces 272 frames including the initial and terminal observations.
+This validates the demonstrated grasp behavior. Contact-aware collision
+certification and expert LeRobot qualification remain outside this preview;
+use the separate PickUp collection example below for expert-data validation.
+
+Saving Accepted Episodes
+------------------------
+
+:class:`~embodichain.lab.trajectory_generation.sinks.LeRobotEpisodeSink` consumes
+an owned ``ExpertEpisode`` whose validation checks have passed. Each submission
+creates a local LeRobot dataset shard containing one episode. The sink has no
+live environment dependency and can receive frozen data from either a sim or Gym
+host. The caller still owns task/trajectory validation and episode construction.
+
+An episode with ``T`` commands is stored as:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 26 74
+
+ * - Artifact
+ - Content
+ * - ``dataset/``
+ - ``T`` LeRobot frames pairing observations ``0..T-1`` with actual commands;
+ RGB observations are stored as images.
+ * - ``terminal.npz``
+ - Observation ``T`` and all ``T+1`` measured timestamps, including their
+ original time origin.
+ * - ``episode.json``
+ - Candidate/episode/commit identity, action representation, validation,
+ phases, feature mapping, and user metadata.
+ * - Collection ``manifest.json``
+ - The committed episode-to-shard mapping. Unlisted shards are not committed.
+
+The complete causal sequence consists of the LeRobot training frames plus the
+required terminal sidecar. Measured timestamps must match the configured integer
+``fps`` within ``timestamp_tolerance``. LeRobot frame timestamps are relative to
+the episode origin; the exact measured clock remains in the sidecar.
+
+Numeric observations have shape ``(T+1,D)`` or ``(T+1,M,N)`` and dtype float32,
+float64, int32, int64, or uint8. Matrices flatten to vectors in C order for
+LeRobot, with their source layout in ``observation_shapes`` and their terminal
+matrix preserved in the sidecar. RGB images use uint8 ``(T+1,H,W,3)``. Actions use float32 or
+float64 ``(T,A)``. Unsupported layouts, conflicting feature names, invalid
+clocks, and payloads exceeding ``max_episode_bytes`` are rejected before episode
+writes. The byte limit covers raw tensors and metadata per submission.
+
+After writing, the sink finalizes the LeRobot writer, opens every training frame
+and image again, verifies both sidecars, and replaces and reads back the manifest.
+Only then does ``submit`` return ``receipt.confirmed=True``. Persistence errors
+return an unconfirmed receipt with an error message; they do not count toward
+confirmed generation coverage.
+
+.. code-block:: python
+
+ from embodichain.lab.trajectory_generation.sinks import LeRobotEpisodeSink
+
+ # episode already owns the accepted rollout observations and actual commands.
+ with LeRobotEpisodeSink("outputs/accepted-episodes", fps=50) as sink:
+ receipt = sink.submit(episode)
+ if not receipt.confirmed:
+ # Reuse the same episode and commit ID; distinguish this submission.
+ receipt = sink.submit(episode, submission_id=1)
+ if not receipt.confirmed:
+ raise RuntimeError(receipt.error)
+
+The output directory must be new or empty. An identical payload under a confirmed
+commit ID only reads and verifies the existing artifacts; a different payload
+under that ID raises. A confirmed duplicate does not rewrite files or create
+another logical episode.
+Readable sealed data can be reused after a metadata failure, and incomplete
+uncommitted data can be rebuilt at the same shard path. This retry behavior
+applies within the owning sink process; reopening an existing collection after
+a process restart is not supported.
+
+When using ``GenerationSession``, first reserve the measured episode with
+``accept_episode``; submit only when it returns ``True``. Apply every returned
+receipt with ``apply_receipt``.
+After a failed write, use ``retry_write(commit_id)`` to obtain the retained
+episode and its next submission ID before resubmitting. Successful write retries
+reuse the rollout; they do not generate another physical attempt.
+
+Submission is synchronous and serialized. ``drain()`` has no deferred receipts,
+and ``close()`` releases ownership without another save operation. Confirmations
+cover closed, readable artifacts; they do not promise restart recovery or
+power-loss durability.
+
+Public interfaces and supported snapshot fields are documented in the
+:doc:`trajectory-generation API `.
+
+Contact-Validated Parallel PickUp Collection
+--------------------------------------------
+
+``examples/sim/motion/trajectory_generation/cube_pickup_collection.py`` reuses
+the cube preview's four-row scene and MoveEndEffector/PickUp compilation, then
+connects them to ``FixedSceneHost``, ``GenerationRunner``, ``GenerationSession``
+and ``LeRobotEpisodeSink``. Each row receives its own local residual proposal
+in transit; approach, closing, lift and hold remain protected. Two grasp goals
+use the cube's 0/90-degree symmetry. All four rows share the physical clock.
+
+.. code-block:: bash
+
+ python -m pip install -e '.[trajectory-generation]' 'imageio[ffmpeg]'
+ python examples/sim/motion/trajectory_generation/cube_pickup_collection.py \
+ --output /tmp/cube-experts --episodes 8 --record-video
+
+The output directory must be new or empty. ``--episodes`` defaults to 8 and is
+bounded to 1–64; ``--seed`` defaults to 13. ``--cuda-device`` selects the renderer.
+Physics uses CPU at 200 Hz with normal gravity; control/video use 20 Hz.
+The UR5 arm stiffness is 200000 with the existing damping, and the PGI open
+command sits 1 mm inside its joint limit to avoid limit overshoot during transit.
+These values are fixed before initial-state capture; the same endpoint/tracking
+and collision tolerances apply to both grasp orientations.
+
+The PickUp integration adds the following mandatory evidence:
+
+* Planned full-joint geometry includes both fingers and their mimic coupling.
+ Conservative convex hulls come from URDF collision shapes, not render meshes.
+ Joint-segment sampling is bounded; the held cuboid follows the TCP after lift
+ begins. The implicit ground is represented explicitly in the checker.
+* Actual geometry uses measured joints, including passive fingers, and the
+ measured cube pose. Only the declared target may move; robot root and other
+ scene objects must retain their captured poses at observation boundaries.
+* Native CPU contacts are read after every 5 ms physics step. The profile allows
+ only declared fixed mounting contacts, cube/support contact through initial
+ lift-off, and finger/cube contact during closing/lift/hold. During approach,
+ finger contact is confined to the declared entry region; this PGI example uses
+ 6 cm to include fingertip overhang beyond its TCP. Other contacts, unknown
+ bodies, cross-row pairs, more than 2 mm penetration, and buffer-budget overflow
+ reject the evidence. URDF self pairs within two kinematic hops are structural
+ exclusions shared with the existing cuRobo policy.
+* During the 1.5-second terminal hold, each finger must report positive normal
+ impulse in at least 95% of physics samples and the cube must remain at least
+ 12 cm above its initial height. Translation and rotation drift relative to TCP
+ are checked from lift through hold, with 1 cm / 0.15 rad limits. The cube must
+ remain within 6 cm of TCP. Arm endpoint/tracking tolerances are 0.05 / 0.08 rad.
+ Motion limits and path/duration quality checks remain mandatory.
+
+These are conservative sampled geometric checks plus actual discrete-physics
+contact evidence, not continuous collision detection. The integration currently
+supports one unscaled fixed-base URDF robot and cuboid rigid scene objects.
+It requires the optional ``python-fcl``, ``trimesh`` and ``yourdfpy`` dependencies.
+Contact-aware Gym execution requires equivalent physics-substep evidence and
+is rejected at construction today.
+
+Between rounds, the entire robot/cube state is restored and verified, including
+current joints, passive coordinates, velocities, drive targets and efforts.
+Every returned episode has T actual controller targets and T+1 measured
+observations/timestamps. ``commanded_joint_indices`` identifies actuated columns
+in the full ordered joint vector; passive target columns are retained as read
+from the controller, while collision checks use actual passive positions.
+
+The collection saves ``generation_report.json`` (attempts, rejections, validation
+and commit audit), ``pickup_report.json`` (contact profile, prepared rounds and
+video frames), and ``manifest.json`` (only read-back-confirmed shards). Each shard
+contains LeRobot training frames plus ``episode.json`` and ``terminal.npz``.
+Object/TCP 4x4 matrices are flattened in C order into 16-element LeRobot numeric
+features; ``observation_shapes`` records their original layout. Terminal matrices
+retain their 4x4 shape. ``preview.mp4`` includes accepted and rejected attempts;
+it is a four-panel record of actual observations, and each round restarts its
+trail and time display. Video is separate from the numeric training dataset.
+
+``source.kind=atomic`` here identifies an offline compilation source. Exporting
+its trajectory does not commit projected symbolic effects or execute the atomic
+recovery/tracking state machine. A full runtime source adapter, the four-way
+handwritten/atomic × sim/Gym qualification matrix, general via-point planning,
+and YAML-based deployment remain subsequent implementation milestones.
diff --git a/embodichain/lab/gym/envs/base_env.py b/embodichain/lab/gym/envs/base_env.py
index 536a50521..8df2ed3a3 100644
--- a/embodichain/lab/gym/envs/base_env.py
+++ b/embodichain/lab/gym/envs/base_env.py
@@ -17,7 +17,8 @@
from __future__ import annotations
import math
-from collections.abc import Mapping
+from collections.abc import Callable, Iterator, Mapping
+from contextlib import contextmanager
from numbers import Integral, Real
import torch
@@ -136,6 +137,11 @@ def __init__(
**kwargs,
):
self.cfg = cfg
+ self._generation_lease_owner: object | None = None
+ self._generation_epoch = 0
+ self._generation_prepared = False
+ self._generation_preparing = False
+ self._generation_no_auto_reset = False
# the number of envs to be simulated in parallel.
self._num_envs = self.cfg.num_envs
@@ -820,6 +826,108 @@ def _step_action(self, action: EnvAction) -> EnvAction:
"""
pass
+ @property
+ def generation_epoch(self) -> int:
+ """Return the revision used to invalidate generation runtime bindings.
+
+ Acquisition, release, each controlled preparation attempt, and normal
+ resets advance this revision, including preparations that fail.
+ """
+ return getattr(self, "_generation_epoch", 0)
+
+ def acquire_generation_lease(self, owner: object) -> None:
+ """Reserve this complete environment batch for controlled generation.
+
+ The environment and simulator share one owner identity. The same owner
+ may acquire its lease repeatedly. While reserved, normal resets are
+ rejected and automatic resets are disabled. The caller must prepare a
+ generation episode before stepping and serialize host access.
+
+ Args:
+ owner: Identity token retained until release; must not be ``None``.
+
+ Raises:
+ ValueError: If the owner is ``None``.
+ RuntimeError: If another owner already holds the lease.
+ """
+ if owner is None:
+ raise ValueError("A generation lease owner must not be None.")
+ current = getattr(self, "_generation_lease_owner", None)
+ sim_owner = getattr(self.sim, "_trajectory_generation_owner", None)
+ if current is owner:
+ self._require_generation_lease(owner)
+ return
+ if current is not None or sim_owner is not None:
+ raise RuntimeError(
+ "The environment or simulator already has a generation lease owner."
+ )
+ self.sim._trajectory_generation_owner = owner
+ self._generation_lease_owner = owner
+ self._generation_epoch = self.generation_epoch + 1
+ self._generation_prepared = False
+ self._generation_no_auto_reset = True
+
+ def _require_generation_lease(self, owner: object) -> None:
+ """Require the same lease identity on the environment and simulator."""
+ if (
+ owner is None
+ or getattr(self, "_generation_lease_owner", None) is not owner
+ or getattr(self.sim, "_trajectory_generation_owner", None) is not owner
+ ):
+ raise RuntimeError("The caller does not own the generation lease.")
+
+ def release_generation_lease(self, owner: object) -> None:
+ """Release generation ownership without saving or resetting an episode.
+
+ Args:
+ owner: The identity token used to acquire the lease.
+
+ Raises:
+ RuntimeError: If the caller is not the owner or preparation is active.
+ """
+ self._require_generation_lease(owner)
+ if getattr(self, "_generation_preparing", False):
+ raise RuntimeError("Cannot release a generation lease during preparation.")
+ if getattr(self, "_generation_command_observer", None) is not None:
+ raise RuntimeError(
+ "Cannot release a generation lease during command observation."
+ )
+ self.sim._trajectory_generation_owner = None
+ self._generation_lease_owner = None
+ self._generation_epoch = self.generation_epoch + 1
+ self._generation_prepared = False
+ self._generation_no_auto_reset = False
+
+ @contextmanager
+ def observe_generation_commands(
+ self, owner: object, observer: Callable[[], None]
+ ) -> Iterator[None]:
+ """Observe successfully submitted controller commands before physics.
+
+ The callback runs after ``_step_action`` returns and before simulation
+ advances. It can read actual robot targets without action postprocessing
+ changing the evidence. Exceptions propagate after command submission;
+ callers must count the attempt and safely stop the robot. Only the
+ generation lease owner may install one serialized observer.
+
+ Args:
+ owner: Current generation lease identity.
+ observer: Callback invoked once per submitted batched command.
+
+ Yields:
+ Control while the observer is installed.
+ """
+ self._require_generation_lease(owner)
+ if not callable(observer):
+ raise TypeError("The generation command observer must be callable.")
+ if getattr(self, "_generation_command_observer", None) is not None:
+ raise RuntimeError("A generation command observer is already installed.")
+ self._generation_command_observer = (owner, observer)
+ try:
+ yield
+ finally:
+ self._generation_command_observer = None
+
def reset(
self, seed: int | None = None, options: dict | None = None
) -> Tuple[EnvObs, Dict]:
@@ -831,7 +939,15 @@ def reset(
Returns:
A tuple containing the observations and infos.
+
+ Raises:
+ RuntimeError: If a generation lease owns this environment batch.
"""
+ if getattr(self, "_generation_lease_owner", None) is not None:
+ raise RuntimeError(
+ "Normal reset is disabled while a generation lease is held."
+ )
+ self._generation_epoch = self.generation_epoch + 1
if seed is not None:
seed = self._set_seed(seed)
super().reset(seed=seed)
@@ -895,13 +1011,27 @@ def step(
Returns:
A tuple contraining the observation, reward, terminated, truncated, and info dictionary.
+
+ Raises:
+ RuntimeError: If generation owns the batch without a successfully
+ prepared episode.
"""
+ if getattr(self, "_generation_lease_owner", None) is not None and not getattr(
+ self, "_generation_prepared", False
+ ):
+ raise RuntimeError("Prepare a valid generation episode before stepping.")
+
with self._profiler.section("step", is_root=True):
with self._profiler.section("preprocess_action"):
action = self._preprocess_action(action=action)
with self._profiler.section("step_action"):
action = self._step_action(action=action)
+ command_observer = getattr(self, "_generation_command_observer", None)
+ if command_observer is not None:
+ observer_owner, observer = command_observer
+ self._require_generation_lease(observer_owner)
+ observer()
with self._profiler.section("sim_update"):
self.sim.update(self.physics_dt, self.cfg.sim_steps_per_control)
@@ -957,6 +1087,7 @@ def step(
if not (
getattr(self, "_replay_no_auto_reset", False)
or getattr(self, "_demo_no_auto_reset", False)
+ or getattr(self, "_generation_no_auto_reset", False)
):
reset_env_ids = dones.nonzero(as_tuple=False).squeeze(-1)
if len(reset_env_ids) > 0:
diff --git a/embodichain/lab/gym/envs/demo.py b/embodichain/lab/gym/envs/demo.py
index 6414bf8ea..708a2ea01 100644
--- a/embodichain/lab/gym/envs/demo.py
+++ b/embodichain/lab/gym/envs/demo.py
@@ -649,18 +649,36 @@ def resolve_demo_segments(env: Any, **kwargs: Any) -> Iterable[DemoSegment]:
else (DemoSegment(actions, name="legacy", metadata={"segment_count": 1}),)
)
+ return _validated_demo_segments(
+ segments,
+ fallback_instruction=_dataset_instruction(env),
+ source="create_demo_segments()",
+ )
+
+
+def _validated_demo_segments(
+ segments: Iterable[DemoSegment] | DemoSegment | None,
+ *,
+ fallback_instruction: str,
+ source: str,
+) -> Iterable[DemoSegment]:
+ """Normalize a segment source without eagerly consuming lazy plans."""
if segments is None:
return ()
if isinstance(segments, DemoSegment):
segments = (segments,)
-
- fallback_instruction = _dataset_instruction(env)
+ try:
+ iterator = iter(segments)
+ except TypeError as exc:
+ raise TypeError(
+ f"{source} must be a DemoSegment or an iterable of DemoSegment objects."
+ ) from exc
def _validate() -> Iterable[DemoSegment]:
- for segment in segments:
+ for segment in iterator:
if not isinstance(segment, DemoSegment):
raise TypeError(
- "create_demo_segments() must yield DemoSegment objects, "
+ f"{source} must yield DemoSegment objects, "
f"got {type(segment).__name__}."
)
if segment.instruction is None:
@@ -673,14 +691,17 @@ def _validate() -> Iterable[DemoSegment]:
def execute_demo_episode(
env: Any,
*,
+ segments: Iterable[DemoSegment] | DemoSegment | None = None,
episode_index: int = 0,
execution_cfg: DemoExecutionCfg | None = None,
attempt_id: int = 0,
should_stop: StopPredicate | None = None,
progress: ProgressWrapper | None = None,
+ step_observer: Callable[[Any, tuple[bool, ...]], None] | None = None,
+ row_step_limits: tuple[int, ...] | None = None,
**plan_kwargs: Any,
) -> DemoEpisodeResult:
- """Plan and execute every segment in one environment episode.
+ """Execute supplied segments or plan one environment demonstration episode.
Auto-reset is suspended for the duration of execution. The caller owns the
transaction boundary and must explicitly call ``env.reset()`` to commit a
@@ -689,18 +710,36 @@ def execute_demo_episode(
Args:
env: Gym environment or wrapper.
+ segments: Explicit candidate segments, consumed lazily without calling
+ the environment's planning methods. ``None`` retains task-owned
+ planning. Explicit segments cannot be combined with planning
+ arguments.
episode_index: Logical episode identifier used in metadata and logs.
execution_cfg: Collector-owned output settings. Defaults to continuous
episode persistence.
attempt_id: Zero-based identifier for this collection attempt.
should_stop: Optional callback checked before every action.
progress: Optional wrapper such as ``tqdm`` for action iterables.
+ step_observer: Optional callback receiving the unchanged ``env.step``
+ result and the rows active for that transition. It runs before
+ terminal handling, without querying observations again.
+ row_step_limits: Optional full-batch action counts for supplied ragged
+ trajectories. Zero skips a row. Finished rows stop recording and
+ receive normal demo hold commands while others finish; their task
+ success is checked at the final batch boundary. ``None`` preserves
+ ordinary segment execution.
**plan_kwargs: Arguments forwarded to the task's planning method.
Returns:
A :class:`DemoEpisodeResult` describing segment spans and terminal
state.
+
+ Raises:
+ TypeError: If a segment source contains values other than DemoSegment.
+ ValueError: If explicit segments and planning arguments are combined.
"""
+ if segments is not None and plan_kwargs:
+ raise ValueError("Explicit segments cannot be combined with plan_kwargs.")
if execution_cfg is None:
execution_cfg = DemoExecutionCfg()
elif not isinstance(execution_cfg, DemoExecutionCfg):
@@ -710,6 +749,18 @@ def execute_demo_episode(
target = _env_target(env)
num_envs = int(getattr(target, "num_envs", 1))
+ if step_observer is not None and not callable(step_observer):
+ raise TypeError("step_observer must be callable or None.")
+ if row_step_limits is not None:
+ row_step_limits = tuple(row_step_limits)
+ if (
+ segments is None
+ or len(row_step_limits) != num_envs
+ or any(type(value) is not int or value < 0 for value in row_step_limits)
+ ):
+ raise ValueError(
+ "row_step_limits require explicit segments and one nonnegative action count per row."
+ )
begin_episode = _get_env_callable(env, "_begin_demo_episode_recording")
begin_segment = _get_env_callable(env, "_begin_demo_segment_recording")
end_segment = _get_env_callable(env, "_end_demo_segment_recording")
@@ -719,7 +770,10 @@ def execute_demo_episode(
set_active_mask = _get_env_callable(env, "_set_demo_active_mask")
success_fn = _get_env_callable(env, "is_task_success")
- active = [True] * num_envs
+ active = [
+ row_step_limits is None or row_step_limits[row] > 0 for row in range(num_envs)
+ ]
+ row_exhausted = [False] * num_envs
def publish_active_mask() -> None:
"""Publish executor liveness to recording hooks and action masking."""
@@ -757,7 +811,15 @@ def publish_active_mask() -> None:
try:
segment_count = 0
- segments = iter(resolve_demo_segments(env, **plan_kwargs))
+ segment_iterator = iter(
+ resolve_demo_segments(env, **plan_kwargs)
+ if segments is None
+ else _validated_demo_segments(
+ segments,
+ fallback_instruction=_dataset_instruction(env),
+ source="segments",
+ )
+ )
while any(active):
if should_stop is not None and should_stop():
fatal_reason = "interrupted"
@@ -768,7 +830,7 @@ def publish_active_mask() -> None:
publish_active_mask()
break
try:
- segment = next(segments)
+ segment = next(segment_iterator)
except StopIteration:
break
@@ -847,7 +909,8 @@ def publish_active_mask() -> None:
active_before_step = tuple(active)
try:
- _, _, terminated_value, truncated_value, info = env.step(action)
+ step_result = env.step(action)
+ _, _, terminated_value, truncated_value, info = step_result
except Exception as exc:
action_error = exc
actions_exhausted = False
@@ -859,6 +922,14 @@ def publish_active_mask() -> None:
for env_id, was_active in enumerate(active_before_step):
if was_active:
lengths[env_id] += 1
+ if step_observer is not None:
+ try:
+ step_observer(step_result, active_before_step)
+ except Exception as exc:
+ action_error = exc
+ actions_exhausted = False
+ segment_reason = "step_observation_failed"
+ break
step_terminated = _as_bool_tuple(terminated_value, num_envs)
step_truncated = _as_bool_tuple(truncated_value, num_envs)
@@ -926,11 +997,17 @@ def publish_active_mask() -> None:
actions_exhausted = False
break
+ if row_step_limits is not None:
+ for env_id, is_active in enumerate(active):
+ if is_active and lengths[env_id] >= row_step_limits[env_id]:
+ active[env_id] = False
+ row_exhausted[env_id] = True
+ terminal_reasons[env_id] = "trajectory_exhausted"
publish_active_mask()
if not any(active):
# Every row reached episode-level success. Stop this segment
# and do not request another lazy segment.
- actions_exhausted = False
+ actions_exhausted = any(row_exhausted)
break
if should_stop is not None and should_stop():
actions_exhausted = False
@@ -1037,10 +1114,13 @@ def publish_active_mask() -> None:
continue
if completed_by_env[env_id] and success[env_id]:
segment_successes[env_id] = True
- elif active[env_id] and validation[env_id]:
+ elif (active[env_id] or row_exhausted[env_id]) and validation[
+ env_id
+ ]:
segment_successes[env_id] = True
- elif active[env_id]:
+ elif active[env_id] or row_exhausted[env_id]:
validation_failed = True
+ row_exhausted[env_id] = False
segment_failure_reasons[env_id] = "segment_validation_failed"
terminal_reasons[env_id] = "segment_validation_failed"
@@ -1115,7 +1195,7 @@ def publish_active_mask() -> None:
terminal_reasons[env_id] = fatal_reason
active[env_id] = False
publish_active_mask()
- elif fatal_reason is None and any(active):
+ elif fatal_reason is None and (any(active) or any(row_exhausted)):
# Normal plan exhaustion validates only rows that have not already
# reached sticky episode success. Legacy expert tasks use
# is_task_success() for this final validation.
@@ -1126,7 +1206,7 @@ def publish_active_mask() -> None:
)
final_success = _as_bool_tuple(success_source, num_envs)
for env_id, is_active in enumerate(active):
- if not is_active:
+ if not is_active and not row_exhausted[env_id]:
continue
success[env_id] = final_success[env_id]
completed_by_env[env_id] = final_success[env_id]
diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py
index b8de4176e..8c8fa83b0 100644
--- a/embodichain/lab/gym/envs/embodied_env.py
+++ b/embodichain/lab/gym/envs/embodied_env.py
@@ -16,7 +16,7 @@
from __future__ import annotations
-from collections.abc import Mapping
+from collections.abc import Callable, Mapping
from math import log
from functools import wraps
from datetime import datetime
@@ -83,6 +83,7 @@
from embodichain.data.constants import EMBODICHAIN_DEFAULT_DATA_ROOT
if TYPE_CHECKING:
+ from embodichain.lab.sim.motion.expansion import ValidationResult
from embodichain.lab.task_program import CompiledTaskProgram, TaskProgramCfg
from embodichain.lab.task_program.integrations import (
TaskProgramAdapterFactory,
@@ -574,6 +575,85 @@ def reset(
self._seed_recording_state(obs, reset_ids)
return obs, info
+ def prepare_generation_episode(
+ self,
+ owner: object,
+ *,
+ prepare: Callable[[], None],
+ restore: Callable[[], None],
+ settle: Callable[[], None],
+ verify: Callable[[], ValidationResult],
+ ) -> tuple[EnvObs, Dict[str, Any]]:
+ """Discard buffered evidence and prepare the whole leased batch.
+
+ Freeze any previous episode before calling this method. Host callbacks
+ own deterministic task/controller preparation, physical restoration,
+ settling, and initial-state verification. They must not call ``step``
+ or ``reset``. Reset/interval events and environment seeding are never
+ run here. Standard managers and observation histories are reset after
+ settling, before verification checks their initial state. Recording is
+ seeded only after successful verification and fresh observation capture.
+
+ Every attempt invalidates earlier generation epochs. A failed attempt
+ leaves stepping disabled until another preparation succeeds.
+
+ Args:
+ owner: The current generation lease identity.
+ prepare: Initialize deterministic task and controller state.
+ restore: Restore the host's complete captured initial state.
+ settle: Advance the host through its explicit settling policy.
+ verify: Return nonempty, all-passed initial-state evidence.
+
+ Returns:
+ The prepared observation and task information for the entire batch.
+
+ Raises:
+ RuntimeError: If the lease is invalid, preparation is active, or
+ initial-state verification does not pass.
+ TypeError: If callbacks are not callable or verification returns
+ something other than ``ValidationResult``.
+ """
+ from embodichain.lab.sim.motion.expansion import ValidationResult
+
+ self._require_generation_lease(owner)
+ if getattr(self, "_generation_preparing", False):
+ raise RuntimeError("Generation episode preparation is already active.")
+ self._generation_epoch = self.generation_epoch + 1
+ self._generation_prepared = False
+ self._active_task_program_bridge = None
+ if not all(
+ callable(callback) for callback in (prepare, restore, settle, verify)
+ ):
+ raise TypeError("Generation preparation requires four callable callbacks.")
+ env_ids = torch.arange(self.num_envs, dtype=torch.long, device=self.device)
+ self._generation_preparing = True
+ try:
+ self._finish_camera_recordings(env_ids, save_data=False)
+ self._clear_episode_recording_state(env_ids)
+ self._elapsed_steps.zero_()
+ self._task_success.zero_()
+ self._traj_raw_action = None
+ prepare()
+ restore()
+ settle()
+ self._reset_episode_managers(env_ids)
+ validation = verify()
+ if not isinstance(validation, ValidationResult):
+ raise TypeError(
+ "Initial-state verification must return ValidationResult."
+ )
+ if not validation.accepted:
+ raise RuntimeError(
+ "Generation initial-state verification did not pass."
+ )
+ obs = self.get_obs()
+ info = self.get_info()
+ self._seed_recording_state(obs, env_ids)
+ self._generation_prepared = True
+ return obs, info
+ finally:
+ self._generation_preparing = False
+
def _seed_recording_state(self, obs: EnvObs, env_ids: torch.Tensor) -> None:
"""Seed all enabled recorders from the current environment state."""
self._seed_expert_observations(obs, env_ids)
@@ -943,22 +1023,9 @@ def _initialize_episode(
env_ids=env_ids_to_save,
)
- # Save recorded camera data before resetting
- if self.cfg.events and self.event_manager is not None:
- from embodichain.lab.gym.envs.managers.record import record_camera_data
-
- with self._profiler.section("record_camera_save"):
- for mode_cfgs in self.event_manager._mode_functor_cfgs.values():
- for functor_cfg in mode_cfgs:
- if isinstance(functor_cfg.func, record_camera_data):
- if save_data:
- functor_cfg.func.save_and_clear(
- env_ids=env_ids_to_process
- )
- else:
- functor_cfg.func.discard_and_clear(
- env_ids=env_ids_to_process
- )
+ EmbodiedEnv._finish_camera_recordings(
+ self, env_ids_to_process, save_data=save_data
+ )
# Auto-save + reset the per-env trajectory buffer for environments being
# reset. Use getattr so this no-ops on envs/subclasses that don't allocate
@@ -973,26 +1040,54 @@ def _initialize_episode(
for env_id in env_ids_to_process.tolist():
self._save_trajectory_for_env(env_id)
+ EmbodiedEnv._clear_episode_recording_state(self, env_ids_to_process)
+
+ # apply events such as randomization for environments that need a reset
+ if self.cfg.events:
+ if "reset" in self.event_manager.available_modes:
+ with self._profiler.section("event_reset"):
+ self.event_manager.apply(mode="reset", env_ids=env_ids)
+
+ EmbodiedEnv._reset_episode_managers(self, env_ids)
+
+ def _finish_camera_recordings(
+ self, env_ids: torch.Tensor, *, save_data: bool
+ ) -> None:
+ """Finish selected camera buffers without changing event streams."""
+ if self.cfg.events and self.event_manager is not None:
+ from embodichain.lab.gym.envs.managers.record import record_camera_data
+
+ with self._profiler.section("record_camera_save"):
+ for mode_cfgs in self.event_manager._mode_functor_cfgs.values():
+ for functor_cfg in mode_cfgs:
+ if isinstance(functor_cfg.func, record_camera_data):
+ if save_data:
+ functor_cfg.func.save_and_clear(env_ids=env_ids)
+ else:
+ functor_cfg.func.discard_and_clear(env_ids=env_ids)
+
+ def _clear_episode_recording_state(self, env_ids: torch.Tensor) -> None:
+ """Discard episode buffers and annotations without saving or events."""
_traj_steps = getattr(self, "_traj_steps", None)
if _traj_steps is not None:
- _traj_steps[env_ids_to_process] = 0
+ _traj_steps[env_ids] = 0
# Clear episode buffers only after every recorder has consumed them.
if self.rollout_buffer is not None and self._rollout_buffer_mode != "rl":
- self._clear_expert_rollout_rows(env_ids_to_process)
+ self._clear_expert_rollout_rows(env_ids)
rollout_steps = getattr(self, "rollout_steps", None)
if rollout_steps is not None:
- rollout_ids = env_ids_to_process.to(rollout_steps.device)
+ rollout_ids = env_ids.to(rollout_steps.device)
rollout_steps[rollout_ids] = 0
self.current_rollout_step = int(rollout_steps.max().item())
episode_metadata = getattr(self, "_demo_episode_metadata", None)
if episode_metadata is not None:
- for env_id in env_ids_to_process.cpu().tolist():
+ for env_id in env_ids.cpu().tolist():
episode_metadata[env_id] = self._new_demo_episode_metadata(env_id)
active_segment_ids = getattr(self, "_demo_active_segment_ids", None)
if active_segment_ids is not None:
- demo_ids = env_ids_to_process.to(active_segment_ids.device)
+ demo_ids = env_ids.to(active_segment_ids.device)
active_segment_ids[demo_ids] = 0
self._demo_active_mask[demo_ids] = True
self._demo_segment_participants[demo_ids] = False
@@ -1000,14 +1095,12 @@ def _initialize_episode(
self._demo_active_rollout_start_steps[demo_ids] = 0
self._demo_steps[demo_ids] = 0
- self.episode_success_status[env_ids_to_process] = False
-
- # apply events such as randomization for environments that need a reset
- if self.cfg.events:
- if "reset" in self.event_manager.available_modes:
- with self._profiler.section("event_reset"):
- self.event_manager.apply(mode="reset", env_ids=env_ids)
+ self.episode_success_status[env_ids] = False
+ def _reset_episode_managers(
+ self, env_ids: Sequence[int] | torch.Tensor | None
+ ) -> None:
+ """Reset observation history, reward, and dataset functors only."""
# reset observation manager for environments that need a reset
# This clears any cached data in observation functors (e.g., physics attributes)
if self.cfg.observations:
diff --git a/embodichain/lab/sim/atomic_actions/core.py b/embodichain/lab/sim/atomic_actions/core.py
index ef03d926d..1bec94d8f 100644
--- a/embodichain/lab/sim/atomic_actions/core.py
+++ b/embodichain/lab/sim/atomic_actions/core.py
@@ -19,7 +19,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
-from collections.abc import Mapping
+from collections.abc import Callable, Mapping
from copy import deepcopy
from dataclasses import dataclass, field, replace
from functools import cached_property
@@ -406,11 +406,22 @@ def plan(
Returns:
Scene-bound action plan with expected, uncommitted effects.
"""
+ return self._plan_with_provider(request, context, self._plan)
+
+ def _plan_with_provider(
+ self,
+ request: ResolvedActionRequest[GoalT, OptionsT],
+ context: PlanningContext,
+ plan_provider: Callable[
+ [ResolvedActionRequest[GoalT, OptionsT], PlanningContext], ActionPlan
+ ],
+ ) -> ActionPlan:
+ """Apply framework validation to an ordinary or supplied initial plan."""
self.require_goal(request)
prepared = self._prepare_request(request, context)
- plan = self._plan(prepared, context)
+ plan = plan_provider(prepared, context)
if not isinstance(plan, ActionPlan):
- raise TypeError("AtomicAction._plan() must return an ActionPlan.")
+ raise TypeError("Atomic action plan provider must return an ActionPlan.")
return replace(
plan,
commands=self._authorize_command_targets(prepared, plan.commands),
diff --git a/embodichain/lab/sim/atomic_actions/engine.py b/embodichain/lab/sim/atomic_actions/engine.py
index a6a54fb06..3107a1b48 100644
--- a/embodichain/lab/sim/atomic_actions/engine.py
+++ b/embodichain/lab/sim/atomic_actions/engine.py
@@ -19,7 +19,7 @@
from __future__ import annotations
from types import MappingProxyType
-from typing import Iterable, Mapping, TYPE_CHECKING
+from typing import Callable, Iterable, Mapping, TYPE_CHECKING
import torch
@@ -349,6 +349,10 @@ def _plan_request(
self,
request: ResolvedActionRequest,
context: PlanningContext | None = None,
+ *,
+ plan_provider: (
+ Callable[[ResolvedActionRequest, PlanningContext], ActionPlan] | None
+ ) = None,
) -> ActionPlan:
"""Plan an already-resolved request without rebuilding its snapshot.
@@ -359,6 +363,8 @@ def _plan_request(
Args:
request: Immutable request previously returned by :meth:`_resolve`.
context: Optional latest planning state; captured when omitted.
+ plan_provider: Initial-plan materializer supplied by a new session.
+ Recovery leaves this unset and invokes the registered planner.
Returns:
Validated side-effect-free action plan.
@@ -372,7 +378,11 @@ def _plan_request(
)
current = self.initial_context() if context is None else context
self._validate_context(current)
- plan = action.plan(request, current)
+ plan = (
+ action.plan(request, current)
+ if plan_provider is None
+ else action._plan_with_provider(request, current, plan_provider)
+ )
self._validate_plan(plan, current, request)
return plan
@@ -524,6 +534,9 @@ def start(
context: PlanningContext | None = None,
*,
eligible_mask: torch.Tensor | None = None,
+ initial_plan_provider: (
+ Callable[[ResolvedActionRequest, PlanningContext], ActionPlan] | None
+ ) = None,
) -> ExecutionSession:
"""Start incremental execution for a grounded invocation sequence.
@@ -534,6 +547,13 @@ def start(
eligible_mask: Optional per-environment cohort allowed to execute.
Ineligible rows remain excluded for the whole session. All rows
are eligible when omitted.
+ initial_plan_provider: Optional materializer for the first invocation's
+ initial plan. It receives a newly resolved request with current
+ collision options and the initial measured context. Its result
+ passes ordinary plan, endpoint, tracking, and phase-gate checks.
+ Later invocations and recovery use the registered skill planner.
+ Rebuild candidate bindings and effects from these inputs; do not
+ reuse a plan or execution session across environment resets.
Returns:
Stateful execution session advanced by ``session.tick(...)``.
@@ -546,6 +566,7 @@ def start(
tuple(invocations),
initial,
eligible_mask=eligible_mask,
+ initial_plan_provider=initial_plan_provider,
)
def _validate_context(self, context: PlanningContext) -> None:
diff --git a/embodichain/lab/sim/atomic_actions/execution.py b/embodichain/lab/sim/atomic_actions/execution.py
index 0af767e1a..abdd50cfb 100644
--- a/embodichain/lab/sim/atomic_actions/execution.py
+++ b/embodichain/lab/sim/atomic_actions/execution.py
@@ -21,7 +21,7 @@
from dataclasses import dataclass, replace
from enum import Enum
import math
-from typing import TYPE_CHECKING
+from typing import Callable, TYPE_CHECKING
import torch
@@ -325,9 +325,14 @@ def __init__(
context: PlanningContext,
*,
eligible_mask: torch.Tensor | None = None,
+ initial_plan_provider: (
+ Callable[[ResolvedActionRequest, PlanningContext], ActionPlan] | None
+ ) = None,
) -> None:
if not invocations:
raise ValueError("ExecutionSession requires at least one invocation.")
+ if initial_plan_provider is not None and not callable(initial_plan_provider):
+ raise TypeError("initial_plan_provider must be callable.")
engine._validate_context(context)
self._engine = engine
self._requests: tuple[ResolvedActionRequest, ...] = tuple(
@@ -388,7 +393,11 @@ def __init__(
)
self._queued_events: list[ExecutionEvent] = []
if self._status is ExecutionStatus.RUNNING:
- self._plan_current(context, ExecutionEventKind.ACTION_PLANNED)
+ self._plan_current(
+ context,
+ ExecutionEventKind.ACTION_PLANNED,
+ plan_provider=initial_plan_provider,
+ )
else:
self._queued_events.append(
self._event(
@@ -1145,10 +1154,14 @@ def _plan_current(
self,
context: PlanningContext,
event_kind: ExecutionEventKind,
+ *,
+ plan_provider: (
+ Callable[[ResolvedActionRequest, PlanningContext], ActionPlan] | None
+ ) = None,
) -> None:
"""Plan the current invocation from the latest observation."""
request = self._requests[self._invocation_index]
- plan = self._engine._plan_request(request, context)
+ plan = self._engine._plan_request(request, context, plan_provider=plan_provider)
self._install_plan(plan, context, event_kind)
def _install_plan(
diff --git a/embodichain/lab/sim/atomic_actions/primitives/pick_up.py b/embodichain/lab/sim/atomic_actions/primitives/pick_up.py
index 646d35a7b..7ac8fbdf6 100644
--- a/embodichain/lab/sim/atomic_actions/primitives/pick_up.py
+++ b/embodichain/lab/sim/atomic_actions/primitives/pick_up.py
@@ -574,8 +574,19 @@ def _resolve_grasp_pose(
)
num_envs = object_pose.shape[0]
n_max_pose = max(r[0].shape[0] for r in grasp_poses_result)
- grasp_xpos_padding = torch.zeros(
- (num_envs, n_max_pose, 4, 4), dtype=torch.float32, device=self.device
+ safe_pose = self.robot.compute_fk(
+ qpos=start_qpos,
+ name=manipulator.control_part,
+ to_matrix=True,
+ )
+ if n_max_pose == 0:
+ return (
+ torch.zeros(num_envs, dtype=torch.bool, device=self.device),
+ safe_pose,
+ )
+ grasp_xpos_padding = safe_pose[:, None].expand(-1, n_max_pose, -1, -1).clone()
+ candidate_mask = torch.zeros(
+ (num_envs, n_max_pose), dtype=torch.bool, device=self.device
)
grasp_cost_padding = torch.full(
(num_envs, n_max_pose),
@@ -585,16 +596,47 @@ def _resolve_grasp_pose(
)
for i in range(num_envs):
n_pose = grasp_poses_result[i][0].shape[0]
+ if n_pose == 0:
+ continue
grasp_poses = grasp_poses_result[i][0].to(
- device=self.device, dtype=torch.float32
+ device=self.device, dtype=grasp_xpos_padding.dtype
)
grasp_costs = grasp_poses_result[i][1].to(
device=self.device, dtype=torch.float32
)
- grasp_xpos_padding[i, :n_pose] = grasp_poses
- grasp_cost_padding[i, :n_pose] = grasp_costs
- grasp_xpos_padding[i, n_pose:] = grasp_poses[0]
- grasp_cost_padding[i, n_pose:] = grasp_costs[0]
+ valid = torch.isfinite(grasp_costs) & torch.isfinite(grasp_poses).all(
+ dim=(-2, -1)
+ )
+ # Validate finite transforms before any rotation math or batch IK.
+ # Invalid candidates remain masked even if the safe placeholder is
+ # itself reachable by the robot.
+ checked_poses = torch.where(
+ valid[:, None, None], grasp_poses, safe_pose[i]
+ ).to(dtype=torch.float64)
+ rotation = checked_poses[:, :3, :3]
+ valid &= torch.isclose(
+ checked_poses[:, 3],
+ checked_poses.new_tensor((0.0, 0.0, 0.0, 1.0)),
+ atol=1.0e-6,
+ rtol=0.0,
+ ).all(dim=-1)
+ valid &= torch.isclose(
+ rotation.transpose(-2, -1) @ rotation,
+ torch.eye(3, dtype=rotation.dtype, device=rotation.device),
+ atol=1.0e-6,
+ rtol=0.0,
+ ).all(dim=(-2, -1))
+ valid &= torch.isclose(
+ torch.linalg.det(rotation),
+ rotation.new_tensor(1.0),
+ atol=1.0e-6,
+ rtol=0.0,
+ )
+ grasp_xpos_padding[i, :n_pose] = torch.where(
+ valid[:, None, None], grasp_poses, safe_pose[i]
+ )
+ grasp_cost_padding[i, :n_pose] = torch.where(valid, grasp_costs, torch.inf)
+ candidate_mask[i, :n_pose] = valid
grasp_xpos_padding, ik_success = self._select_feasible_grasp_variants(
grasp_xpos_padding,
start_qpos,
@@ -603,13 +645,17 @@ def _resolve_grasp_pose(
options,
approach_direction,
)
- grasp_cost_masked = torch.where(ik_success, grasp_cost_padding, 10000.0)
+ grasp_cost_masked = torch.where(
+ candidate_mask & ik_success, grasp_cost_padding, torch.inf
+ )
best_cost, best_idx = grasp_cost_masked.min(dim=1)
- is_success = best_cost < 9999.0
+ is_success = torch.isfinite(best_cost)
best_grasp_xpos = grasp_xpos_padding[
torch.arange(num_envs, device=self.device), best_idx
]
- return is_success, best_grasp_xpos
+ return is_success, torch.where(
+ is_success[:, None, None], best_grasp_xpos, safe_pose
+ )
def _select_feasible_grasp_variants(
self,
@@ -767,8 +813,14 @@ def _compute_batch_candidate_ik(
name=manipulator.control_part,
joint_seed=flat_seed,
)
+ success = is_success.to(device=self.device, dtype=torch.bool)
+ success = success.reshape(num_envs, n_pose * n_variant)
+ success &= torch.isfinite(qpos).all(dim=-1)
+ # A failed solver may return NaNs or an arbitrary finite configuration.
+ # Keep its last valid seed so it cannot contaminate the next stage's IK.
+ qpos = torch.where(success[..., None], qpos, flat_seed)
return (
- is_success.to(device=self.device, dtype=torch.bool).reshape(
+ success.reshape(
num_envs,
n_pose,
n_variant,
diff --git a/embodichain/lab/sim/motion/planners/curobo/curobo_planner.py b/embodichain/lab/sim/motion/planners/curobo/curobo_planner.py
index c9869d84b..750fcf710 100644
--- a/embodichain/lab/sim/motion/planners/curobo/curobo_planner.py
+++ b/embodichain/lab/sim/motion/planners/curobo/curobo_planner.py
@@ -303,9 +303,9 @@ class CuroboAutoGenCfg:
``None`` (default) uses ``$XDG_CACHE_HOME/embodichain_curobo`` or
``~/.cache/embodichain_curobo``. The cache key hashes the generator version,
- URDF path, URDF content, control part, tool frame, and fit parameters, so
- editing the URDF, changing the fit settings, or a generator update
- regenerates automatically.
+ URDF path, URDF content, control part, tool frame, fit parameters, and initial
+ values of locked joints. Changing their static collision geometry or a
+ generator update regenerates automatically.
"""
fit_type: str = "voxel"
@@ -856,9 +856,10 @@ def validate_joint_trajectory(
The samples are not replanned or replaced. They are mapped from the
simulator's control-part order into the exact cuRobo model, then checked
against joint bounds, self-collision, and the live world collision
- checker. Calling the configuration validator once per horizon sample
- works around cuRobo 0.8's configuration-only ``validate`` contract while
- retaining batched environments.
+ checker. The cuRobo 0.8 convenience validators pass obsolete tensor
+ arguments to the scene cost and flatten trajectory dimensions. Calling
+ its bound, self, and scene costs with the actual kinematics state avoids
+ both issues while retaining batched environments.
"""
if (
not isinstance(trajectory, torch.Tensor)
@@ -934,13 +935,42 @@ def validate_joint_trajectory(
else nullcontext()
)
with device_context:
+ checker = backend.collision_checker
for sample_index in range(horizon):
sample = curobo_trajectory[:, sample_index : sample_index + 1]
- valid = backend.collision_checker.validate(
- sample,
- env_query_idx=env_query_idx,
+ state = checker.get_kinematics(sample)
+ if sample_index == 0:
+ checker.setup_batch_tensors(batch_size, 1)
+ if checker.self_collision_cost is None:
+ raise RuntimeError("cuRobo self-collision cost is unavailable.")
+ if checker.collision_constraint is not None:
+ checker.collision_constraint.update_num_spheres(
+ state.robot_spheres.shape[-2], batch_size, 1
+ )
+ elif self.cfg.world.rigid_objects:
+ raise RuntimeError(
+ "cuRobo scene-collision cost is unavailable."
+ )
+ costs = [
+ checker.get_bound(sample),
+ checker.get_self_collision(state.robot_spheres),
+ ]
+ if checker.collision_constraint is not None:
+ costs.append(
+ checker.collision_constraint.forward(
+ state, idxs_env_query=env_query_idx
+ )
+ )
+ valid = torch.ones(
+ batch_size, device=self._curobo_device, dtype=torch.bool
)
- samples.append(valid[:, 0].to(torch.bool))
+ for cost in costs:
+ valid &= (
+ (torch.isfinite(cost) & (cost == 0))
+ .reshape(batch_size, -1)
+ .all(dim=1)
+ )
+ samples.append(valid)
return torch.stack(samples, dim=1).to(trajectory.device)
def __init__(self, cfg: CuroboPlannerCfg) -> None:
@@ -1268,11 +1298,18 @@ def _get_backend(
batch_size: int,
planning_mode: MoveType = MoveType.EEF_MOVE,
) -> "_CuroboBackend":
- """Return a cached in-process backend for one goal-buffer shape."""
+ """Reuse a backend only while its locked-joint model remains unchanged."""
multi_env = bool(self.cfg.world.multi_env)
key = (control_part, int(batch_size), multi_env, planning_mode)
+ lock_signature = self._locked_joint_signature(control_part)
if key in self._backend_cache:
- return self._backend_cache[key]
+ cached = self._backend_cache[key]
+ if cached.robot_lock_signature != lock_signature:
+ raise RuntimeError(
+ "cuRobo locked-joint configuration changed after backend "
+ "creation. Call planner.close() before rebuilding its model."
+ )
+ return cached
profile = self._materialize_profile(control_part)
sim_joint_names = self._resolve_sim_joint_names(control_part)
@@ -1366,6 +1403,7 @@ def _get_backend(
f"cuRobo capture coordinator release failed: {exc}"
)
+ backend.robot_lock_signature = lock_signature
self._backend_cache[key] = backend
logger.log_info(
f"cuRobo in-process backend ready for '{control_part}' "
@@ -1741,7 +1779,7 @@ def _robot_yaml_cache_key(
tool_frame: str | None,
auto: CuroboAutoGenCfg,
) -> str:
- """Hash the URDF path/content and fit parameters into a stable cache key."""
+ """Hash geometry, fit settings, and locked joint values into a cache key."""
hasher = hashlib.md5()
hasher.update(_CUROBO_ROBOT_YAML_GENERATOR_VERSION.encode("utf-8"))
hasher.update(urdf_path.encode("utf-8"))
@@ -1758,8 +1796,30 @@ def _robot_yaml_cache_key(
hasher.update(str(auto.surface_radius).encode("utf-8"))
hasher.update(str(auto.iterations).encode("utf-8"))
hasher.update(str(auto.collision_sphere_buffer).encode("utf-8"))
+ hasher.update(self._locked_joint_signature(control_part).encode("utf-8"))
return hasher.hexdigest()
+ def _locked_joint_signature(self, control_part: str) -> str:
+ """Identify the static joint values shared by disk and runtime models."""
+ # Match generate_curobo_robot_yaml's initial-state resolution. Joints
+ # outside the control part become static collision geometry, so reusing
+ # a YAML with different gripper/other-arm values is unsafe.
+ joint_names = tuple(self.robot.joint_names)
+ initial = self.robot.cfg.init_qpos
+ initial = [] if initial is None else list(initial)
+ if len(initial) != len(joint_names):
+ try:
+ initial = self.robot.get_qpos()[0].detach().cpu().tolist()
+ except Exception:
+ initial = [0.0] * len(joint_names)
+ controlled = tuple((self.robot.control_parts or {}).get(control_part, ()))
+ locked = tuple(
+ (name, float(value))
+ for name, value in zip(joint_names, initial)
+ if name not in controlled
+ )
+ return repr((controlled, locked))
+
def _auto_generate_world_yaml(self, world_cfg: CuroboWorldCfg) -> str:
"""Return a cached cuRobo world YAML path generated from ``rigid_objects``.
@@ -2528,6 +2588,7 @@ class _CuroboBackend:
use_cuda_graph: bool
planning_mode: MoveType
collision_checker: "Any | None" = None
+ robot_lock_signature: str | None = None
# Lazily-built device-tensor caches for the shared post-processing. The
# cuRobo joint order and the profile's fixed transforms are stable for a
# planner's life, so these are built once on first use and reused across
diff --git a/embodichain/lab/sim/objects/articulation.py b/embodichain/lab/sim/objects/articulation.py
index 88f70be7c..a29380787 100644
--- a/embodichain/lab/sim/objects/articulation.py
+++ b/embodichain/lab/sim/objects/articulation.py
@@ -868,7 +868,7 @@ def root_link_name(self) -> str:
Returns:
str: The name of the root link.
"""
- return self.entities[0].get_root_link_name()
+ return self._entities[0].get_root_link_name()
@cached_property
def joint_names(self) -> List[str]:
diff --git a/embodichain/lab/sim/sim_manager.py b/embodichain/lab/sim/sim_manager.py
index 1bc8fdd9d..5f405753d 100644
--- a/embodichain/lab/sim/sim_manager.py
+++ b/embodichain/lab/sim/sim_manager.py
@@ -832,6 +832,16 @@ def render_camera_group(self, group_ids: list[int]) -> None:
self._world.render_camera_group(group_ids)
+ @property
+ def simulation_time(self) -> float:
+ """Seconds successfully advanced through :meth:`update` since construction.
+
+ Manual-step hosts can use differences of this clock for observation
+ timestamps. Direct backend updates performed outside this manager,
+ including some object preparation setters, are not included.
+ """
+ return self._visualization_sim_time
+
def update(self, physics_dt: float | None = None, step: int = 10) -> None:
"""Advance physics explicitly and publish the resulting simulation state.
@@ -3280,7 +3290,14 @@ def reset_objects_state(
Args:
env_ids (Sequence[int] | None): The environment IDs to reset. If None, reset all environments.
excluded_uids (Sequence[str] | None): List of asset UIDs to exclude from resetting. If None, reset all assets.
+
+ Raises:
+ RuntimeError: If a fixed-scene generation host owns this batch.
"""
+ if getattr(self, "_trajectory_generation_owner", None) is not None:
+ raise RuntimeError(
+ "Normal scene reset is disabled while a generation host owns the batch."
+ )
excluded_uids = set(excluded_uids) if excluded_uids is not None else set()
for uid, robot in self._robots.items():
if uid not in excluded_uids:
diff --git a/embodichain/lab/trajectory_generation/__init__.py b/embodichain/lab/trajectory_generation/__init__.py
new file mode 100644
index 000000000..3818cfd8e
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/__init__.py
@@ -0,0 +1,19 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+__all__ = []
diff --git a/embodichain/lab/trajectory_generation/execution.py b/embodichain/lab/trajectory_generation/execution.py
new file mode 100644
index 000000000..2632571d4
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/execution.py
@@ -0,0 +1,891 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Full-batch qpos rollouts with actual controller labels and causal evidence."""
+
+from __future__ import annotations
+
+import math
+import json
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import dataclass, field, fields, replace
+from types import MappingProxyType
+from typing import Any
+
+import torch
+
+from embodichain.lab.sim.motion.expansion import (
+ CandidateIdentity,
+ CandidateTrajectoryBatch,
+ ExpertEpisode,
+ ValidationCheck,
+ ValidationResult,
+)
+from .initial_state import FixedSceneHost, PreparedBatch
+from .integrations.contact import PickUpMotionValidator
+
+__all__ = ["QposRolloutExecutor"]
+
+_METADATA_RESERVE = 64 * 1024
+_EXECUTOR_METADATA_LIMIT = _METADATA_RESERVE // 2
+
+
+def _failure_text(error: Exception) -> str:
+ """Retain the concrete cause hidden by an execution-loop wrapper."""
+ seen = {id(error)}
+ while error.__cause__ is not None and id(error.__cause__) not in seen:
+ error = error.__cause__
+ seen.add(id(error))
+ return f"{type(error).__name__}: {error}"[:512]
+
+
+def _metadata_bytes(value: object) -> int:
+ def encode(item: object) -> dict:
+ if isinstance(item, Mapping):
+ return dict(item)
+ return {entry.name: getattr(item, entry.name) for entry in fields(item)}
+
+ size = 0
+ for chunk in json.JSONEncoder(default=encode, ensure_ascii=False).iterencode(value):
+ size += len(chunk.encode())
+ if size > _EXECUTOR_METADATA_LIMIT:
+ break
+ return size
+
+
+@dataclass
+class _Row:
+ candidate: CandidateTrajectoryBatch
+ steps: int
+ observations: list[dict[str, torch.Tensor]] = field(default_factory=list)
+ actions: list[torch.Tensor] = field(default_factory=list)
+ timestamps: list[float] = field(default_factory=lambda: [0.0])
+ started: bool = False
+ failure: str | None = None
+ fixed_world: bool = True
+
+
+def _flatten_observation(value: Mapping, prefix: str = "") -> dict[str, torch.Tensor]:
+ """Preserve every nested tensor when no explicit Gym encoder is supplied."""
+ result = {}
+ for key, item in value.items():
+ if not isinstance(key, str) or not key or "." in key:
+ raise ValueError(
+ "Default Gym observation keys must be nonempty and contain no dots."
+ )
+ name = f"{prefix}.{key}" if prefix else str(key)
+ if isinstance(item, torch.Tensor):
+ result[name] = item
+ elif isinstance(item, Mapping):
+ result.update(_flatten_observation(item, name))
+ else:
+ raise ValueError(
+ f"Observation field {name!r} requires an explicit encoder."
+ )
+ return result
+
+
+class QposRolloutExecutor:
+ """Execute one candidate per physical row through Gym or a pure simulator.
+
+ Each candidate contains one initial sample followed by controller targets;
+ ``valid_length - 1`` commands are executed on the fixed control clock.
+ Missing, finished, and terminated rows receive safe holds while remaining
+ rows advance in a single batched physics update. Holds and padding never
+ enter returned episodes. Gym execution uses the normal demonstration loop
+ and ``ControllerAction``; policy preprocessing is skipped once and normal
+ postprocessing remains enabled. Actual targets are captured before physics.
+
+ The executor supports changes only to the host's active joints. Inactive
+ or mimic coordinates must remain constant unless a shared PickUp validator
+ models their complete geometry and physical motion. Task
+ validation runs at the final batch boundary; early trajectory interruption,
+ target disagreement, and observation/physics errors always reject evidence.
+ A submitted command without a complete observation transition still calls
+ ``on_started``, but cannot produce a fabricated training transition.
+
+ Args:
+ host: Exclusive fixed-scene host; prepare it before calling execute.
+ control_dt: Control period, an integer multiple of profile physics_dt.
+ observe: Required pure-simulator observation callback, returning full
+ batch tensors. It is never used for Gym execution.
+ encode_observation: Optional Gym observation encoder. The default
+ flattens every tensor field. Uses prepared/step observations only.
+ validator: Trusted final task validator for one candidate and its owned
+ ``observations, actions, timestamps``. Must include ``validator_id``.
+ validator_id: Required task validation check identifier.
+ validation_profile_id: Registered execution validation profile identity.
+ max_episode_bytes: Maximum tensor plus metadata payload per row. Before
+ commands, reserve 64 KiB for bounded metadata in addition to tensors;
+ half remains available for downstream validation and storage schema.
+ contact_validator: Optional PickUp validator sharing this simulator and
+ robot. Adds physical-substep contact gates and measured object/TCP
+ observations; permits its target and observed mimic joints to move.
+ Supported only with pure simulation and the matching Runner planner.
+ """
+
+ def __init__(
+ self,
+ host: FixedSceneHost,
+ *,
+ control_dt: float,
+ observe: Callable[[], Mapping[str, torch.Tensor]] | None = None,
+ encode_observation: Callable[[Any], Mapping[str, torch.Tensor]] | None = None,
+ validator: Callable[
+ [
+ CandidateTrajectoryBatch,
+ Mapping[str, torch.Tensor],
+ torch.Tensor,
+ torch.Tensor,
+ ],
+ ValidationResult,
+ ],
+ validator_id: str = "task_success",
+ validation_profile_id: str = "verified_motion",
+ max_episode_bytes: int = 256 * 1024 * 1024,
+ contact_validator: PickUpMotionValidator | None = None,
+ ) -> None:
+ if (
+ isinstance(control_dt, bool)
+ or not isinstance(control_dt, (int, float))
+ or not math.isfinite(control_dt)
+ or control_dt <= 0
+ ):
+ raise ValueError("control_dt must be finite and positive.")
+ if not callable(validator) or (host.env is None and not callable(observe)):
+ raise ValueError(
+ "Provide a task validator and a pure-sim observation callback when needed."
+ )
+ if encode_observation is not None and not callable(encode_observation):
+ raise TypeError("encode_observation must be callable.")
+ if type(max_episode_bytes) is not int or max_episode_bytes <= 0:
+ raise ValueError("max_episode_bytes must be a positive integer.")
+ for name, value in (
+ ("validator_id", validator_id),
+ ("validation_profile_id", validation_profile_id),
+ ):
+ if not isinstance(value, str) or not value or len(value.encode()) > 1024:
+ raise ValueError(f"{name} must be nonempty text of at most 1024 bytes.")
+ if validator_id in {"execution_complete", "fixed_collision_world"}:
+ raise ValueError("validator_id cannot replace an execution check.")
+ ratio = control_dt / host.profile.physics_dt
+ if round(ratio) < 1 or not math.isclose(
+ ratio, round(ratio), rel_tol=0, abs_tol=1e-8
+ ):
+ raise ValueError(
+ "control_dt must contain an integer number of physics steps."
+ )
+ if host.env is not None and not math.isclose(
+ control_dt, host.env.step_dt, rel_tol=0, abs_tol=1e-8
+ ):
+ raise ValueError("control_dt must match the Gym control clock.")
+ self.host = host
+ self.control_dt = float(control_dt)
+ self.observe = observe
+ self.encode_observation = encode_observation or _flatten_observation
+ self.validator = validator
+ self.validator_id = validator_id
+ self.validation_profile_id = validation_profile_id
+ self.max_episode_bytes = max_episode_bytes
+ if contact_validator is not None and (
+ not isinstance(contact_validator, PickUpMotionValidator)
+ or host.env is not None
+ or contact_validator.robot is not host.adapter.robot
+ or contact_validator.sim is not host.adapter.sim
+ ):
+ raise ValueError(
+ "Contact validation requires the same pure-sim host robot."
+ )
+ self.contact_validator = contact_validator
+ self._substeps = round(ratio)
+ self._running = False
+ self._last_failures: Mapping[str, str] = MappingProxyType({})
+
+ @property
+ def last_failures(self) -> Mapping[str, str]:
+ """Read failure reasons from the last batch, including zero-frame rows.
+
+ Cleared when a new serialized ``execute`` call begins. The owned,
+ immutable mapping contains at most one entry per candidate, each
+ limited to 512 characters. Failed physics/observation transitions retain
+ their concrete exception cause even when no episode can be returned.
+ """
+ return self._last_failures
+
+ def _observations(
+ self, raw: Any = None, schema: dict | None = None
+ ) -> dict[str, torch.Tensor]:
+ values = (
+ self.observe() if self.host.env is None else self.encode_observation(raw)
+ )
+ if not isinstance(values, Mapping) or not values:
+ raise ValueError(
+ "Observation providers must return a nonempty tensor mapping."
+ )
+ values = dict(values)
+ if self.contact_validator is not None:
+ physical = self.contact_validator.observations()
+ if any(
+ key in values and not torch.equal(values[key], value)
+ for key, value in physical.items()
+ ):
+ raise ValueError(
+ "Contact observations must contain actual simulator poses."
+ )
+ values.update(physical)
+ joints = self.host.adapter.robot.get_qpos()
+ if "joint_positions" in values and not torch.equal(
+ values["joint_positions"], joints
+ ):
+ raise ValueError(
+ "joint_positions must contain actual full-joint measurements."
+ )
+ values["joint_positions"] = joints
+ batch = self.host.adapter.sim.num_envs
+ if joints.shape != (batch, len(self.host.adapter.robot.joint_names)):
+ raise ValueError(
+ "Measured joint positions do not match the full joint layout."
+ )
+ for key, value in values.items():
+ if (
+ not isinstance(key, str)
+ or not key
+ or not isinstance(value, torch.Tensor)
+ or value.ndim < 1
+ or value.shape[0] != batch
+ or value.is_complex()
+ ):
+ raise ValueError(f"Invalid full-batch observation field {key!r}.")
+ if (
+ sum(value.numel() * value.element_size() for value in values.values())
+ > batch * self.max_episode_bytes
+ ):
+ raise ValueError(
+ "Observation batch exceeds the payload budget before ownership copy."
+ )
+ if (
+ schema is not None
+ and {key: (value.shape, value.dtype) for key, value in values.items()}
+ != schema
+ ):
+ raise ValueError("Observation schema changed during the rollout.")
+ if not all(bool(torch.isfinite(value).all()) for value in values.values()):
+ raise ValueError("Observations must contain finite real values.")
+ return {key: value.detach().cpu().clone() for key, value in values.items()}
+
+ def _active_joints(self) -> tuple[int, ...]:
+ source = self.host.env if self.host.env is not None else self.host.adapter.robot
+ indices = tuple(int(value) for value in source.active_joint_ids)
+ dof = len(self.host.adapter.robot.joint_names)
+ if (
+ not indices
+ or len(set(indices)) != len(indices)
+ or any(index < 0 or index >= dof for index in indices)
+ or not set(indices).issubset(self.host.adapter.robot.active_joint_ids)
+ ):
+ raise ValueError("The host must declare unique active joint indices.")
+ return indices
+
+ def _preflight(
+ self,
+ binding: PreparedBatch,
+ candidates: Sequence[CandidateTrajectoryBatch | None],
+ episode_ids: Mapping[str, tuple[str, str]],
+ initial: dict[str, torch.Tensor],
+ joints: tuple[int, ...],
+ ) -> list[_Row | None]:
+ batch = self.host.adapter.sim.num_envs
+ if len(candidates) != batch:
+ raise ValueError(
+ "Candidates must cover every physical row, using None for idle rows."
+ )
+ if not self.host.verify_initial(binding).accepted:
+ raise ValueError(
+ "The physical batch no longer matches its prepared initial state."
+ )
+ robot = self.host.adapter.robot
+ inactive = [
+ index
+ for index in range(len(robot.joint_names))
+ if index not in joints
+ and (
+ self.contact_validator is None
+ or index not in self.contact_validator.mimic_ids
+ )
+ ]
+ limits = robot.get_qpos_limits().detach().cpu()
+ rows = []
+ seen = set()
+ for index, candidate in enumerate(candidates):
+ if candidate is None:
+ rows.append(None)
+ continue
+ if (
+ not isinstance(candidate, CandidateTrajectoryBatch)
+ or len(candidate.identities) != 1
+ ):
+ raise ValueError("Each physical row requires a single owned candidate.")
+ candidate = candidate.row(0)
+ identity = candidate.identities[0]
+ if identity.candidate_id in seen:
+ raise ValueError("A candidate cannot execute in two rows at once.")
+ seen.add(identity.candidate_id)
+ if (
+ identity.candidate_id not in episode_ids
+ or len(episode_ids[identity.candidate_id]) != 2
+ or any(
+ not isinstance(value, str) or not value
+ for value in episode_ids[identity.candidate_id]
+ )
+ ):
+ raise ValueError(
+ "Every candidate requires stable episode and commit IDs."
+ )
+ case = binding.cases[index]
+ if (
+ identity.scene_case_id != case.scene_case_id
+ or identity.initial_state_id != case.initial_state_id
+ or candidate.joint_names != tuple(robot.joint_names)
+ ):
+ raise ValueError(
+ "Candidate case, initial state, or joint order does not match its slot."
+ )
+ length = int(candidate.valid_length[0])
+ if length < 2:
+ raise ValueError(
+ "A rollout requires an initial sample and at least one command."
+ )
+ positions = candidate.positions[0, :length].detach().cpu()
+ intervals = candidate.dt[0, 1:length].detach().cpu().to(torch.float64)
+ if not torch.allclose(
+ intervals,
+ torch.full_like(intervals, self.control_dt),
+ atol=1e-8,
+ rtol=0,
+ ):
+ raise ValueError(
+ "Candidate timing must use the executor's control clock."
+ )
+ if not torch.allclose(
+ positions[0],
+ initial["joint_positions"][index].to(positions.dtype),
+ atol=self.host.adapter.atol,
+ rtol=0,
+ ):
+ raise ValueError(
+ "Candidate first sample does not match the actual initial joints."
+ )
+ if inactive and not torch.equal(
+ positions[:, inactive], positions[0, inactive].expand(length, -1)
+ ):
+ raise ValueError(
+ "Candidates cannot change inactive or mimic joint coordinates."
+ )
+ if bool(
+ (
+ (positions < limits[index, :, 0])
+ | (positions > limits[index, :, 1])
+ ).any()
+ ):
+ raise ValueError(
+ "Candidate joint targets exceed actual controller limits."
+ )
+ bytes_per_obs = sum(
+ value[index].numel() * value.element_size()
+ for value in initial.values()
+ )
+ size = (
+ length * (bytes_per_obs + 8)
+ + (length - 1)
+ * initial["joint_positions"][index].numel()
+ * initial["joint_positions"].element_size()
+ + _METADATA_RESERVE
+ )
+ if size > self.max_episode_bytes:
+ raise ValueError(
+ "Rollout would exceed max_episode_bytes before its first command."
+ )
+ if (
+ _metadata_bytes(
+ (
+ identity,
+ episode_ids[identity.candidate_id],
+ candidate.phases,
+ tuple(initial),
+ )
+ )
+ > _EXECUTOR_METADATA_LIMIT
+ ):
+ raise ValueError(
+ "Candidate lineage or observation keys exceed the metadata budget."
+ )
+ rows.append(
+ _Row(
+ candidate,
+ length - 1,
+ [{key: value[index].clone() for key, value in initial.items()}],
+ )
+ )
+ return rows
+
+ def _hold(self, joints: tuple[int, ...]) -> None:
+ robot = self.host.adapter.robot
+ measured = robot.get_qpos()[:, joints]
+ if not bool(torch.isfinite(measured).all()):
+ raise RuntimeError("Cannot safely hold nonfinite measured joint positions.")
+ robot.set_qpos(measured, joint_ids=joints, target=True)
+ robot.set_qvel(torch.zeros_like(measured), joint_ids=joints, target=True)
+ robot.set_qf(torch.zeros_like(measured), joint_ids=joints)
+
+ def _clear_hold_dynamics(
+ self, joints: tuple[int, ...], active: tuple[bool, ...]
+ ) -> None:
+ if all(active):
+ return
+ robot = self.host.adapter.robot
+ velocity = robot.get_qvel(target=True)[:, joints].clone()
+ effort = robot.get_qf()[:, joints].clone()
+ inactive = torch.tensor([not value for value in active], device=velocity.device)
+ velocity[inactive] = 0
+ effort[inactive] = 0
+ robot.set_qvel(velocity, joint_ids=joints, target=True)
+ robot.set_qf(effort, joint_ids=joints)
+
+ def execute(
+ self,
+ binding: PreparedBatch,
+ candidates: Sequence[CandidateTrajectoryBatch | None],
+ episode_ids: Mapping[str, tuple[str, str]],
+ *,
+ on_started: Callable[[CandidateIdentity], None],
+ should_stop: Callable[[], bool] | None = None,
+ ) -> tuple[ExpertEpisode | None, ...]:
+ """Execute the full batch and freeze each row at its final observed step.
+
+ Invalid inputs raise before commands. Runtime failures yield rejected
+ episodes for rows with complete transitions and ``None`` otherwise.
+ ``on_started`` fires after the first submitted command, even when its
+ subsequent physics or observation fails. Inability to install the final
+ safe hold raises and must stop the caller's collection job.
+ """
+ if self._running:
+ raise RuntimeError("QposRolloutExecutor requires serialized execution.")
+ self._last_failures = MappingProxyType({})
+ if not callable(on_started) or (
+ should_stop is not None and not callable(should_stop)
+ ):
+ raise TypeError("Execution callbacks must be callable.")
+ self.host.assert_current(binding)
+ initial = self._observations(self.host.initial_observation(binding))
+ joints = self._active_joints()
+ rows = self._preflight(binding, candidates, episode_ids, initial, joints)
+ if not any(row is not None for row in rows):
+ return tuple(None for _ in rows)
+ schema = {key: (value.shape, value.dtype) for key, value in initial.items()}
+ robot, sim, env = self.host.adapter.robot, self.host.adapter.sim, self.host.env
+ snapshots = self.host.snapshots(binding)
+ if self.contact_validator is not None:
+ self.contact_validator.begin_rollout(candidates, snapshots)
+ initial_time = float(sim.simulation_time)
+ if not math.isfinite(initial_time):
+ raise ValueError("The simulator clock must be finite.")
+ last_time = initial_time
+ pending: torch.Tensor | None = None
+ current_mask = tuple(False for _ in rows)
+ expected_command: torch.Tensor | None = None
+ self._running = True
+ stop_requested = False
+
+ def stop() -> bool:
+ nonlocal stop_requested
+ requested = should_stop is not None and bool(should_stop())
+ stop_requested |= requested
+ return requested
+
+ def command_submitted() -> None:
+ nonlocal pending, current_mask
+ if env is not None:
+ current_mask = tuple(bool(value) for value in env._demo_active_mask)
+ callback_error = None
+ for index, row in enumerate(rows):
+ if row is not None and current_mask[index] and not row.started:
+ row.started = True
+ try:
+ on_started(row.candidate.identities[0])
+ except Exception as error:
+ callback_error = error
+ pending = robot.get_qpos(target=True).detach().cpu().clone()
+ if (
+ pending.dtype != initial["joint_positions"].dtype
+ or pending.shape != initial["joint_positions"].shape
+ or not bool(torch.isfinite(pending).all())
+ ):
+ raise ValueError(
+ "Actual controller targets have invalid shape or values."
+ )
+ for index, row in enumerate(rows):
+ if (
+ row is not None
+ and current_mask[index]
+ and not torch.allclose(
+ pending[index, joints],
+ expected_command[index, joints].cpu().to(pending.dtype),
+ atol=self.host.adapter.atol,
+ rtol=0,
+ )
+ ):
+ row.failure = "actual controller targets differ from the candidate"
+ if callback_error is not None:
+ raise callback_error
+
+ def transition(raw: Any = None, mask: tuple[bool, ...] | None = None) -> None:
+ nonlocal pending, last_time
+ self.host.assert_current(binding)
+ observed = self._observations(raw, schema)
+ now = float(sim.simulation_time)
+ if not math.isfinite(now) or not math.isclose(
+ now - last_time, self.control_dt, rel_tol=0, abs_tol=1e-8
+ ):
+ raise ValueError(
+ "Actual simulator time does not match the control clock."
+ )
+ last_time = now
+ if pending is None:
+ raise RuntimeError(
+ "A transition has no observed controller submission."
+ )
+ active = current_mask if mask is None else mask
+ root_pose = robot.get_local_pose(to_matrix=True).detach().cpu()
+ entity_poses = {
+ uid: sim.get_rigid_object(uid)
+ .get_local_pose(to_matrix=True)
+ .detach()
+ .cpu()
+ for uid in sim.get_rigid_object_uid_list()
+ }
+ for index, row in enumerate(rows):
+ if row is not None:
+ snapshot = snapshots[index]
+ row.fixed_world &= (
+ set(entity_poses) == set(snapshot.entity_poses)
+ and torch.allclose(
+ root_pose[index],
+ snapshot.root_pose.cpu(),
+ atol=self.host.adapter.atol,
+ rtol=0,
+ )
+ and all(
+ torch.allclose(
+ pose[index],
+ snapshot.entity_poses[uid].cpu(),
+ atol=self.host.adapter.atol,
+ rtol=0,
+ )
+ for uid, pose in entity_poses.items()
+ if self.contact_validator is None
+ or uid not in self.contact_validator.dynamic_entity_ids
+ )
+ )
+ if row is not None and active[index] and len(row.actions) < row.steps:
+ inactive = [
+ joint
+ for joint in range(len(robot.joint_names))
+ if joint not in joints
+ and (
+ self.contact_validator is None
+ or joint not in self.contact_validator.mimic_ids
+ )
+ ]
+ if inactive and (
+ not torch.allclose(
+ observed["joint_positions"][index, inactive],
+ initial["joint_positions"][index, inactive],
+ atol=self.host.adapter.atol,
+ rtol=0,
+ )
+ or not torch.allclose(
+ pending[index, inactive],
+ initial["joint_positions"][index, inactive].to(pending),
+ atol=self.host.adapter.atol,
+ rtol=0,
+ )
+ ):
+ row.failure = (
+ "inactive or mimic joints changed during execution"
+ )
+ row.actions.append(pending[index].clone())
+ row.timestamps.append(now - initial_time)
+ row.observations.append(
+ {key: value[index].clone() for key, value in observed.items()}
+ )
+ pending = None
+
+ def actions():
+ nonlocal expected_command, current_mask
+ for index in range(max(row.steps for row in rows if row is not None)):
+ self.host.assert_current(binding)
+ expected_command = robot.get_qpos().detach().clone()
+ current_mask = tuple(
+ row is not None and index < row.steps for row in rows
+ )
+ hold_mask = (
+ current_mask
+ if env is None
+ else tuple(
+ active and bool(env._demo_active_mask[slot])
+ for slot, active in enumerate(current_mask)
+ )
+ )
+ self._clear_hold_dynamics(joints, hold_mask)
+ for slot, row in enumerate(rows):
+ if row is not None and current_mask[slot]:
+ expected_command[slot] = row.candidate.positions[
+ 0, index + 1
+ ].to(expected_command)
+ yield expected_command.clone()
+
+ try:
+ if env is not None:
+ from embodichain.lab.gym.envs.demo import (
+ DemoSegment,
+ execute_demo_episode,
+ )
+ from embodichain.lab.gym.envs.types import ControllerAction
+
+ def observe_step(result, mask) -> None:
+ transition(result[0], mask)
+ terminated, truncated, info = result[2], result[3], result[4]
+ for index, row in enumerate(rows):
+ if row is None or not mask[index]:
+ continue
+ if bool(truncated[index]) or bool(
+ info.get("fail", torch.zeros(len(rows), dtype=torch.bool))[
+ index
+ ]
+ ):
+ row.failure = "environment failed or truncated"
+ elif bool(terminated[index]) and len(row.actions) < row.steps:
+ row.failure = (
+ "environment terminated before the trajectory ended"
+ )
+
+ with env.observe_generation_commands(self.host, command_submitted):
+ execute_demo_episode(
+ env,
+ segments=DemoSegment(
+ actions=(ControllerAction(value) for value in actions()),
+ name="qpos_candidate",
+ failure_policy="row_independent",
+ ),
+ row_step_limits=tuple(
+ row.steps if row is not None else 0 for row in rows
+ ),
+ step_observer=observe_step,
+ should_stop=stop,
+ )
+ else:
+ for command in actions():
+ if stop():
+ break
+ robot.set_qpos(command[:, joints], joint_ids=joints, target=True)
+ command_submitted()
+ if self.contact_validator is None:
+ sim.update(self.host.profile.physics_dt, self._substeps)
+ else:
+ sample_index = (
+ max(len(row.actions) for row in rows if row is not None) + 1
+ )
+ for _ in range(self._substeps):
+ sim.update(self.host.profile.physics_dt, 1)
+ self.contact_validator.observe_substep(
+ sample_index,
+ current_mask,
+ physics_dt=self.host.profile.physics_dt,
+ )
+ transition()
+ except Exception as error:
+ for row in rows:
+ if row is not None:
+ row.failure = _failure_text(error)
+ finally:
+ try:
+ self._hold(joints)
+ except Exception as error:
+ self._last_failures = MappingProxyType(
+ {
+ row.candidate.identities[
+ 0
+ ].candidate_id: f"Safe hold failed: {_failure_text(error)}"[
+ :512
+ ]
+ for row in rows
+ if row is not None
+ }
+ )
+ self._running = False
+ raise
+
+ for row in rows:
+ if row is not None and row.failure is None and len(row.actions) < row.steps:
+ row.failure = (
+ "execution cancelled by should_stop"
+ if stop_requested
+ else "trajectory interrupted before completion"
+ )
+ self._last_failures = MappingProxyType(
+ {
+ row.candidate.identities[0].candidate_id: row.failure[:512]
+ for row in rows
+ if row is not None and row.failure is not None
+ }
+ )
+ try:
+ episodes = self._freeze(rows, episode_ids)
+ failures = dict(self._last_failures)
+ for episode in episodes:
+ if episode is not None and not episode.validation.accepted:
+ failure = next(
+ check
+ for check in episode.validation.checks
+ if check.status != "passed"
+ )
+ failures[episode.identity.candidate_id] = (
+ failure.detail or failure.check_id
+ )[:512]
+ self._last_failures = MappingProxyType(failures)
+ return episodes
+ finally:
+ self._running = False
+
+ def _freeze(
+ self,
+ rows: list[_Row | None],
+ episode_ids: Mapping[str, tuple[str, str]],
+ ) -> tuple[ExpertEpisode | None, ...]:
+ episodes = []
+ for row_index, row in enumerate(rows):
+ if row is None or not row.actions:
+ episodes.append(None)
+ continue
+ observations = {
+ key: torch.stack([sample[key] for sample in row.observations])
+ for key in row.observations[0]
+ }
+ commands = torch.stack(row.actions)
+ timestamps = torch.tensor(row.timestamps, dtype=torch.float64)
+ complete = len(commands) == row.steps and row.failure is None
+ checks = [
+ ValidationCheck(
+ "execution_complete",
+ "passed" if complete else "failed",
+ row.failure
+ or ("" if complete else "trajectory interrupted before completion"),
+ ),
+ ValidationCheck(
+ "fixed_collision_world",
+ "passed" if row.fixed_world else "failed",
+ (
+ ""
+ if row.fixed_world
+ else "Robot root or obstacle poses changed during execution."
+ ),
+ ),
+ ]
+ if self.contact_validator is not None:
+ checks.extend(
+ self.contact_validator.rollout_validation(row_index).checks
+ )
+ try:
+ validation = self.validator(
+ row.candidate,
+ {key: value.clone() for key, value in observations.items()},
+ commands.clone(),
+ timestamps.clone(),
+ )
+ if not isinstance(validation, ValidationResult):
+ raise TypeError("Task validator must return ValidationResult.")
+ if not any(
+ check.check_id == self.validator_id for check in validation.checks
+ ):
+ raise ValueError(
+ f"Task validator omitted required check {self.validator_id!r}."
+ )
+ if any(
+ check.check_id in {"execution_complete", "fixed_collision_world"}
+ for check in validation.checks
+ ):
+ raise ValueError("Task validator cannot replace execution checks.")
+ checks.extend(validation.checks)
+ except Exception as error:
+ checks.append(
+ ValidationCheck(self.validator_id, "failed", str(error)[:512])
+ )
+ episode_id, commit_id = episode_ids[
+ row.candidate.identities[0].candidate_id
+ ]
+ phases = tuple(
+ replace(phase, stop_index=min(phase.stop_index, len(commands) + 1))
+ for phase in row.candidate.phases[0]
+ if phase.start_index < len(commands) + 1
+ )
+ metadata = {
+ "validation_profile_id": self.validation_profile_id,
+ "timing_source": "simulation_time",
+ "phase_annotations": "planned_sample_intervals",
+ "joint_names": row.candidate.joint_names,
+ "commanded_joint_indices": self._active_joints(),
+ }
+ if self.contact_validator is not None:
+ metadata["contact_profile"] = self.contact_validator.profile.to_dict()
+ metadata["collision_geometry"] = (
+ "URDF collision convex hulls; cuboid world; implicit ground"
+ )
+ if (
+ _metadata_bytes(
+ (
+ row.candidate.identities[0],
+ episode_id,
+ commit_id,
+ "qpos",
+ tuple(observations),
+ ValidationResult(tuple(checks)),
+ phases,
+ metadata,
+ )
+ )
+ > _EXECUTOR_METADATA_LIMIT
+ ):
+ checks = [
+ ValidationCheck(
+ "execution_complete",
+ "failed",
+ "Execution validation metadata exceeds its reserved budget.",
+ )
+ ]
+ episode = ExpertEpisode(
+ identity=row.candidate.identities[0],
+ observations=observations,
+ actions=commands,
+ timestamps=timestamps,
+ action_representation="qpos",
+ validation=ValidationResult(tuple(checks)),
+ episode_id=episode_id,
+ commit_id=commit_id,
+ phases=phases,
+ metadata=metadata,
+ )
+ episodes.append(episode)
+ return tuple(episodes)
diff --git a/embodichain/lab/trajectory_generation/initial_state.py b/embodichain/lab/trajectory_generation/initial_state.py
new file mode 100644
index 000000000..d1659d219
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/initial_state.py
@@ -0,0 +1,398 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Exclusive full-batch preparation for fixed-scene trajectory collection."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Sequence
+from dataclasses import MISSING, dataclass
+import math
+from typing import TYPE_CHECKING
+from uuid import uuid4
+
+from embodichain.lab.sim.motion.expansion import (
+ MotionSnapshot,
+ SceneCase,
+ ValidationCheck,
+ ValidationResult,
+)
+from embodichain.utils import configclass
+
+if TYPE_CHECKING:
+ from embodichain.lab.gym.envs.embodied_env import EmbodiedEnv
+ from embodichain.lab.sim.types import EnvObs
+ from .integrations.sim import SimInitialState, SimInitialStateAdapter
+
+__all__ = ["InitialStateProfile", "PreparedBatch", "FixedSceneHost"]
+
+
+@configclass
+class InitialStateProfile:
+ """Trusted preparation callbacks for one task and controller configuration.
+
+ ``prepare`` must reset all task/controller state not owned by the standard
+ Gym managers without randomizing the scene. ``signature`` must identify the
+ fixed physical, visual, sensor, and control conditions, excluding evolving
+ poses and joint positions. ``verify`` checks the provided task initial state
+ against every declared case after settling. These callables are supplied
+ by trusted Python integration code, never imported from job YAML strings.
+
+ Args:
+ profile_id: Stable preparation profile identifier.
+ prepare: Deterministic task/controller preparation callback.
+ signature: Callback returning a nonempty fixed-condition signature.
+ verify: Task-specific initial-state checks for the complete batch.
+ physics_dt: Physics step duration in seconds; must match Gym when used.
+ settling_steps: Number of preparation physics steps, outside rollouts.
+ allowed_interval_events: Gym interval event names explicitly certified
+ to preserve the fixed conditions by this profile.
+ """
+
+ profile_id: str = MISSING
+ prepare: Callable[[], None] = MISSING
+ signature: Callable[[], str] = MISSING
+ verify: Callable[[tuple[SceneCase, ...]], ValidationResult] = MISSING
+ physics_dt: float = 0.01
+ settling_steps: int = 0
+ allowed_interval_events: tuple[str, ...] = ()
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.profile_id, str) or not self.profile_id.strip():
+ raise ValueError("profile_id must be a nonempty string")
+ for name in ("prepare", "signature", "verify"):
+ if not callable(getattr(self, name)):
+ raise ValueError(f"{name} must be a trusted callback")
+ if (
+ isinstance(self.physics_dt, bool)
+ or not isinstance(self.physics_dt, (int, float))
+ or not math.isfinite(self.physics_dt)
+ or self.physics_dt <= 0
+ ):
+ raise ValueError("physics_dt must be positive and finite")
+ if type(self.settling_steps) is not int or self.settling_steps < 0:
+ raise ValueError("settling_steps must be a nonnegative integer")
+ if isinstance(self.allowed_interval_events, str):
+ raise ValueError("allowed_interval_events must be a sequence of names")
+ self.allowed_interval_events = tuple(self.allowed_interval_events)
+ if any(
+ not isinstance(name, str) or not name.strip()
+ for name in self.allowed_interval_events
+ ) or len(set(self.allowed_interval_events)) != len(
+ self.allowed_interval_events
+ ):
+ raise ValueError("allowed_interval_events must contain unique names")
+
+
+@dataclass(frozen=True)
+class PreparedBatch:
+ """Identity of a successfully prepared full batch.
+
+ Cases are ordered by physical slot. A token is valid only for its owning
+ host and epoch; it does not make an old ActionPlan or runtime reusable.
+ """
+
+ host_id: str
+ epoch: int
+ cases: tuple[SceneCase, ...]
+
+
+class FixedSceneHost:
+ """Own preparation of one complete simulator batch, optionally through Gym.
+
+ Capture consumes a caller-provided scene. Restoring discards pending Gym
+ recordings, so the caller must freeze old episode evidence first. Both
+ paths run deterministic preparation, settling, and verification before
+ publishing a new binding. The owner must remain the sole simulation
+ stepper for the lifetime of this object.
+
+ Args:
+ adapter: Physical state adapter for the complete simulator batch.
+ profile: Trusted task, controller, event, and fixed-condition policy.
+ env: Optional unwrapped Gym host using the same simulator and robot.
+
+ Raises:
+ ValueError: If the host or preparation policy is incompatible.
+ RuntimeError: If another generation host already owns the batch.
+ """
+
+ def __init__(
+ self,
+ adapter: SimInitialStateAdapter,
+ profile: InitialStateProfile,
+ *,
+ env: EmbodiedEnv | None = None,
+ ) -> None:
+ self.adapter = adapter
+ self.profile = profile.copy()
+ self.env = env
+ self._host_id = uuid4().hex
+ self._epoch = 0
+ self._binding: PreparedBatch | None = None
+ self._initial_state: SimInitialState | None = None
+ self._cases: tuple[SceneCase, ...] = ()
+ self._condition_signature: str | None = None
+ self._prepared_observation: EnvObs | None = None
+ self._closed = False
+ if env is not None:
+ if env.sim is not adapter.sim or env.robot is not adapter.robot:
+ raise ValueError("Gym and physical adapter must own the same sim/robot")
+ if env.num_envs != adapter.sim.num_envs:
+ raise ValueError("generation requires the complete simulator batch")
+ if not math.isclose(env.physics_dt, profile.physics_dt, rel_tol=1e-9):
+ raise ValueError("profile physics_dt must match the Gym physics clock")
+ self._check_interval_events()
+ if getattr(adapter.sim, "_trajectory_generation_owner", None) is not None:
+ raise RuntimeError("simulator batch already has a generation owner")
+ self._structure_signature = adapter.signature()
+ if env is not None:
+ env.acquire_generation_lease(self)
+ adapter.sim._trajectory_generation_owner = self
+
+ def acquire_case(self, cases: Sequence[SceneCase]) -> PreparedBatch:
+ """Prepare and capture all caller-provided initial states.
+
+ Args:
+ cases: One immutable scene case per simulator row.
+
+ Returns:
+ A binding published only after preparation and validation succeed.
+
+ Raises:
+ ValueError: If cases do not cover the full batch.
+ RuntimeError: If this host already acquired its fixed cases.
+ """
+ self._assert_owner()
+ if self._initial_state is not None:
+ raise RuntimeError("cases already acquired; use restore_initial")
+ values = tuple(cases)
+ if len(values) != self.adapter.sim.num_envs or any(
+ not isinstance(case, SceneCase) for case in values
+ ):
+ raise ValueError("provide one SceneCase for every simulator row")
+ self._cases = values
+ self._condition_signature = self._signature()
+ return self._prepare(capture=True)
+
+ def restore_initial(self) -> PreparedBatch:
+ """Restore the owned initial batch and invalidate every earlier binding.
+
+ Returns:
+ The new successfully prepared binding.
+
+ Raises:
+ RuntimeError: If no initial batch exists or fixed conditions drifted.
+ """
+ self._assert_owner()
+ if self._initial_state is None:
+ raise RuntimeError("acquire_case must succeed before restore_initial")
+ return self._prepare(capture=False)
+
+ def assert_current(self, batch: PreparedBatch) -> None:
+ """Reject a foreign or obsolete execution binding before issuing commands.
+
+ Args:
+ batch: The binding attached to the candidate execution.
+
+ Raises:
+ RuntimeError: If ownership, epoch, or fixed conditions changed.
+ """
+ self._assert_owner()
+ if batch is not self._binding or batch.epoch != self._current_epoch():
+ raise RuntimeError("execution binding belongs to an obsolete host epoch")
+ self._check_conditions()
+
+ def verify_initial(self, batch: PreparedBatch) -> ValidationResult:
+ """Recheck physical and task initial state for the current binding.
+
+ Args:
+ batch: The current prepared binding, before rollout begins.
+
+ Returns:
+ Physical restoration and task-specific verification checks.
+ """
+ self.assert_current(batch)
+ return self._verify()
+
+ def snapshots(self, batch: PreparedBatch) -> tuple[MotionSnapshot, ...]:
+ """Export owned planning inputs from the captured initial batch.
+
+ Args:
+ batch: Current preparation binding for the declared cases.
+
+ Returns:
+ One full-joint snapshot per physical row, in local arena coordinates.
+ """
+ self.assert_current(batch)
+ state = self._initial_state
+ return tuple(
+ MotionSnapshot(
+ scene_case=case,
+ joint_names=state.joint_names,
+ joint_positions=state.robot["qpos"][row],
+ joint_velocities=state.robot["qvel"][row],
+ root_pose=state.robot["root_pose"][row],
+ entity_poses={
+ uid: fields["pose"][row]
+ for uid, fields in state.rigid_objects.items()
+ },
+ )
+ for row, case in enumerate(batch.cases)
+ )
+
+ def initial_observation(self, batch: PreparedBatch) -> EnvObs | None:
+ """Copy the Gym observation published by preparation without querying again.
+
+ Args:
+ batch: Current preparation binding.
+
+ Returns:
+ The owned Gym first frame, or ``None`` for a pure simulator host.
+ """
+ self.assert_current(batch)
+ return (
+ None
+ if self._prepared_observation is None
+ else self._prepared_observation.clone()
+ )
+
+ def close(self) -> None:
+ """Release exclusive ownership and invalidate all execution bindings."""
+ if self._closed:
+ return
+ self._assert_owner()
+ if self.env is not None:
+ self.env.release_generation_lease(self)
+ self.adapter.sim._trajectory_generation_owner = None
+ self._binding = None
+ self._prepared_observation = None
+ self._epoch += 1
+ self._closed = True
+
+ def __enter__(self) -> FixedSceneHost:
+ self._assert_owner()
+ return self
+
+ def __exit__(self, *args: object) -> None:
+ self.close()
+
+ def _assert_owner(self) -> None:
+ if (
+ self._closed
+ or getattr(self.adapter.sim, "_trajectory_generation_owner", None)
+ is not self
+ ):
+ raise RuntimeError("generation host does not own the simulator batch")
+
+ def _signature(self) -> str:
+ value = self.profile.signature()
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError("fixed-condition signature must be a nonempty string")
+ return value
+
+ def _check_interval_events(self) -> None:
+ manager = getattr(self.env, "event_manager", None)
+ active = manager.active_functors if manager is not None else {}
+ unknown = set(active.get("interval", ())) - set(
+ self.profile.allowed_interval_events
+ )
+ if unknown:
+ raise ValueError(f"uncertified interval events: {sorted(unknown)}")
+
+ def _check_conditions(self) -> None:
+ self._check_interval_events()
+ if self.adapter.signature() != self._structure_signature:
+ raise RuntimeError("simulator topology or controller identity changed")
+ if self._signature() != self._condition_signature:
+ raise RuntimeError("fixed scene conditions changed after case acquisition")
+
+ def _current_epoch(self) -> int:
+ return self.env.generation_epoch if self.env is not None else self._epoch
+
+ def _settle(self) -> None:
+ if self.profile.settling_steps:
+ self.adapter.sim.update(
+ self.profile.physics_dt, self.profile.settling_steps
+ )
+
+ def _verify(self) -> ValidationResult:
+ self._check_conditions()
+ task_result = self.profile.verify(self._cases)
+ if not isinstance(task_result, ValidationResult):
+ raise TypeError("profile verify must return ValidationResult")
+ if not task_result.checks:
+ task_result = ValidationResult(
+ (
+ ValidationCheck(
+ "initial_conditions", "failed", "profile rejected initial state"
+ ),
+ )
+ )
+ physical = (
+ self.adapter.verify(self._initial_state)
+ if self._initial_state is not None
+ else ValidationResult(())
+ )
+ return ValidationResult((*physical.checks, *task_result.checks))
+
+ def _prepare(self, *, capture: bool) -> PreparedBatch:
+ self._binding = None
+ self._prepared_observation = None
+ self._epoch += 1
+
+ def prepare() -> None:
+ self._check_conditions()
+ self.profile.prepare()
+
+ def restore() -> None:
+ if not capture:
+ self.adapter.restore(self._initial_state)
+
+ def verify() -> ValidationResult:
+ result = self._verify()
+ if result.accepted and capture:
+ state = self.adapter.capture()
+ physical = self.adapter.verify(state)
+ result = ValidationResult((*physical.checks, *result.checks))
+ if result.accepted:
+ self._initial_state = state
+ return result
+
+ try:
+ if self.env is not None:
+ observation, _ = self.env.prepare_generation_episode(
+ self,
+ prepare=prepare,
+ restore=restore,
+ settle=self._settle,
+ verify=verify,
+ )
+ self._prepared_observation = observation.clone()
+ else:
+ prepare()
+ restore()
+ self._settle()
+ result = verify()
+ if not result.accepted:
+ raise RuntimeError(
+ f"initial state verification failed: {result.checks}"
+ )
+ except Exception:
+ if capture:
+ self._initial_state = None
+ raise
+ self._binding = PreparedBatch(self._host_id, self._current_epoch(), self._cases)
+ return self._binding
diff --git a/embodichain/lab/trajectory_generation/integrations/__init__.py b/embodichain/lab/trajectory_generation/integrations/__init__.py
new file mode 100644
index 000000000..3818cfd8e
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/integrations/__init__.py
@@ -0,0 +1,19 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+__all__ = []
diff --git a/embodichain/lab/trajectory_generation/integrations/_collision.py b/embodichain/lab/trajectory_generation/integrations/_collision.py
new file mode 100644
index 000000000..9e894902b
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/integrations/_collision.py
@@ -0,0 +1,189 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Conservative CPU collision geometry for the bounded PickUp integration."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from itertools import combinations
+import numpy as np
+import torch
+
+__all__ = []
+
+
+class _FullStateCollisionWorld:
+ """URDF collision convex hulls and explicitly supported physical cuboids.
+
+ Never substitute render meshes for collision geometry. One convex hull per
+ link conservatively covers all its URDF collision shapes, including their
+ local transforms. Unsupported scene shapes/scales fail at construction.
+ """
+
+ def __init__(self, sim, robot):
+ import fcl
+ import trimesh
+ from yourdfpy import URDF
+ from embodichain.lab.sim.shapes import CubeCfg
+
+ if tuple(robot.cfg.body_scale) != (1.0, 1.0, 1.0):
+ raise ValueError("PickUp collision validation requires an unscaled URDF")
+ self.fcl, self.robot = fcl, robot
+ urdf = URDF.load(
+ robot.cfg.fpath,
+ build_scene_graph=False,
+ load_meshes=False,
+ build_collision_scene_graph=True,
+ load_collision_meshes=True,
+ )
+ scene = urdf.collision_scene
+ meshes = defaultdict(list)
+ for node in scene.graph.nodes_geometry:
+ parent = scene.graph.transforms.parents[node]
+ if parent not in urdf.link_map:
+ raise ValueError(
+ "Collision geometry is not directly owned by a URDF link"
+ )
+ transform, name = scene.graph.get(frame_to=node, frame_from=parent)
+ mesh = scene.geometry[name].copy()
+ mesh.apply_transform(transform)
+ meshes[parent].append(mesh)
+ expected = {name for name, link in urdf.link_map.items() if link.collisions}
+ if not expected or set(meshes) != expected:
+ raise ValueError("Every URDF collision link must have loaded geometry")
+ self.links = tuple(meshes)
+ neighbors = defaultdict(set)
+ fixed_links = {urdf.base_link}
+ while True:
+ previous = len(fixed_links)
+ fixed_links.update(
+ j.child
+ for j in urdf.joint_map.values()
+ if j.type == "fixed" and j.parent in fixed_links
+ )
+ if previous == len(fixed_links):
+ break
+ self.fixed_links = frozenset(fixed_links)
+ for joint in urdf.joint_map.values():
+ neighbors[joint.parent].add(joint.child)
+ neighbors[joint.child].add(joint.parent)
+ # Match the existing cuRobo profile's two-hop structural exclusions.
+ self.adjacent_pairs = set()
+ for link in self.links:
+ nearby = neighbors[link] | set().union(
+ *(neighbors[n] for n in neighbors[link])
+ )
+ self.adjacent_pairs.update(
+ frozenset((link, other)) for other in nearby if other != link
+ )
+ self.objects = {}
+ self.bounds = {}
+ for link, parts in meshes.items():
+ hull = trimesh.util.concatenate(parts).convex_hull
+ faces = np.c_[np.full(len(hull.faces), 3), hull.faces].astype(np.int32)
+ geometry = fcl.Convex(
+ hull.vertices.astype(np.float64), len(faces), faces.ravel()
+ )
+ self.objects[link] = fcl.CollisionObject(geometry)
+ self.bounds[link] = hull.bounds
+ self.entity_ids = tuple(sim.get_rigid_object_uid_list())
+ if set(self.links) & set(self.entity_ids) or "__ground__" in self.entity_ids:
+ raise ValueError("Collision link and object names must be distinct")
+ for uid in self.entity_ids:
+ obj = sim.get_rigid_object(uid)
+ if not isinstance(obj.cfg.shape, CubeCfg):
+ raise ValueError(
+ "PickUp collision validation currently supports cuboid rigid objects only"
+ )
+ scale = obj.get_body_scale().detach().cpu().numpy()
+ if not np.allclose(scale, scale[:1], atol=0, rtol=0):
+ raise ValueError(
+ "Collision shapes must have identical dimensions in every row"
+ )
+ size = np.asarray(obj.cfg.shape.size, dtype=float) * scale[0]
+ self._box(uid, size)
+ # SimulationManager's implicit floor, in each arena's local frame.
+ self._box("__ground__", np.array([1000.0, 1000.0, 100.0]))
+ self.ground_pose = np.eye(4)
+ self.ground_pose[2, 3] = -50.001
+ self.self_pairs = tuple(combinations(self.links, 2))
+ self.world_pairs = tuple(
+ (link, uid)
+ for link in self.links
+ for uid in (*self.entity_ids, "__ground__")
+ )
+
+ def _box(self, uid, size):
+ if size.shape != (3,) or not np.isfinite(size).all() or (size <= 0).any():
+ raise ValueError("Cuboid dimensions must be three positive finite values")
+ self.objects[uid] = self.fcl.CollisionObject(self.fcl.Box(*size))
+ self.bounds[uid] = np.stack([-size / 2, size / 2])
+
+ def link_poses(self, positions, root_pose, extra_links=()):
+ from embodichain.lab.sim.objects.articulation import Articulation
+
+ local = Articulation.compute_fk(
+ self.robot,
+ positions.to(self.robot.device),
+ link_names=tuple(dict.fromkeys((*self.links, *extra_links))),
+ qpos_joint_names=tuple(self.robot.joint_names),
+ to_dict=True,
+ )
+ root = root_pose.detach().cpu().double().numpy()
+ return {
+ name: root @ local[name].get_matrix().detach().cpu().double().numpy()
+ for name in dict.fromkeys((*self.links, *extra_links))
+ }
+
+ def collisions(self, link_poses, entity_poses, target_id, allowed):
+ poses = {**link_poses, **entity_poses, "__ground__": self.ground_pose}
+ aabbs = {}
+ for name, pose in poses.items():
+ if name not in self.objects:
+ raise ValueError(f"Missing collision shape: {name}")
+ self.objects[name].setTransform(
+ self.fcl.Transform(pose[:3, :3], pose[:3, 3])
+ )
+ low, high = self.bounds[name]
+ center = pose[:3, :3] @ ((low + high) / 2) + pose[:3, 3]
+ radius = np.abs(pose[:3, :3]) @ ((high - low) / 2)
+ aabbs[name] = (center - radius, center + radius)
+ pairs = (
+ *self.self_pairs,
+ *self.world_pairs,
+ *(
+ (target_id, uid)
+ for uid in (*self.entity_ids, "__ground__")
+ if uid != target_id
+ ),
+ )
+ for first, second in pairs:
+ if allowed(first, second):
+ continue
+ al, ah = aabbs[first]
+ bl, bh = aabbs[second]
+ if (al > bh).any() or (bl > ah).any():
+ continue
+ result = self.fcl.CollisionResult()
+ if self.fcl.collide(
+ self.objects[first],
+ self.objects[second],
+ self.fcl.CollisionRequest(),
+ result,
+ ):
+ return f"{first} / {second}"
+ return None
diff --git a/embodichain/lab/trajectory_generation/integrations/atomic.py b/embodichain/lab/trajectory_generation/integrations/atomic.py
new file mode 100644
index 000000000..7ee7c69d0
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/integrations/atomic.py
@@ -0,0 +1,163 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Explicit export of an offline MoveEndEffector → PickUp compilation."""
+
+from __future__ import annotations
+
+import math
+from typing import TYPE_CHECKING
+import torch
+
+from embodichain.lab.sim.motion.expansion import (
+ TrajectoryPhase,
+ TrajectoryTemplate,
+)
+
+if TYPE_CHECKING:
+ from embodichain.lab.sim.atomic_actions.plans import CompiledTrajectory
+ from embodichain.lab.sim.objects import Robot
+
+__all__ = ["export_pickup_templates"]
+
+
+def export_pickup_templates(
+ compiled: CompiledTrajectory,
+ robot: Robot,
+ *,
+ control_dt: float,
+ source_id: str = "atomic_pickup",
+ source_revision: str = "v1",
+ template_id: str = "reference_0",
+ validator_id: str = "task_success",
+ hold_steps: int = 30,
+ control_part: str = "arm",
+) -> tuple[TrajectoryTemplate, ...]:
+ """Export full-joint candidates with protected approach/close/lift/hold phases.
+
+ Accepts exactly MoveEndEffector followed by PickUp. Segment boundaries come
+ from the atomic plans; they are never inferred from motion. The duplicate
+ zero-time boundary must be a shared position and becomes one control-period
+ hold. Mimic geometry is expanded from the robot's declared affine coupling.
+ Only the initial transit permits spatial residual augmentation.
+
+ This exports an offline atomic plan for independently validated qpos replay.
+ It does not commit projected symbolic effects or run AtomicActionRuntime's
+ recovery/feedback state machine. Physical contact and task verification are
+ required separately before an exported trajectory can become expert data.
+
+ Args:
+ compiled: Successful full-batch result from AtomicActionEngine.compile.
+ robot: Robot whose full joint order and mimic model produced the plan.
+ control_dt: Fixed replay period, matching every positive plan interval.
+ source_id: Stable source identity.
+ source_revision: Caller-owned source revision.
+ template_id: Template identity shared across row-specific cases.
+ validator_id: Required physical task check identity.
+ hold_steps: Explicit terminal commands used to measure a stable grasp.
+ control_part: Arm joints authorized for transit residuals.
+
+ Returns:
+ One independently owned template for each physical row.
+ """
+ if (
+ not math.isfinite(control_dt)
+ or control_dt <= 0
+ or type(hold_steps) is not int
+ or hold_steps < 2
+ ):
+ raise ValueError("Provide a positive control_dt and at least two hold steps")
+ if tuple(plan.skill_id for plan in compiled.action_plans) != (
+ "move_end_effector",
+ "pick_up",
+ ):
+ raise ValueError("Export requires exactly MoveEndEffector followed by PickUp")
+ if not bool(compiled.plan_success.all()):
+ raise ValueError("Cannot export failed atomic plans")
+ positions = compiled.trajectory.positions.clone()
+ intervals = compiled.trajectory.dt.clone()
+ if positions.shape[0] != robot.num_instances or positions.shape[2] != robot.dof:
+ raise ValueError(
+ "Compiled trajectory must cover the complete physical robot batch"
+ )
+ if not torch.equal(
+ compiled.trajectory.env_ids.cpu(), torch.arange(robot.num_instances)
+ ):
+ raise ValueError("Compiled row order must be the physical environment order")
+ transit = compiled.action_waypoint_offset(1)
+ approach, close, lift = (
+ compiled.segment(1, name) for name in ("approach", "close", "lift")
+ )
+ if (
+ approach.start != transit
+ or approach.stop != close.start
+ or close.stop != lift.start
+ or lift.stop != positions.shape[1]
+ ):
+ raise ValueError("PickUp segments must cover the complete second action")
+ if not torch.allclose(
+ positions[:, transit], positions[:, transit - 1], atol=1e-5, rtol=0
+ ):
+ raise ValueError("Atomic action boundary is not a shared position")
+ if not bool((intervals[:, 0] == 0).all() and (intervals[:, transit] == 0).all()):
+ raise ValueError("Expected explicit zero-time action anchors")
+ intervals[:, transit] = control_dt
+ if not torch.allclose(
+ intervals[:, 1:].double(),
+ torch.full_like(intervals[:, 1:].double(), control_dt),
+ atol=1e-8,
+ rtol=0,
+ ):
+ raise ValueError("Atomic timing must match the replay control clock")
+ if set(robot.mimic_ids) & set(robot.mimic_parents):
+ raise ValueError("PickUp export requires direct active-to-mimic coupling")
+ for child, parent, multiplier, offset in zip(
+ robot.mimic_ids,
+ robot.mimic_parents,
+ robot.mimic_multipliers,
+ robot.mimic_offsets,
+ ):
+ positions[..., child] = positions[..., parent] * multiplier + offset
+ length = positions.shape[1]
+ positions = torch.cat(
+ (positions, positions[:, -1:].repeat(1, hold_steps, 1)), dim=1
+ )
+ intervals = torch.cat(
+ (intervals, intervals.new_full((robot.num_instances, hold_steps), control_dt)),
+ dim=1,
+ )
+ phases = (
+ TrajectoryPhase("transit", 0, transit, allowed_operators=("joint_residual",)),
+ TrajectoryPhase("approach", transit, close.start, kind="contact"),
+ TrajectoryPhase("close", close.start, lift.start, kind="contact"),
+ TrajectoryPhase("lift", lift.start, length, kind="contact"),
+ TrajectoryPhase("hold", length, length + hold_steps, kind="hold"),
+ )
+ return tuple(
+ TrajectoryTemplate(
+ source_id,
+ source_revision,
+ template_id,
+ tuple(robot.joint_names),
+ positions[row],
+ intervals[row],
+ phases,
+ allowed_operators=("joint_residual",),
+ validator_id=validator_id,
+ controlled_joint_indices=tuple(robot.get_joint_ids(control_part)),
+ )
+ for row in range(robot.num_instances)
+ )
diff --git a/embodichain/lab/trajectory_generation/integrations/contact.py b/embodichain/lab/trajectory_generation/integrations/contact.py
new file mode 100644
index 000000000..b75b746cb
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/integrations/contact.py
@@ -0,0 +1,661 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Contact-aware, full-state validation for one fixed-scene cuboid PickUp."""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass, field
+import math
+from typing import TYPE_CHECKING
+
+import numpy as np
+import torch
+
+from embodichain.utils import configclass
+from embodichain.lab.sim.motion.expansion import (
+ CandidateTrajectoryBatch,
+ ExpertEpisode,
+ MotionSnapshot,
+ ValidationCheck,
+ ValidationResult,
+)
+from ._collision import _FullStateCollisionWorld
+
+if TYPE_CHECKING:
+ from embodichain.lab.sim import SimulationManager
+ from embodichain.lab.sim.objects import Robot
+
+__all__ = ["PickUpContactProfile", "PickUpMotionValidator"]
+
+
+def _result(name, passed, detail="", **metrics):
+ return ValidationResult(
+ (ValidationCheck(name, "passed" if passed else "failed", detail, metrics),)
+ )
+
+
+@configclass
+class PickUpContactProfile:
+ """Explicit permissions and measured-grasp tolerances for a single PickUp.
+
+ Finger/object contacts are allowed in the declared final approach region
+ and from ``close`` onwards. Object/support contact is allowed through
+ initial lift-off; robot/support contact
+ is restricted to the declared fixed mounting links. Self pairs within two
+ URDF kinematic hops follow the existing cuRobo structural exclusion policy.
+ All other robot/world, object/world and nonexcluded self collisions reject.
+ """
+
+ object_id: str = "cube"
+ support_id: str = "bench"
+ finger_links: tuple[str, ...] = ("gripper_finger1_link_1", "gripper_finger2_link_1")
+ mounting_links: tuple[str, ...] = ("arm_base_link",)
+ tcp_link: str = "ee_link"
+ tcp_offset: tuple[tuple[float, ...], ...] = (
+ (1.0, 0.0, 0.0, 0.0),
+ (0.0, 1.0, 0.0, 0.0),
+ (0.0, 0.0, 1.0, 0.15),
+ (0.0, 0.0, 0.0, 1.0),
+ )
+ min_lift: float = 0.12
+ max_relative_translation: float = 0.01
+ max_relative_rotation: float = 0.15
+ max_tcp_distance: float = 0.06
+ approach_contact_distance: float = 0.025
+ max_penetration: float = 0.002
+ min_contact_fraction: float = 0.95
+ min_hold_seconds: float = 1.0
+ measured_joint_tolerance: float = 1e-4
+ max_joint_step: float = 0.02
+ max_validation_samples: int = 4096
+ max_contacts_per_step: int = 4096
+
+ def __post_init__(self) -> None:
+ for name in ("object_id", "support_id", "tcp_link"):
+ if not isinstance(getattr(self, name), str) or not getattr(self, name):
+ raise ValueError(f"{name} must be a nonempty name")
+ if self.object_id == self.support_id:
+ raise ValueError("PickUp target and support must differ")
+ for name in ("finger_links", "mounting_links"):
+ values = getattr(self, name)
+ if (
+ isinstance(values, str)
+ or any(not isinstance(v, str) or not v for v in values)
+ or len(set(values)) != len(values)
+ ):
+ raise ValueError(f"{name} must contain unique names")
+ setattr(self, name, tuple(values))
+ if len(self.finger_links) != 2:
+ raise ValueError(
+ "This PickUp profile requires two distinct gripping fingers"
+ )
+ from embodichain.lab.sim.motion.expansion.contracts import _pose
+
+ _pose(torch.tensor(self.tcp_offset, dtype=torch.float64), "tcp_offset")
+ for name in (
+ "min_lift",
+ "max_relative_translation",
+ "max_relative_rotation",
+ "max_tcp_distance",
+ "approach_contact_distance",
+ "max_penetration",
+ "min_contact_fraction",
+ "min_hold_seconds",
+ "measured_joint_tolerance",
+ "max_joint_step",
+ ):
+ value = getattr(self, name)
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, (int, float))
+ or not math.isfinite(value)
+ or value <= 0
+ ):
+ raise ValueError(f"{name} must be finite and positive")
+ if self.min_contact_fraction > 1:
+ raise ValueError("min_contact_fraction must be <= 1")
+ for name in ("max_validation_samples", "max_contacts_per_step"):
+ if type(getattr(self, name)) is not int or getattr(self, name) < 2:
+ raise ValueError(f"{name} must be an integer >= 2")
+
+
+@dataclass
+class _Evidence:
+ steps: int = 0
+ hold_steps: int = 0
+ contact_steps: list[int] = field(default_factory=lambda: [0, 0])
+ first_relative: np.ndarray | None = None
+ minimum_lift: float = math.inf
+ max_translation: float = 0.0
+ max_rotation: float = 0.0
+ max_distance: float = 0.0
+ failure: str | None = None
+
+
+class PickUpMotionValidator:
+ """Validate compiled PickUp plans and actual CPU-physics contact evidence.
+
+ This bounded integration accepts an unscaled, fixed-base URDF robot and
+ cuboid rigid objects. Planned checks use conservative convex hulls of URDF
+ *collision* shapes, full joint FK including mimic geometry, a TCP-relative
+ lifted object, and densified joint samples. Actual checks use measured full
+ joints and object poses; native contacts are also checked after every physics
+ substep. No continuous-collision guarantee is claimed.
+
+ The same instance must be attached to QposRolloutExecutor and GenerationRunner.
+ Only the target object and physically observed mimic joints may move outside
+ the active controller layout. Gym is deliberately unsupported until its
+ execution loop exposes equivalent physical-substep evidence.
+
+ Args:
+ sim: Manually stepped CPU-physics simulation owning every row.
+ robot: The sole fixed-base robot, with initialized full-articulation FK.
+ profile: Explicit collision permissions and measured task thresholds.
+ """
+
+ def __init__(
+ self,
+ sim: SimulationManager,
+ robot: Robot,
+ *,
+ profile: PickUpContactProfile | None = None,
+ ) -> None:
+ if torch.device(sim.sim_config.sim_device).type != "cpu":
+ raise ValueError("PickUp contact evidence currently requires CPU physics")
+ self.sim, self.robot = sim, robot
+ self.profile = (profile or PickUpContactProfile()).copy()
+ self.profile.__post_init__()
+ self.world = _FullStateCollisionWorld(sim, robot)
+ p = self.profile
+ if {p.object_id, p.support_id} - set(self.world.entity_ids):
+ raise ValueError(
+ "PickUp object and support must be registered collision entities"
+ )
+ if (
+ set((*p.finger_links, *p.mounting_links)) - set(self.world.links)
+ or p.tcp_link not in robot.link_names
+ ):
+ raise ValueError(
+ "PickUp link permissions must resolve to actual collision links"
+ )
+ self.tcp_offset = torch.tensor(
+ p.tcp_offset, dtype=torch.float32, device=robot.device
+ )
+ if set(p.mounting_links) - self.world.fixed_links:
+ raise ValueError(
+ "Only links fixed to the robot root may contact its mounting support"
+ )
+ self.mimic_ids = tuple(robot.mimic_ids)
+ self.dynamic_entity_ids = (p.object_id,)
+ import dexsim
+
+ self._physics = dexsim.default_world().get_physics_scene()
+ self._physics.enable_contact_data_update_on_cpu(True)
+ self._users = {}
+ for link in robot.link_names:
+ self._register_users(link, robot.get_user_ids(link))
+ for uid in self.world.entity_ids:
+ self._register_users(uid, sim.get_rigid_object(uid).get_user_ids())
+ self._rows = ()
+ self._snapshots = ()
+ self._evidence = []
+
+ @property
+ def collision_world_entity_ids(self) -> tuple[str, ...]:
+ """Physical rigid UIDs represented by this complete collision model."""
+ return self.world.entity_ids
+
+ def _register_users(self, name, values):
+ ids = values.detach().cpu().reshape(-1).tolist()
+ if len(ids) != self.robot.num_instances:
+ raise ValueError("Contact user IDs must map every physical row")
+ for row, uid in enumerate(ids):
+ if uid in self._users and self._users[uid] != (row, name):
+ raise ValueError("Ambiguous native contact body ID")
+ self._users[uid] = (row, name)
+
+ def _phases(self, phases, length):
+ expected = ("transit", "approach", "close", "lift", "hold")
+ if tuple(phase.phase_id for phase in phases) != expected:
+ raise ValueError(
+ "PickUp requires explicit transit/approach/close/lift/hold phases"
+ )
+ end = 0
+ kinds = ("free", "contact", "contact", "contact", "hold")
+ for phase, kind in zip(phases, kinds):
+ if phase.start_index != end or phase.kind != kind:
+ raise ValueError("PickUp phases must cover the trajectory in order")
+ if phase.phase_id != "transit" and phase.allowed_operators:
+ raise ValueError("PickUp contact and approach phases must be protected")
+ end = phase.stop_index
+ if end != length:
+ raise ValueError("PickUp phases must cover every trajectory sample")
+ return phases
+
+ def _allowed(self, first, second, phase, lift, grasp_distance=math.inf):
+ p = self.profile
+ pair = frozenset((first, second))
+ if pair in self.world.adjacent_pairs:
+ return True
+ if p.support_id in pair and any(link in pair for link in p.mounting_links):
+ return True
+ if p.object_id in pair:
+ other = second if first == p.object_id else first
+ if other in p.finger_links and (
+ phase in ("close", "lift", "hold")
+ or (
+ phase == "approach"
+ and grasp_distance <= p.approach_contact_distance
+ )
+ ):
+ return True
+ if other == p.support_id and (
+ phase in ("transit", "approach", "close")
+ or (phase == "lift" and lift <= 0.005)
+ ):
+ return True
+ return False
+
+ def _validate_path(self, qpos, phases, snapshot, row, *, measured_objects=None):
+ p = self.profile
+ qpos = qpos.detach().cpu()
+ self._phases(phases, len(qpos))
+ if not torch.allclose(
+ qpos[0], snapshot.joint_positions.cpu().to(qpos), atol=1e-6, rtol=0
+ ):
+ raise ValueError("PickUp path must begin at the captured initial joints")
+ limits = self.robot.get_qpos_limits()[row].detach().cpu().to(qpos)
+ tolerance = 0.0 if measured_objects is None else p.measured_joint_tolerance
+ if bool(
+ (
+ (qpos < limits[:, 0] - tolerance) | (qpos > limits[:, 1] + tolerance)
+ ).any()
+ ):
+ return _result(
+ "path_collision",
+ False,
+ "Full-joint position limits exceeded",
+ maximum_joint_limit_violation=float(
+ torch.maximum(limits[:, 0] - qpos, qpos - limits[:, 1])
+ .clamp(min=0)
+ .max()
+ ),
+ )
+ if set(snapshot.entity_poses) != set(self.world.entity_ids):
+ raise ValueError("PickUp snapshots must cover the complete collision world")
+ if measured_objects is None:
+ # Planned geometry must model passive mimic motion, even though the
+ # drive only receives targets for active joints.
+ for child, parent, multiplier, offset in zip(
+ self.robot.mimic_ids,
+ self.robot.mimic_parents,
+ self.robot.mimic_multipliers,
+ self.robot.mimic_offsets,
+ ):
+ if not torch.allclose(
+ qpos[:, child],
+ qpos[:, parent] * multiplier + offset,
+ atol=1e-6,
+ rtol=0,
+ ):
+ raise ValueError(
+ "Planned mimic coordinates disagree with the robot coupling"
+ )
+ increments = qpos.diff(dim=0).abs().amax(dim=1)
+ counts = torch.clamp(
+ torch.ceil(increments / p.max_joint_step).long(), min=1
+ )
+ if int(counts.sum()) + 1 > p.max_validation_samples:
+ raise ValueError("PickUp collision sampling exceeds the bounded budget")
+ samples, source_indices = [qpos[:1]], [0]
+ for index, count in enumerate(counts.tolist(), 1):
+ alpha = torch.arange(1, count + 1, dtype=qpos.dtype)[:, None] / count
+ samples.append(torch.lerp(qpos[index - 1], qpos[index], alpha))
+ source_indices.extend([index] * count)
+ values = torch.cat(samples)
+ else:
+ values = qpos
+ source_indices = list(range(len(qpos)))
+ if len(values) > p.max_validation_samples or measured_objects.shape != (
+ len(values),
+ 4,
+ 4,
+ ):
+ raise ValueError(
+ "Actual collision evidence has incompatible shape or exceeds its budget"
+ )
+ poses = self.world.link_poses(values, snapshot.root_pose, (p.tcp_link,))
+ tcp = poses[p.tcp_link] @ self.tcp_offset.cpu().double().numpy()
+ lift_start = phases[3].start_index
+ anchor = next(
+ i for i, index in enumerate(source_indices) if index >= lift_start
+ )
+ initial_object = snapshot.entity_poses[p.object_id].cpu().double().numpy()
+ relative = np.linalg.inv(tcp[anchor]) @ initial_object
+ for sample, index in enumerate(source_indices):
+ phase = next(
+ phase.phase_id
+ for phase in phases
+ if phase.start_index <= index < phase.stop_index
+ )
+ objects = {
+ uid: pose.cpu().double().numpy()
+ for uid, pose in snapshot.entity_poses.items()
+ }
+ if measured_objects is not None:
+ objects[p.object_id] = measured_objects[sample].cpu().double().numpy()
+ elif index >= lift_start:
+ objects[p.object_id] = tcp[sample] @ relative
+ height = objects[p.object_id][2, 3] - initial_object[2, 3]
+ collision = self.world.collisions(
+ {link: poses[link][sample] for link in self.world.links},
+ objects,
+ p.object_id,
+ lambda a, b: self._allowed(
+ a,
+ b,
+ phase,
+ height,
+ float(
+ np.linalg.norm(tcp[sample, :3, 3] - objects[p.object_id][:3, 3])
+ ),
+ ),
+ )
+ if collision:
+ return _result(
+ "path_collision",
+ False,
+ f"Forbidden collision at sample {index} ({phase}): {collision}",
+ checked_samples=sample + 1,
+ )
+ return _result(
+ "path_collision",
+ True,
+ "Conservative full-joint URDF hulls and object/world samples",
+ checked_samples=len(values),
+ )
+
+ def validate_qpos(
+ self, batch: CandidateTrajectoryBatch, snapshots: Sequence[MotionSnapshot]
+ ) -> tuple[ValidationResult, ...]:
+ """Check planned full-state paths, phase permissions and lifted geometry.
+
+ Args:
+ batch: Full-joint candidates with explicit physical source rows.
+ snapshots: Initial states in physical environment order.
+
+ Returns:
+ One path validation result per candidate, in candidate order.
+ """
+ if (
+ len(snapshots) != self.robot.num_instances
+ or batch.source_row_indices is None
+ or batch.joint_names != tuple(self.robot.joint_names)
+ ):
+ raise ValueError(
+ "PickUp validation requires full ordered joints and real source rows"
+ )
+ results = []
+ for index, row in enumerate(batch.source_row_indices.tolist()):
+ if row >= len(snapshots):
+ raise ValueError("Source row is outside the physical batch")
+ snapshot = snapshots[row]
+ if snapshot.joint_names != batch.joint_names:
+ raise ValueError(
+ "PickUp snapshots must use the full ordered robot joints"
+ )
+ identity = batch.identities[index]
+ if (identity.scene_case_id, identity.initial_state_id) != (
+ snapshot.scene_case.scene_case_id,
+ snapshot.scene_case.initial_state_id,
+ ):
+ raise ValueError(
+ "PickUp candidate identity does not match its snapshot"
+ )
+ length = int(batch.valid_length[index])
+ results.append(
+ self._validate_path(
+ batch.positions[index, :length], batch.phases[index], snapshot, row
+ )
+ )
+ return tuple(results)
+
+ def validate_episode(
+ self, episode: ExpertEpisode, snapshot: MotionSnapshot, *, row: int
+ ) -> ValidationResult:
+ """Recheck measured finger geometry and measured target poses after rollout.
+
+ Args:
+ episode: Frozen observations containing full joints and object poses.
+ snapshot: Initial scene state belonging to the episode's case.
+ row: Physical environment row used during execution.
+
+ Returns:
+ The measured path's joint-limit and collision validation result.
+ """
+ return self._validate_path(
+ episode.observations["joint_positions"],
+ episode.phases,
+ snapshot,
+ row,
+ measured_objects=episode.observations["object_pose"],
+ )
+
+ def observations(self) -> dict[str, torch.Tensor]:
+ """Return current physical object and TCP poses in each local arena.
+
+ Returns:
+ Object and TCP transforms, each shaped ``(num_envs, 4, 4)``.
+ """
+ return {
+ "object_pose": self.sim.get_rigid_object(
+ self.profile.object_id
+ ).get_local_pose(to_matrix=True),
+ "tcp_pose": self.robot.get_link_pose(self.profile.tcp_link, to_matrix=True)
+ @ self.tcp_offset,
+ }
+
+ def begin_rollout(
+ self,
+ candidates: Sequence[CandidateTrajectoryBatch | None],
+ snapshots: Sequence[MotionSnapshot],
+ ) -> None:
+ """Reset bounded per-row evidence before the first physical command.
+
+ Args:
+ candidates: One candidate per physical row, or ``None`` for idle rows.
+ snapshots: Initial states in the same physical row order.
+ """
+ if len(candidates) != self.robot.num_instances or len(snapshots) != len(
+ candidates
+ ):
+ raise ValueError(
+ "Contact monitoring must cover the complete physical batch"
+ )
+ for candidate in candidates:
+ if candidate is not None:
+ self._phases(candidate.phases[0], int(candidate.valid_length[0]))
+ self._rows, self._snapshots = tuple(candidates), tuple(snapshots)
+ self._evidence = [_Evidence() for _ in candidates]
+
+ def observe_substep(
+ self, sample_index: int, active: Sequence[bool], *, physics_dt: float
+ ) -> None:
+ """Accumulate native contacts and held-object stability after one physics step.
+
+ The CPU contact buffer is read before the next physics update. Unknown
+ bodies, cross-row contacts, excessive penetration and buffer budget
+ overflow cannot produce accepted evidence. No contact-history arrays grow.
+
+ Args:
+ sample_index: Command sample whose physics substep just completed.
+ active: Whether each physical row is still executing its candidate.
+ physics_dt: Duration of the completed physics substep in seconds.
+ """
+ p = self.profile
+ data, users = self._physics.get_cpu_contact_buffer()
+ data, users = np.asarray(data), np.asarray(users)
+ if (
+ data.ndim != 2
+ or data.shape[1] != 11
+ or users.shape != (len(data), 2)
+ or not np.isfinite(data).all()
+ or len(data) > p.max_contacts_per_step
+ ):
+ raise ValueError(
+ "Native contact evidence is malformed or exceeds its bounded budget"
+ )
+ observation = {
+ key: value.detach().cpu().double().numpy()
+ for key, value in self.observations().items()
+ }
+ finger_contacts = [set() for _ in active]
+ phases = []
+ for row, candidate in enumerate(self._rows):
+ phases.append(
+ None
+ if candidate is None or not active[row]
+ else next(
+ phase.phase_id
+ for phase in candidate.phases[0]
+ if phase.start_index <= sample_index < phase.stop_index
+ )
+ )
+ for contact, pair in zip(data, users):
+ first, second = self._users.get(int(pair[0])), self._users.get(int(pair[1]))
+ if first is None and second is None:
+ continue
+ known = first or second
+ row = known[0]
+ if first is not None and second is not None and first[0] != second[0]:
+ for owner in (first[0], second[0]):
+ if active[owner]:
+ self._evidence[owner].failure = (
+ self._evidence[owner].failure or "Cross-row native contact"
+ )
+ continue
+ if not active[row]:
+ continue
+ evidence = self._evidence[row]
+ if first is None or second is None:
+ evidence.failure = (
+ evidence.failure or "Unmapped body or cross-row native contact"
+ )
+ continue
+ a, b = first[1], second[1]
+ height = observation["object_pose"][row, 2, 3] - float(
+ self._snapshots[row].entity_poses[p.object_id][2, 3]
+ )
+ distance = float(
+ np.linalg.norm(
+ observation["tcp_pose"][row, :3, 3]
+ - observation["object_pose"][row, :3, 3]
+ )
+ )
+ if not self._allowed(a, b, phases[row], height, distance):
+ evidence.failure = (
+ evidence.failure
+ or f"Forbidden physical contact during {phases[row]}: {a} / {b}"
+ )
+ if contact[10] < -p.max_penetration:
+ evidence.failure = (
+ evidence.failure
+ or f"Physical penetration exceeds tolerance: {a} / {b}"
+ )
+ if contact[9] > 1e-7 and p.object_id in (a, b):
+ other = b if a == p.object_id else a
+ if other in p.finger_links:
+ finger_contacts[row].add(other)
+ for row, phase in enumerate(phases):
+ if phase is None:
+ continue
+ evidence = self._evidence[row]
+ evidence.steps += 1
+ if phase not in ("lift", "hold"):
+ continue
+ if phase == "hold":
+ evidence.hold_steps += 1
+ for index, link in enumerate(p.finger_links):
+ evidence.contact_steps[index] += link in finger_contacts[row]
+ obj, tcp = observation["object_pose"][row], observation["tcp_pose"][row]
+ relative = np.linalg.inv(tcp) @ obj
+ if evidence.first_relative is None:
+ evidence.first_relative = relative.copy()
+ delta = np.linalg.inv(evidence.first_relative) @ relative
+ if phase == "hold":
+ evidence.minimum_lift = min(
+ evidence.minimum_lift,
+ obj[2, 3]
+ - float(self._snapshots[row].entity_poses[p.object_id][2, 3]),
+ )
+ evidence.max_translation = max(
+ evidence.max_translation, float(np.linalg.norm(delta[:3, 3]))
+ )
+ angle = math.acos(float(np.clip((np.trace(delta[:3, :3]) - 1) / 2, -1, 1)))
+ evidence.max_rotation = max(evidence.max_rotation, angle)
+ evidence.max_distance = max(
+ evidence.max_distance, float(np.linalg.norm(relative[:3, 3]))
+ )
+ self._physics_dt = physics_dt
+
+ def rollout_validation(self, row: int) -> ValidationResult:
+ """Freeze contact coverage and stable-grasp gates for one completed row.
+
+ Args:
+ row: Physical environment row monitored by this validator.
+
+ Returns:
+ Mandatory native-contact and held-object stability checks.
+ """
+ e, p = self._evidence[row], self.profile
+ duration = e.hold_steps * getattr(self, "_physics_dt", 0.0)
+ fractions = [value / max(1, e.hold_steps) for value in e.contact_steps]
+ lift = float(e.minimum_lift) if math.isfinite(e.minimum_lift) else 0.0
+ held = (
+ duration >= p.min_hold_seconds
+ and lift >= p.min_lift
+ and e.max_translation <= p.max_relative_translation
+ and e.max_rotation <= p.max_relative_rotation
+ and e.max_distance <= p.max_tcp_distance
+ and min(fractions) >= p.min_contact_fraction
+ )
+ return ValidationResult(
+ (
+ ValidationCheck(
+ "physical_contacts",
+ "passed" if e.steps and e.failure is None else "failed",
+ e.failure
+ or "Native contacts checked after every CPU physics substep",
+ {"physics_samples": e.steps},
+ ),
+ ValidationCheck(
+ "held_object_stability",
+ "passed" if held else "failed",
+ "Both fingers must maintain physical contact with a lifted, stable object",
+ {
+ "hold_seconds": duration,
+ "minimum_lift_m": lift,
+ "max_relative_translation_m": e.max_translation,
+ "max_relative_rotation_rad": e.max_rotation,
+ "max_tcp_distance_m": e.max_distance,
+ "finger_0_contact_fraction": fractions[0],
+ "finger_1_contact_fraction": fractions[1],
+ },
+ ),
+ )
+ )
diff --git a/embodichain/lab/trajectory_generation/integrations/planning.py b/embodichain/lab/trajectory_generation/integrations/planning.py
new file mode 100644
index 000000000..71900d9a9
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/integrations/planning.py
@@ -0,0 +1,614 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Bounded environment-row planning for explicitly unloaded free trajectories."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+import math
+from typing import TYPE_CHECKING
+
+import torch
+
+from embodichain.lab.sim.motion.expansion import (
+ CandidateIdentity,
+ CandidateTrajectoryBatch,
+ MotionSnapshot,
+ TrajectoryPhase,
+ TrajectoryTemplate,
+ ValidationCheck,
+ ValidationResult,
+)
+
+if TYPE_CHECKING:
+ from embodichain.lab.sim.motion.motion_generator import MotionGenerator
+
+__all__ = ["EEFPath", "EnvRowMotionPlanner"]
+
+
+@dataclass(frozen=True)
+class EEFPath:
+ """Explicit TCP samples in the source row's local arena frame.
+
+ The first pose is the initial TCP anchor. ``dt`` contains arrival intervals,
+ starting at zero. Optional solved joint samples preserve a chosen IK branch
+ exactly and are checked against the poses through FK, without another IK.
+ Paths have no attachment or contact-permission representation.
+ """
+
+ identity: CandidateIdentity
+ source_row_index: int
+ poses: torch.Tensor
+ dt: torch.Tensor
+ phases: tuple[TrajectoryPhase, ...]
+ solved_joint_targets: torch.Tensor | None = None
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.identity, CandidateIdentity):
+ raise ValueError("identity must be CandidateIdentity")
+ if type(self.source_row_index) is not int or self.source_row_index < 0:
+ raise ValueError("source_row_index must be a nonnegative integer")
+ poses = self.poses
+ if (
+ not isinstance(poses, torch.Tensor)
+ or not poses.is_floating_point()
+ or poses.ndim != 3
+ or poses.shape[1:] != (4, 4)
+ or not poses.shape[0]
+ or not bool(torch.isfinite(poses).all())
+ ):
+ raise ValueError("poses must be finite floating shape (N >= 1, 4, 4)")
+ rotation = poses[:, :3, :3].double()
+ identity = torch.eye(3, device=poses.device, dtype=torch.float64)
+ if (
+ not torch.allclose(
+ rotation.transpose(1, 2) @ rotation,
+ identity.expand_as(rotation),
+ atol=1e-5,
+ rtol=0,
+ )
+ or not torch.allclose(
+ torch.linalg.det(rotation),
+ torch.ones(len(poses), device=poses.device, dtype=torch.float64),
+ atol=1e-5,
+ rtol=0,
+ )
+ or not torch.allclose(
+ poses[:, 3],
+ poses.new_tensor([0, 0, 0, 1]).expand(len(poses), 4),
+ atol=1e-6,
+ rtol=0,
+ )
+ ):
+ raise ValueError("poses must contain proper SE(3) transforms")
+ reference = TrajectoryTemplate(
+ self.identity.source_id,
+ self.identity.source_revision,
+ self.identity.template_id,
+ ("validation_joint",),
+ poses.new_zeros((len(poses), 1)),
+ self.dt,
+ self.phases,
+ )
+ solved = self.solved_joint_targets
+ if solved is not None:
+ if (
+ not isinstance(solved, torch.Tensor)
+ or not solved.is_floating_point()
+ or solved.ndim != 2
+ or solved.shape[0] != len(poses)
+ or not solved.shape[1]
+ or not bool(torch.isfinite(solved).all())
+ ):
+ raise ValueError(
+ "solved_joint_targets must be finite floating shape (N, D)"
+ )
+ solved = solved.detach().clone()
+ object.__setattr__(self, "poses", poses.detach().clone())
+ object.__setattr__(self, "dt", reference.dt)
+ object.__setattr__(self, "phases", reference.phases)
+ object.__setattr__(self, "solved_joint_targets", solved)
+
+
+def _check(status: str, detail: str, **metrics: float) -> ValidationResult:
+ return ValidationResult(
+ (ValidationCheck("path_collision", status, detail, metrics),)
+ )
+
+
+class EnvRowMotionPlanner:
+ """Convert EEF paths and check qpos without changing simulator batch size.
+
+ Only fully annotated free motion with no held object is supported. Every
+ uncontrolled joint must remain at the snapshot and robot configuration's
+ initial value, matching the current planner's locked-joint model. Callers
+ keep the live root poses and collision model synchronized with these same
+ snapshots. Scene entities use canonical IDs in the backend collision world.
+
+ Collision validation densifies joint segments, including phase boundaries,
+ then checks those samples through ``MotionGenerator``. This is a bounded
+ sampled approximation, not continuous collision detection. It neither steps
+ physics nor verifies task success or controller velocity/acceleration limits.
+
+ Args:
+ motion_generator: Existing generator attached to the real batch robot.
+ control_part: Ordered control part represented by the backend model.
+ held_object_ids: Explicit held-object declaration; nonempty is unsupported.
+ max_joint_step: Maximum absolute joint increment between checked samples.
+ max_validation_samples: Maximum samples per candidate, including anchors.
+ fk_position_tolerance: Maximum TCP translation residual in meters.
+ fk_rotation_tolerance: Maximum TCP rotation residual in radians.
+ """
+
+ def __init__(
+ self,
+ motion_generator: MotionGenerator,
+ *,
+ control_part: str,
+ held_object_ids: Sequence[str],
+ max_joint_step: float = 0.02,
+ max_validation_samples: int = 4096,
+ fk_position_tolerance: float = 1e-3,
+ fk_rotation_tolerance: float = 1e-2,
+ ) -> None:
+ self.motion_generator = motion_generator
+ self.robot = motion_generator.robot
+ if not isinstance(control_part, str) or not control_part:
+ raise ValueError("control_part must be a nonempty name")
+ self.control_part = control_part
+ self.joint_names = tuple(self.robot.joint_names)
+ self.joint_ids = tuple(self.robot.get_joint_ids(control_part))
+ if not self.joint_ids or len(set(self.joint_ids)) != len(self.joint_ids):
+ raise ValueError("control_part must contain unique joints")
+ self.fixed_ids = tuple(
+ i for i in range(len(self.joint_names)) if i not in self.joint_ids
+ )
+ if isinstance(held_object_ids, str) or any(
+ not isinstance(x, str) or not x for x in held_object_ids
+ ):
+ raise ValueError("held_object_ids must be an explicit sequence of names")
+ self.held_object_ids = tuple(held_object_ids)
+ for name, value in (
+ ("max_joint_step", max_joint_step),
+ ("fk_position_tolerance", fk_position_tolerance),
+ ("fk_rotation_tolerance", fk_rotation_tolerance),
+ ):
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, (float, int))
+ or not math.isfinite(value)
+ or value <= 0
+ ):
+ raise ValueError(f"{name} must be positive and finite")
+ setattr(self, name, float(value))
+ if type(max_validation_samples) is not int or max_validation_samples < 1:
+ raise ValueError("max_validation_samples must be a positive integer")
+ self.max_validation_samples = max_validation_samples
+
+ def _snapshots(
+ self, snapshots: Sequence[MotionSnapshot]
+ ) -> tuple[MotionSnapshot, ...]:
+ values = tuple(snapshots)
+ if len(values) != self.robot.num_instances or not values:
+ raise ValueError("snapshots must cover the complete real robot batch")
+ if any(
+ not isinstance(value, MotionSnapshot)
+ or value.joint_names != self.joint_names
+ for value in values
+ ):
+ raise ValueError("snapshots must use the robot's full ordered joint names")
+ current_roots = self.robot.get_local_pose(to_matrix=True)
+ for row, snapshot in enumerate(values):
+ if not torch.allclose(
+ current_roots[row].to(snapshot.root_pose),
+ snapshot.root_pose,
+ atol=1e-6,
+ rtol=0,
+ ):
+ raise ValueError("snapshot root pose differs from its live source row")
+ return values
+
+ @staticmethod
+ def _rounds(indices: Sequence[int], candidate_ids: Sequence[int]):
+ queues: dict[int, list[int]] = {}
+ for candidate in candidate_ids:
+ queues.setdefault(indices[candidate], []).append(candidate)
+ while any(queues.values()):
+ yield {row: queue.pop(0) for row, queue in queues.items() if queue}
+
+ def _unsupported(
+ self, phases: tuple[TrajectoryPhase, ...], length: int
+ ) -> str | None:
+ if self.held_object_ids:
+ return "held-object sweep geometry is unavailable"
+ end = 0
+ for phase in phases:
+ if phase.kind != "free" or phase.start_index != end:
+ return (
+ "only fully annotated free motion has supported collision semantics"
+ )
+ end = phase.stop_index
+ return (
+ None if end == length else "every sample must have an explicit free phase"
+ )
+
+ def _obstacles(self, snapshots, obstacle_poses):
+ names = self.motion_generator.dynamic_collision_entity_ids
+ if obstacle_poses is None and names:
+ if any(
+ name not in snapshot.entity_poses
+ for name in names
+ for snapshot in snapshots
+ ):
+ raise ValueError("snapshots lack required dynamic collision poses")
+ obstacle_poses = {
+ name: torch.stack(
+ [snapshot.entity_poses[name] for snapshot in snapshots]
+ )
+ for name in names
+ }
+ if obstacle_poses is None:
+ return None
+ if set(obstacle_poses) != set(names):
+ raise ValueError(
+ "obstacle poses must match the backend's dynamic entity IDs"
+ )
+ result = {}
+ for name, pose in obstacle_poses.items():
+ if (
+ not isinstance(pose, torch.Tensor)
+ or pose.shape != (len(snapshots), 4, 4)
+ or not bool(torch.isfinite(pose).all())
+ ):
+ raise ValueError("obstacle poses must cover all real source rows")
+ result[name] = pose.detach().clone()
+ return result
+
+ def validate_qpos(
+ self,
+ batch: CandidateTrajectoryBatch,
+ snapshots: Sequence[MotionSnapshot],
+ *,
+ obstacle_poses: Mapping[str, torch.Tensor] | None = None,
+ ) -> tuple[ValidationResult, ...]:
+ """Check original candidates without replacing their positions or timing.
+
+ Return one result per candidate in input order. Unsupported geometry or
+ backend capability yields ``unavailable``; collisions and invalid paths
+ yield ``failed``. No success is inferred from padding or planning alone.
+
+ Measured qpos may be checked after execution while the live base frames
+ remain fixed. The first sample must match its snapshot within ``1e-6``.
+ Obstacle poses describe one constant world for the entire path; callers
+ must independently establish that the world stayed fixed throughout a
+ measured rollout. Neither initial nor final obstacle poses reconstruct
+ a moving obstacle's history.
+ """
+ snapshots = self._snapshots(snapshots)
+ if batch.joint_names != self.joint_names:
+ raise ValueError(
+ "candidate joint names must match the complete robot order"
+ )
+ count = len(batch.identities)
+ if not count:
+ return ()
+ if batch.source_row_indices is None:
+ raise ValueError(
+ "source_row_indices are required for environment-row planning"
+ )
+ indices = batch.source_row_indices.tolist()
+ if min(indices) < 0 or max(indices) >= len(snapshots):
+ raise ValueError("source_row_indices exceed the real robot batch")
+ obstacles = self._obstacles(snapshots, obstacle_poses)
+ device = self.robot.device
+ starts = torch.stack([snapshot.joint_positions for snapshot in snapshots]).to(
+ device
+ )
+ initial_values = self.robot.cfg.init_qpos
+ initial = torch.as_tensor(
+ [] if initial_values is None else initial_values,
+ device=device,
+ dtype=starts.dtype,
+ )
+ limits = self.robot.get_qpos_limits().to(device)
+ results = [
+ _check("not_run", "candidate has not been checked") for _ in range(count)
+ ]
+ sample_counts: dict[int, int] = {}
+ for candidate, source in enumerate(indices):
+ identity = batch.identities[candidate]
+ case = snapshots[source].scene_case
+ if (identity.scene_case_id, identity.initial_state_id) != (
+ case.scene_case_id,
+ case.initial_state_id,
+ ):
+ raise ValueError(
+ "candidate identity does not match its source snapshot"
+ )
+ length = int(batch.valid_length[candidate])
+ q = batch.positions[candidate, :length].to(device)
+ dt = batch.dt[candidate, :length]
+ unsupported = self._unsupported(batch.phases[candidate], length)
+ if unsupported:
+ results[candidate] = _check("unavailable", unsupported)
+ continue
+ if (
+ not bool(torch.isfinite(q).all())
+ or not bool(torch.isfinite(dt).all())
+ or dt[0] != 0
+ or bool((dt[1:] <= 0).any())
+ ):
+ results[candidate] = _check(
+ "failed", "non-finite positions or invalid arrival intervals"
+ )
+ continue
+ if not torch.allclose(q[0], starts[source].to(q), atol=1e-6, rtol=0):
+ results[candidate] = _check(
+ "failed", "trajectory does not start at the source initial state"
+ )
+ continue
+ if self.fixed_ids and (
+ initial.shape != starts.shape[1:]
+ or not torch.allclose(
+ q[:, self.fixed_ids],
+ starts[source, self.fixed_ids].expand(length, -1).to(q),
+ atol=1e-6,
+ rtol=0,
+ )
+ or not torch.allclose(
+ starts[source, self.fixed_ids],
+ initial[list(self.fixed_ids)],
+ atol=1e-6,
+ rtol=0,
+ )
+ ):
+ results[candidate] = _check(
+ "unavailable",
+ "changing or noninitial locked joints require a full-state collision model",
+ )
+ continue
+ if bool(((q < limits[source, :, 0]) | (q > limits[source, :, 1])).any()):
+ results[candidate] = _check("failed", "joint limits exceeded")
+ continue
+ if not self.motion_generator.supports_joint_trajectory_validation:
+ results[candidate] = _check(
+ "unavailable", "backend cannot validate exact joint samples"
+ )
+ continue
+ increments = torch.ceil(
+ (q[1:, self.joint_ids].double() - q[:-1, self.joint_ids].double())
+ .abs()
+ .amax(dim=1)
+ / self.max_joint_step
+ ).clamp_min(1)
+ if float(increments.sum()) + 1 > self.max_validation_samples:
+ results[candidate] = _check(
+ "unavailable", "collision sampling exceeds max_validation_samples"
+ )
+ continue
+ sample_counts[candidate] = int(increments.sum()) + 1
+ for round_rows in self._rounds(indices, list(sample_counts)):
+ horizon = max(sample_counts[index] for index in round_rows.values())
+ inputs = starts[:, None, self.joint_ids].expand(-1, horizon, -1).clone()
+ for row, candidate in round_rows.items():
+ length = int(batch.valid_length[candidate])
+ q = batch.positions[candidate, :length, self.joint_ids].to(device)
+ increments = (
+ torch.ceil(
+ (q[1:].double() - q[:-1].double()).abs().amax(dim=1)
+ / self.max_joint_step
+ )
+ .clamp_min(1)
+ .long()
+ )
+ cursor = 1
+ inputs[row, 0] = q[0]
+ for index, intervals in enumerate(increments.tolist()):
+ alpha = (
+ torch.arange(
+ 1, intervals + 1, device=device, dtype=torch.float64
+ )
+ / intervals
+ )
+ samples = q[index].double() + alpha[:, None] * (
+ q[index + 1].double() - q[index].double()
+ )
+ inputs[row, cursor : cursor + intervals] = samples.to(inputs)
+ cursor += intervals
+ inputs[row, cursor:] = q[-1]
+ try:
+ valid = self.motion_generator.validate_joint_trajectory(
+ inputs, control_part=self.control_part, obstacle_poses=obstacles
+ )
+ except (ValueError, RuntimeError, NotImplementedError) as error:
+ for candidate in round_rows.values():
+ results[candidate] = _check(
+ "unavailable", f"backend validation failed: {error}"
+ )
+ continue
+ for row, candidate in round_rows.items():
+ mask = valid[row, : sample_counts[candidate]]
+ results[candidate] = _check(
+ "passed" if bool(mask.all()) else "failed",
+ "sampled joint bounds, self and environment collision checks; no continuous guarantee",
+ samples=float(len(mask)),
+ max_joint_step=self.max_joint_step,
+ )
+ return tuple(results)
+
+ def plan_eef(
+ self,
+ paths: Sequence[EEFPath],
+ snapshots: Sequence[MotionSnapshot],
+ *,
+ obstacle_poses: Mapping[str, torch.Tensor] | None = None,
+ ) -> tuple[CandidateTrajectoryBatch, tuple[ValidationResult, ...]]:
+ """Solve exact EEF samples, preserve supplied branches, and check qpos.
+
+ Returned rows retain input order, phases, intervals, and source indices.
+ Failed rows hold their full initial state and must be rejected using the
+ paired validation result. IK always uses the real environment batch.
+ """
+ snapshots = self._snapshots(snapshots)
+ paths = tuple(paths)
+ if any(not isinstance(path, EEFPath) for path in paths):
+ raise TypeError("paths must contain EEFPath values")
+ if any(path.source_row_index >= len(snapshots) for path in paths):
+ raise ValueError("EEF source row exceeds the real robot batch")
+ device = self.robot.device
+ starts = torch.stack([snapshot.joint_positions for snapshot in snapshots]).to(
+ device
+ )
+ indices = [path.source_row_index for path in paths]
+ horizon = max((len(path.poses) for path in paths), default=0)
+ if horizon > self.max_validation_samples:
+ raise ValueError("EEF input horizon exceeds max_validation_samples")
+ for path in paths:
+ if path.solved_joint_targets is not None:
+ starts = starts.to(
+ dtype=torch.promote_types(
+ starts.dtype, path.solved_joint_targets.dtype
+ )
+ )
+ positions = starts.new_empty((len(paths), horizon, len(self.joint_names)))
+ timing_dtype = paths[0].dt.dtype if paths else starts.dtype
+ for path in paths[1:]:
+ timing_dtype = torch.promote_types(timing_dtype, path.dt.dtype)
+ dt = torch.zeros((len(paths), horizon), device=device, dtype=timing_dtype)
+ failures: dict[int, ValidationResult] = {}
+ for index, path in enumerate(paths):
+ positions[index] = starts[path.source_row_index]
+ dt[index, : len(path.dt)] = path.dt.to(dt)
+ unsupported = self._unsupported(path.phases, len(path.poses))
+ if unsupported:
+ failures[index] = _check("unavailable", unsupported)
+ if (
+ path.solved_joint_targets is not None
+ and path.solved_joint_targets.shape[1] != len(self.joint_ids)
+ ):
+ raise ValueError(
+ "solved_joint_targets must match the ordered control part"
+ )
+ for round_rows in self._rounds(
+ indices, [i for i in range(len(paths)) if i not in failures]
+ ):
+ seeds = starts[:, self.joint_ids].clone()
+ active = dict(round_rows)
+ for sample in range(
+ max(len(paths[index].poses) for index in active.values())
+ ):
+ expected = self.robot.compute_fk(
+ qpos=seeds, name=self.control_part, to_matrix=True
+ )
+ targets = expected.clone()
+ unsolved = []
+ supplied = {}
+ for row, candidate in active.items():
+ path = paths[candidate]
+ if sample >= len(path.poses):
+ continue
+ targets[row] = path.poses[sample].to(targets)
+ if sample and path.solved_joint_targets is None:
+ unsolved.append(row)
+ elif path.solved_joint_targets is not None:
+ supplied[row] = path.solved_joint_targets[sample].to(seeds)
+ solved = seeds.clone()
+ success = torch.ones(len(snapshots), dtype=torch.bool, device=device)
+ if unsolved:
+ ik_targets = expected.clone()
+ ik_targets[unsolved] = targets[unsolved]
+ try:
+ mask, ik = self.robot.compute_ik(
+ pose=ik_targets, name=self.control_part, joint_seed=seeds
+ )
+ mask = torch.as_tensor(
+ mask, device=device, dtype=torch.bool
+ ).reshape(-1)
+ ik = torch.as_tensor(ik, device=device, dtype=seeds.dtype)
+ if ik.shape == (len(snapshots), 1, len(self.joint_ids)):
+ ik = ik[:, 0]
+ if mask.shape != success.shape or ik.shape != seeds.shape:
+ raise ValueError(
+ "IK result has an invalid real-batch shape"
+ )
+ mask &= torch.isfinite(ik).all(dim=1)
+ success[unsolved] = mask[unsolved]
+ solved[unsolved] = torch.where(
+ mask[unsolved, None], ik[unsolved], seeds[unsolved]
+ )
+ except (ValueError, RuntimeError, TypeError) as error:
+ for row in unsolved:
+ failures[active[row]] = _check(
+ "failed", f"IK failed: {error}"
+ )
+ success[row] = False
+ for row, value in supplied.items():
+ solved[row] = value
+ actual = self.robot.compute_fk(
+ qpos=solved, name=self.control_part, to_matrix=True
+ )
+ translation = torch.linalg.vector_norm(
+ actual[:, :3, 3] - targets[:, :3, 3], dim=1
+ )
+ relative = actual[:, :3, :3].transpose(1, 2) @ targets[:, :3, :3]
+ cosine = ((relative.diagonal(dim1=1, dim2=2).sum(-1) - 1) / 2).clamp(
+ -1, 1
+ )
+ angle = torch.acos(cosine)
+ success &= (
+ torch.isfinite(actual).all(dim=2).all(dim=1)
+ & (translation <= self.fk_position_tolerance)
+ & (angle <= self.fk_rotation_tolerance)
+ )
+ for row, candidate in tuple(active.items()):
+ if sample >= len(paths[candidate].poses):
+ continue
+ if not success[row]:
+ failures.setdefault(
+ candidate,
+ _check(
+ "failed",
+ "IK failed or TCP FK residual exceeded tolerance",
+ ),
+ )
+ positions[candidate] = starts[row]
+ del active[row]
+ else:
+ positions[candidate, sample, self.joint_ids] = solved[row]
+ seeds[row] = solved[row]
+ if not active:
+ break
+ for index, path in enumerate(paths):
+ positions[index, len(path.poses) :] = positions[index, len(path.poses) - 1]
+ batch = CandidateTrajectoryBatch(
+ positions,
+ dt,
+ torch.tensor(
+ [len(path.poses) for path in paths], dtype=torch.int64, device=device
+ ),
+ tuple(path.identity for path in paths),
+ self.joint_names,
+ tuple(path.phases for path in paths),
+ source_row_indices=torch.tensor(indices, dtype=torch.int64, device=device),
+ )
+ results = list(
+ self.validate_qpos(batch, snapshots, obstacle_poses=obstacle_poses)
+ )
+ for index, failure in failures.items():
+ results[index] = failure
+ return batch, tuple(results)
diff --git a/embodichain/lab/trajectory_generation/integrations/sim.py b/embodichain/lab/trajectory_generation/integrations/sim.py
new file mode 100644
index 000000000..92a42a126
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/integrations/sim.py
@@ -0,0 +1,433 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Strict physical initial states for an exclusively owned simulation batch."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+from collections.abc import Mapping
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import TYPE_CHECKING
+
+import torch
+
+from embodichain.lab.sim.motion.expansion import (
+ ValidationCheck,
+ ValidationResult,
+)
+
+if TYPE_CHECKING:
+ from embodichain.lab.sim import SimulationManager
+ from embodichain.lab.sim.objects import Robot
+
+__all__ = ["SimInitialState", "SimInitialStateAdapter"]
+
+_ROBOT_FIELDS = (
+ "root_pose",
+ "root_linear_velocity",
+ "root_angular_velocity",
+ "qpos",
+ "qvel",
+ "target_qpos",
+ "target_qvel",
+ "qf",
+)
+_RIGID_FIELDS = ("pose", "linear_velocity", "angular_velocity")
+
+
+def _copy_fields(values: Mapping[str, torch.Tensor]) -> Mapping[str, torch.Tensor]:
+ """Own finite floating-point state independently of backend tensor buffers."""
+ copied = {}
+ for key, value in values.items():
+ if not isinstance(key, str) or not isinstance(value, torch.Tensor):
+ raise TypeError("State fields must map names to tensors.")
+ if not value.is_floating_point() or not bool(torch.isfinite(value).all()):
+ raise ValueError(
+ f"State field {key!r} must contain finite floating values."
+ )
+ copied[key] = value.detach().clone()
+ return MappingProxyType(copied)
+
+
+@dataclass(frozen=True)
+class SimInitialState:
+ """Owned physical state of every row in one fixed-base robot scene.
+
+ Obtain this value from :meth:`SimInitialStateAdapter.capture`. Pose fields
+ are local homogeneous matrices; joint fields include mimic and gripper
+ joints in ``joint_names`` order. Nested mappings are read-only and tensors
+ are cloned on construction. The adapter validates them again before use.
+ This value contains no contact solver checkpoint, pending forces, manager
+ state, or physical/visual property snapshot.
+
+ Args:
+ batch_size: Number of simulation rows.
+ robot_uid: Registered robot identity.
+ joint_names: Complete movable-joint order, including mimic joints.
+ signature: Structural signature supplied by the capturing adapter.
+ robot: Root, joint, drive-target, and joint-effort state tensors.
+ rigid_objects: Pose and velocity tensors keyed by every rigid UID.
+ """
+
+ batch_size: int
+ robot_uid: str
+ joint_names: tuple[str, ...]
+ signature: str
+ robot: Mapping[str, torch.Tensor]
+ rigid_objects: Mapping[str, Mapping[str, torch.Tensor]]
+
+ def __post_init__(self) -> None:
+ if type(self.batch_size) is not int or self.batch_size < 1:
+ raise ValueError("batch_size must be a positive integer.")
+ if not isinstance(self.robot_uid, str) or not self.robot_uid:
+ raise ValueError("robot_uid must be a nonempty string.")
+ if not isinstance(self.signature, str) or not self.signature:
+ raise ValueError("signature must be a nonempty string.")
+ names = tuple(self.joint_names)
+ if (
+ not names
+ or any(not isinstance(name, str) or not name for name in names)
+ or len(set(names)) != len(names)
+ ):
+ raise ValueError("joint_names must contain unique nonempty names.")
+ object.__setattr__(self, "joint_names", names)
+ object.__setattr__(self, "robot", _copy_fields(self.robot))
+ rigid = {}
+ for uid, fields in self.rigid_objects.items():
+ if not isinstance(uid, str) or not uid:
+ raise ValueError("Rigid object UIDs must be nonempty strings.")
+ rigid[uid] = _copy_fields(fields)
+ object.__setattr__(self, "rigid_objects", MappingProxyType(rigid))
+
+
+class SimInitialStateAdapter:
+ """Capture, restore, and verify one entire simulation batch.
+
+ Supports one config-declared fixed-base robot and all ordinary rigid
+ objects. Additional articulations, object groups, deformables, and rigid
+ constraints are rejected. Asset-owned USD articulation properties are
+ rejected because ``cfg.fix_base`` is not authoritative for those assets.
+ The host owns exclusive batch access, deterministic task initialization,
+ fixed scene properties, settling, observation refresh, and runtime epochs.
+ Initial states must have no pending external forces or contact constraints.
+
+ .. attention::
+ Restoring the robot root calls the existing articulation pose setter,
+ which can advance the whole world by 1 ms. Root restoration therefore
+ precedes all remaining writes. No Gym step, reset event, or recording
+ callback is invoked. The host must settle and verify before execution.
+
+ Args:
+ sim: Simulation manager whose complete batch is exclusively owned.
+ robot: The only robot registered in ``sim``.
+ atol: Absolute element-wise tolerance for physical verification and
+ zero root/non-dynamic velocities. Relative tolerance is zero.
+ tolerances_profile_id: Trusted integration ID naming this tolerance policy.
+ """
+
+ def __init__(
+ self,
+ sim: SimulationManager,
+ robot: Robot,
+ *,
+ atol: float = 1e-5,
+ tolerances_profile_id: str = "fixed_scene_tolerances",
+ ) -> None:
+ if isinstance(atol, bool) or not isinstance(atol, (int, float)):
+ raise TypeError("atol must be a finite nonnegative number.")
+ if not math.isfinite(atol) or atol < 0:
+ raise ValueError("atol must be a finite nonnegative number.")
+ self.sim = sim
+ self.robot = robot
+ self.atol = float(atol)
+ if (
+ not isinstance(tolerances_profile_id, str)
+ or not tolerances_profile_id.strip()
+ ):
+ raise ValueError("tolerances_profile_id must be a nonempty string.")
+ self.tolerances_profile_id = tolerances_profile_id
+ self._check_scene()
+
+ def _check_scene(self) -> None:
+ """Reject unsupported or incompletely represented scenes before writes."""
+ sim, robot = self.sim, self.robot
+ if sim._robots != {robot.uid: robot}:
+ raise ValueError(
+ "Initial states require exactly the supplied registered robot."
+ )
+ if not robot.cfg.fix_base or getattr(robot.cfg, "use_usd_properties", False):
+ raise ValueError("Initial states require a config-owned fixed-base robot.")
+ for name in (
+ "_articulations",
+ "_rigid_object_groups",
+ "_soft_objects",
+ "_cloth_objects",
+ "_constraints",
+ ):
+ if getattr(sim, name):
+ raise ValueError(f"Initial state restoration does not support {name}.")
+ if robot.num_instances != sim.num_envs:
+ raise ValueError("The robot must cover the entire simulation batch.")
+ names = tuple(robot.joint_names)
+ if len(names) != robot.dof or len(set(names)) != len(names) or not names:
+ raise ValueError(
+ "Robot joint_names must identify every movable joint once."
+ )
+ for uid, obj in sim._rigid_objects.items():
+ if uid != obj.uid or obj.num_instances != sim.num_envs:
+ raise ValueError(
+ f"Rigid object {uid!r} does not match its full-batch registration."
+ )
+
+ def signature(self) -> str:
+ """Return a stable topology and control-part signature of the batch.
+
+ Includes row count, robot identity, complete joint order, named control
+ parts, and rigid object identities/body modes. Current poses and fixed
+ physical/visual properties are excluded; the host's preparation profile
+ must separately identify and verify those fixed case conditions.
+ """
+ self._check_scene()
+ payload = {
+ "batch_size": self.sim.num_envs,
+ "robot_uid": self.robot.uid,
+ "joint_names": list(self.robot.joint_names),
+ "control_parts": self.robot.control_parts or {},
+ "rigid_objects": [
+ (uid, bool(obj.is_static), bool(obj.is_non_dynamic))
+ for uid, obj in sorted(self.sim._rigid_objects.items())
+ ],
+ }
+ encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
+ return hashlib.sha256(encoded).hexdigest()
+
+ def _read(self) -> SimInitialState:
+ """Read every supported field without invoking simulation updates."""
+ signature = self.signature()
+ robot = self.robot
+ root_index = list(robot.body_data.link_names).index(robot.root_link_name)
+ root_velocity = robot.body_data.body_link_vel[:, root_index]
+ fields = {
+ "root_pose": robot.get_local_pose(to_matrix=True),
+ "root_linear_velocity": root_velocity[:, :3],
+ "root_angular_velocity": root_velocity[:, 3:],
+ "qpos": robot.get_qpos(),
+ "qvel": robot.get_qvel(),
+ "target_qpos": robot.get_qpos(target=True),
+ "target_qvel": robot.get_qvel(target=True),
+ "qf": robot.get_qf(),
+ }
+ rigid = {}
+ for uid, obj in self.sim._rigid_objects.items():
+ body_state = obj.body_state
+ rigid[uid] = {
+ "pose": obj.get_local_pose(to_matrix=True),
+ "linear_velocity": body_state[:, 7:10],
+ "angular_velocity": body_state[:, 10:13],
+ }
+ return SimInitialState(
+ batch_size=self.sim.num_envs,
+ robot_uid=robot.uid,
+ joint_names=tuple(robot.joint_names),
+ signature=signature,
+ robot=fields,
+ rigid_objects=rigid,
+ )
+
+ def _check_fields(
+ self,
+ fields: Mapping[str, torch.Tensor],
+ shapes: Mapping[str, tuple[int, ...]],
+ label: str,
+ ) -> None:
+ if set(fields) != set(shapes):
+ raise ValueError(
+ f"{label} fields do not match the complete initial state schema."
+ )
+ for name, shape in shapes.items():
+ value = fields[name]
+ if (
+ not isinstance(value, torch.Tensor)
+ or value.shape != shape
+ or not value.is_floating_point()
+ or not bool(torch.isfinite(value).all())
+ ):
+ raise ValueError(
+ f"{label}.{name} requires finite floating values of shape {shape}."
+ )
+ if name.endswith("pose"):
+ pose = value.detach().to(dtype=torch.float64, device="cpu")
+ rotation = pose[:, :3, :3]
+ if (
+ not torch.allclose(
+ pose[:, 3],
+ torch.tensor([0.0, 0.0, 0.0, 1.0], dtype=pose.dtype).expand(
+ shape[0], -1
+ ),
+ atol=1e-5,
+ rtol=0,
+ )
+ or not torch.allclose(
+ rotation.mT @ rotation,
+ torch.eye(3, dtype=pose.dtype).expand(shape[0], -1, -1),
+ atol=1e-5,
+ rtol=0,
+ )
+ or not torch.allclose(
+ torch.linalg.det(rotation),
+ torch.ones(shape[0], dtype=pose.dtype),
+ atol=1e-5,
+ rtol=0,
+ )
+ ):
+ raise ValueError(f"{label}.{name} must contain rigid SE(3) poses.")
+
+ def _check_state(self, state: SimInitialState) -> None:
+ """Validate the full snapshot before any physical mutation."""
+ if not isinstance(state, SimInitialState):
+ raise TypeError("state must be a SimInitialState.")
+ if (
+ state.signature != self.signature()
+ or state.batch_size != self.sim.num_envs
+ or state.robot_uid != self.robot.uid
+ or state.joint_names != tuple(self.robot.joint_names)
+ or set(state.rigid_objects) != set(self.sim._rigid_objects)
+ ):
+ raise ValueError(
+ "Initial state topology, joint order, or controller signature changed."
+ )
+ batch, dof = state.batch_size, self.robot.dof
+ shapes = {key: (batch, dof) for key in _ROBOT_FIELDS}
+ shapes.update(
+ root_pose=(batch, 4, 4),
+ root_linear_velocity=(batch, 3),
+ root_angular_velocity=(batch, 3),
+ )
+ self._check_fields(state.robot, shapes, "robot")
+ for key in ("root_linear_velocity", "root_angular_velocity"):
+ if bool((state.robot[key].abs() > self.atol).any()):
+ raise ValueError(
+ "A fixed-base initial state must have zero root velocity."
+ )
+ limits = (
+ self.robot.get_qpos_limits().detach().to(device="cpu", dtype=torch.float64)
+ )
+ if (
+ limits.shape != (batch, dof, 2)
+ or bool(torch.isnan(limits).any())
+ or bool((limits[..., 0] > limits[..., 1]).any())
+ ):
+ raise ValueError("Robot joint limits do not cover the full ordered batch.")
+ for key in ("qpos", "target_qpos"):
+ value = state.robot[key].detach().to(device="cpu", dtype=torch.float64)
+ if bool(((value < limits[..., 0]) | (value > limits[..., 1])).any()):
+ raise ValueError(
+ f"robot.{key} is outside joint limits and would be clamped."
+ )
+ for uid, fields in state.rigid_objects.items():
+ shapes = {
+ "pose": (batch, 4, 4),
+ "linear_velocity": (batch, 3),
+ "angular_velocity": (batch, 3),
+ }
+ self._check_fields(fields, shapes, f"rigid_objects.{uid}")
+ if self.sim._rigid_objects[uid].is_non_dynamic and any(
+ bool((fields[key].abs() > self.atol).any()) for key in _RIGID_FIELDS[1:]
+ ):
+ raise ValueError(
+ f"Non-dynamic object {uid!r} must have zero initial velocity."
+ )
+
+ def capture(self) -> SimInitialState:
+ """Own a validated physical snapshot after host preparation and settling."""
+ state = self._read()
+ self._check_state(state)
+ return state
+
+ def restore(self, state: SimInitialState) -> None:
+ """Restore all rows after complete preflight, without layout randomization.
+
+ Resets pending rigid-body forces/torques, then restores captured
+ velocities. Fixed-base root velocities must be zero. This method does
+ not restore arbitrary contact states or hide write/backend failures.
+ The caller must invalidate its epoch before entry and settle/verify
+ after completion; an exception can leave partially restored state.
+ """
+ self._check_state(state)
+ robot = self.robot
+ fields = {
+ key: value.detach().to(device=robot.device, dtype=torch.float32).clone()
+ for key, value in state.robot.items()
+ }
+ robot.set_local_pose(fields["root_pose"])
+ robot.set_qpos(fields["qpos"], target=False)
+ robot.set_qvel(fields["qvel"], target=False)
+ robot.set_qpos(fields["target_qpos"], target=True)
+ robot.set_qvel(fields["target_qvel"], target=True)
+ robot.set_qf(fields["qf"])
+ for uid, source in state.rigid_objects.items():
+ obj = self.sim._rigid_objects[uid]
+ fields = {
+ key: value.detach().to(device=obj.device, dtype=torch.float32).clone()
+ for key, value in source.items()
+ }
+ obj.set_local_pose(fields["pose"])
+ if not obj.is_non_dynamic:
+ obj.clear_dynamics()
+ obj.set_velocity(
+ lin_vel=fields["linear_velocity"],
+ ang_vel=fields["angular_velocity"],
+ )
+
+ def verify(self, state: SimInitialState) -> ValidationResult:
+ """Check every physical field after settling, with zero relative tolerance.
+
+ Structural incompatibility or a malformed snapshot raises before
+ reading physical state. Live nonfinite state and numerical drift
+ return a failed ``initial_state`` check, never an accepted result.
+ """
+ self._check_state(state)
+ try:
+ actual = self._read()
+ except ValueError as error:
+ return ValidationResult(
+ (ValidationCheck("initial_state", "failed", str(error)),)
+ )
+ pairs = [("robot", state.robot, actual.robot)]
+ pairs.extend(
+ (f"rigid_objects.{uid}", fields, actual.rigid_objects[uid])
+ for uid, fields in state.rigid_objects.items()
+ )
+ for label, expected, observed in pairs:
+ for key, value in expected.items():
+ current = observed[key].to(device=value.device, dtype=value.dtype)
+ if current.shape != value.shape or not torch.allclose(
+ current, value, atol=self.atol, rtol=0
+ ):
+ return ValidationResult(
+ (
+ ValidationCheck(
+ "initial_state",
+ "failed",
+ f"{label}.{key} differs from the specified initial state.",
+ ),
+ )
+ )
+ return ValidationResult((ValidationCheck("initial_state", "passed"),))
diff --git a/embodichain/lab/trajectory_generation/runner.py b/embodichain/lab/trajectory_generation/runner.py
new file mode 100644
index 000000000..45131ee87
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/runner.py
@@ -0,0 +1,743 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Synchronous fixed-scene qpos collection through qualified motion/host ports."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping, Sequence
+from dataclasses import MISSING, fields, is_dataclass, replace
+import hashlib
+import json
+import math
+import os
+from pathlib import Path
+import tempfile
+import time
+from typing import TYPE_CHECKING
+
+import torch
+
+from embodichain import __version__
+from embodichain.lab.sim.motion.expansion import (
+ CandidateTrajectoryBatch,
+ CommitReceipt,
+ ExpertEpisode,
+ GenerationSession,
+ SceneCase,
+ TrajectoryGenerationJobCfg,
+ TrajectoryTemplate,
+ ValidationCheck,
+ ValidationResult,
+ joint_residual,
+ retime,
+ validate_motion_limits,
+)
+from embodichain.utils import configclass
+
+if TYPE_CHECKING:
+ from .execution import QposRolloutExecutor
+ from .initial_state import FixedSceneHost
+ from .integrations.planning import EnvRowMotionPlanner
+ from .sinks import LeRobotEpisodeSink
+
+from .integrations.contact import PickUpMotionValidator
+
+__all__ = ["MotionLimitsProfile", "GenerationRunner"]
+
+
+@configclass
+class MotionLimitsProfile:
+ """Trusted full-joint velocity and acceleration limits.
+
+ Args:
+ velocity_limits: Positive finite speed limits in joint units per second.
+ acceleration_limits: Positive finite acceleration limits in joint units
+ per second squared, in the same full-joint order.
+ profile_id: Registered policy ID used by the generation job.
+ """
+
+ velocity_limits: torch.Tensor = MISSING
+ acceleration_limits: torch.Tensor = MISSING
+ profile_id: str = "robot_execution_limits"
+
+ def __post_init__(self) -> None:
+ if not isinstance(self.profile_id, str) or not self.profile_id.strip():
+ raise ValueError("motion limits profile_id must be a nonempty string")
+ for name in ("velocity_limits", "acceleration_limits"):
+ value = getattr(self, name)
+ if (
+ not isinstance(value, torch.Tensor)
+ or value.ndim != 1
+ or not value.numel()
+ or not value.is_floating_point()
+ or not bool(torch.isfinite(value).all())
+ or not bool((value > 0).all())
+ ):
+ raise ValueError(f"{name} must be a positive finite floating vector")
+ setattr(
+ self, name, value.detach().to(device="cpu", dtype=torch.float64).clone()
+ )
+ if self.velocity_limits.shape != self.acceleration_limits.shape:
+ raise ValueError("motion limit vectors must use the same joint order")
+
+
+def _plain(value: object) -> object:
+ if isinstance(value, Mapping):
+ return {key: _plain(item) for key, item in value.items()}
+ if is_dataclass(value):
+ return {
+ field.name: _plain(getattr(value, field.name)) for field in fields(value)
+ }
+ if isinstance(value, (tuple, list)):
+ return [_plain(item) for item in value]
+ if isinstance(value, torch.Tensor):
+ return value.detach().cpu().tolist()
+ return value
+
+
+class GenerationRunner:
+ """Collect qualified qpos variants with one synchronous owner.
+
+ The caller supplies explicit templates, a qualified initial-state profile,
+ motion collision model, actual-observation encoder, and task validator.
+ This runner supports handwritten free-motion sources and offline atomic
+ PickUp exports in ``env_rows`` / ``full_batch`` / synchronous LeRobot mode.
+ PickUp requires one shared full-state/contact validator for planning and
+ pure-sim execution. AtomicActionRuntime and contact-aware Gym execution
+ remain separate integrations.
+
+ Planning failures do not reset physics. Ready candidates reserve episode
+ memory and target quota before restoration/execution. Only actual accepted
+ rollouts reach the sink, and only confirmed receipts increase coverage.
+ The runner is single-use and closes the supplied host and sink on exit.
+ ``generation_report.json`` preserves resolved settings, audit, and outcome.
+
+ Args:
+ cfg: Strict job settings matching the supplied integration IDs.
+ host: Exclusive owner of the full simulator/Gym batch.
+ planner: Environment-row motion validation adapter for this robot.
+ executor: Concrete qpos rollout adapter for this host.
+ sink: Synchronous, local LeRobot writer with readback confirmation.
+ motion_limits: Trusted full-joint speed/acceleration policy.
+ max_write_retries: Extra synchronous submissions after a failed receipt.
+ clock: Monotonic clock used for the job wall-time budget.
+ """
+
+ def __init__(
+ self,
+ cfg: TrajectoryGenerationJobCfg,
+ host: FixedSceneHost,
+ planner: EnvRowMotionPlanner | PickUpMotionValidator,
+ executor: QposRolloutExecutor,
+ sink: LeRobotEpisodeSink,
+ *,
+ motion_limits: MotionLimitsProfile,
+ max_write_retries: int = 1,
+ clock: Callable[[], float] = time.monotonic,
+ ) -> None:
+ self.cfg = TrajectoryGenerationJobCfg.from_mapping(cfg.to_dict())
+ self.host, self.planner, self.executor, self.sink = (
+ host,
+ planner,
+ executor,
+ sink,
+ )
+ self.motion_limits = motion_limits.copy()
+ self.clock = clock
+ self._ran = False
+ if type(max_write_retries) is not int or max_write_retries < 0:
+ raise ValueError("max_write_retries must be a nonnegative integer")
+ self.max_write_retries = max_write_retries
+ contact_planner = isinstance(planner, PickUpMotionValidator)
+ if cfg.source.kind != "handwritten" and not contact_planner:
+ raise ValueError("this runner requires a handwritten qpos source")
+ if contact_planner and executor.contact_validator is not planner:
+ raise ValueError(
+ "PickUp planning and substep validation must share one validator"
+ )
+ if (
+ not contact_planner
+ and getattr(executor, "contact_validator", None) is not None
+ ):
+ raise ValueError(
+ "Contact execution requires complete PickUp planning validation"
+ )
+ if contact_planner and cfg.augmentation.factors.timing.enabled:
+ raise ValueError("PickUp contact timing remains fixed")
+ if planner.robot is not host.adapter.robot or executor.host is not host:
+ raise ValueError("planner and executor must use the same owned host robot")
+ expected = {
+ "prepare profile": (cfg.reset.prepare_profile_id, host.profile.profile_id),
+ "initial-state tolerances": (
+ cfg.reset.initial_state_tolerances_profile_id,
+ host.adapter.tolerances_profile_id,
+ ),
+ "motion limits": (
+ cfg.validation.motion_limits_profile_id,
+ motion_limits.profile_id,
+ ),
+ "validator": (cfg.validation.validator_id, executor.validator_id),
+ "validation profile": (
+ cfg.validation.profile_id,
+ executor.validation_profile_id,
+ ),
+ "sink": (cfg.persistence.sink, "lerobot"),
+ }
+ for label, (requested, actual) in expected.items():
+ if requested != actual:
+ raise ValueError(
+ f"{label} ID {requested!r} does not match supplied {actual!r}"
+ )
+ if len(motion_limits.velocity_limits) != len(host.adapter.robot.joint_names):
+ raise ValueError("motion limits must cover every robot joint")
+ if not math.isclose(
+ executor.control_dt * sink.fps, 1, rel_tol=1e-7, abs_tol=1e-9
+ ):
+ raise ValueError("sink fps must match the authoritative control period")
+ cfg.validate_capabilities(
+ source_ids=(cfg.source.source_id,),
+ validator_ids=(executor.validator_id,),
+ profile_ids=(
+ host.profile.profile_id,
+ host.adapter.tolerances_profile_id,
+ motion_limits.profile_id,
+ executor.validation_profile_id,
+ ),
+ sink_ids=("lerobot",),
+ operators=("joint_residual", "retime"),
+ )
+ self._episode_budget = executor.max_episode_bytes
+ if self._episode_budget > min(
+ sink.max_episode_bytes, cfg.persistence.pending_max_bytes
+ ):
+ raise ValueError(
+ "executor max_episode_bytes must fit both sink and pending byte limits"
+ )
+
+ def _validate_templates(self, cases, templates) -> None:
+ if len(cases) != self.host.adapter.sim.num_envs or len(templates) != len(cases):
+ raise ValueError("provide one case and template per physical row")
+ keys = [(case.scene_case_id, case.initial_state_id) for case in cases]
+ if len(set(keys)) != len(keys):
+ raise ValueError("per_env_case requires distinct case/initial-state pairs")
+ for template in templates:
+ if not isinstance(template, TrajectoryTemplate):
+ raise TypeError("templates must be explicit TrajectoryTemplate values")
+ if (
+ template.source_id != self.cfg.source.source_id
+ or template.template_id != self.cfg.source.template_id
+ or template.validator_id != self.cfg.validation.validator_id
+ ):
+ raise ValueError(
+ "template source/template/validator IDs do not match the job"
+ )
+ if template.joint_names != tuple(self.host.adapter.robot.joint_names):
+ raise ValueError(
+ "template joint names must match the complete robot order"
+ )
+ if len(template.positions) < 2:
+ raise ValueError("an executable template needs at least two samples")
+ factors = self.cfg.augmentation.factors
+ needed = []
+ if factors.spatial.enabled:
+ needed.append(factors.spatial.method)
+ if factors.timing.enabled:
+ needed.append("retime")
+ if set(needed) - set(template.allowed_operators):
+ raise ValueError(
+ "job enables operators not allowed by the source template"
+ )
+ if not factors.timing.enabled and not torch.allclose(
+ template.dt[1:].double(),
+ torch.full_like(template.dt[1:].double(), self.executor.control_dt),
+ atol=1e-8,
+ rtol=0,
+ ):
+ raise ValueError(
+ "template timing must match the host clock or permit retime"
+ )
+
+ def _variant(self, template, limits, generator):
+ factors = self.cfg.augmentation.factors
+ value = template
+ metadata = {}
+ if factors.spatial.enabled:
+ value = joint_residual(
+ value,
+ joint_limits=limits,
+ normalized_scale=factors.spatial.joint_offset_scale,
+ generator=generator,
+ )
+ metadata["joint_offset_scale"] = factors.spatial.joint_offset_scale
+ if factors.timing.enabled:
+ scales = factors.timing.duration_scales
+ scale = scales[int(torch.randint(len(scales), (), generator=generator))]
+ # Account for positions and arrival intervals before allocating retime output.
+ max_samples = self.cfg.execution.ready_max_bytes // (
+ value.positions.element_size() * value.positions.shape[1]
+ + value.dt.element_size()
+ )
+ value = retime(
+ value,
+ duration_scale=scale,
+ control_dt=self.executor.control_dt,
+ max_samples=max_samples,
+ )
+ metadata["duration_scale"] = scale
+ return value, metadata
+
+ def _quality(self, positions, times, reference, limits) -> ValidationResult:
+ ranges = (limits[:, 1] - limits[:, 0]).to(
+ device=positions.device, dtype=torch.float64
+ )
+ actual_length = float(
+ torch.linalg.vector_norm(
+ positions.double().diff(dim=0) / ranges, dim=1
+ ).sum()
+ )
+ reference_length = float(
+ torch.linalg.vector_norm(
+ reference.positions.to(positions.device).double().diff(dim=0) / ranges,
+ dim=1,
+ ).sum()
+ )
+ duration = float(times[-1] - times[0])
+ reference_duration = float(reference.dt.double().sum())
+ length_ok = (
+ actual_length
+ <= reference_length * self.cfg.validation.path_length_ratio_max + 1e-9
+ )
+ duration_ok = (
+ duration
+ <= reference_duration * self.cfg.validation.duration_ratio_max + 1e-9
+ )
+ metrics = {
+ "path_length": actual_length,
+ "reference_path_length": reference_length,
+ "duration_s": duration,
+ "reference_duration_s": reference_duration,
+ }
+ return ValidationResult(
+ (
+ ValidationCheck(
+ "motion_quality",
+ "passed" if length_ok and duration_ok else "failed",
+ "actual motion compared with the same-case reference",
+ metrics,
+ ),
+ )
+ )
+
+ def _submit(self, session, episode) -> None:
+ for submission_id in range(self.max_write_retries + 1):
+ if submission_id:
+ episode, submission_id = session.retry_write(episode.commit_id)
+ try:
+ receipt = self.sink.submit(episode, submission_id=submission_id)
+ except Exception as error:
+ # This concrete synchronous sink raises input errors before any
+ # writes; all write/readback failures instead return a receipt.
+ session.apply_receipt(
+ CommitReceipt(
+ episode.episode_id,
+ episode.identity.candidate_id,
+ episode.identity.attempt_id,
+ "unsubmitted",
+ episode.commit_id,
+ episode.identity.scene_case_id,
+ confirmed=False,
+ error=f"{type(error).__name__}: {error}",
+ submission_id=submission_id,
+ )
+ )
+ raise
+ session.apply_receipt(receipt)
+ if receipt.confirmed:
+ return
+ raise RuntimeError(f"Episode persistence failed: {receipt.error}")
+
+ def _write_report(self, report) -> None:
+ path = self.sink.root / "generation_report.json"
+ payload = json.dumps(
+ _plain(report),
+ ensure_ascii=False,
+ sort_keys=True,
+ indent=2,
+ allow_nan=False,
+ ).encode()
+ temporary = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ dir=path.parent, prefix=".generation-report.", delete=False
+ ) as stream:
+ temporary = stream.name
+ stream.write(payload)
+ os.replace(temporary, path)
+ finally:
+ if temporary is not None:
+ Path(temporary).unlink(missing_ok=True)
+
+ def run(
+ self,
+ cases: Sequence[SceneCase],
+ templates: Sequence[TrajectoryTemplate],
+ *,
+ should_stop: Callable[[], bool] | None = None,
+ ) -> Mapping[str, object]:
+ """Run one bounded collection job and freeze its final audit.
+
+ Args:
+ cases: One caller-provided fixed case per simulator row.
+ templates: Explicit full-joint references in matching physical row order.
+ should_stop: Optional cancellation predicate checked between commands.
+
+ Returns:
+ Counters, coverage, audit, resolved configuration, and ``target_reached``.
+ Exhausting a budget returns ``target_reached=False`` and a stop reason.
+
+ Raises:
+ ValueError: If source or integration contracts are incompatible.
+ RuntimeError: If required validation, restoration, or persistence fails.
+ """
+ if self._ran:
+ raise RuntimeError("GenerationRunner is single-use")
+ self._ran = True
+ session = GenerationSession(self.cfg, clock=self.clock)
+ error = None
+ cancelled = False
+ cases, templates = tuple(cases), tuple(templates)
+ try:
+ self._validate_templates(cases, templates)
+ binding = self.host.acquire_case(cases)
+ snapshots = self.host.snapshots(binding)
+ if isinstance(self.planner, PickUpMotionValidator):
+ world_ids = updated_ids = set(self.planner.collision_world_entity_ids)
+ else:
+ world_ids = set(
+ self.planner.motion_generator.collision_world_entity_ids
+ )
+ updated_ids = set(
+ self.planner.motion_generator.dynamic_collision_entity_ids
+ )
+ if any(
+ set(snapshot.entity_poses) - (world_ids & updated_ids)
+ for snapshot in snapshots
+ ):
+ raise ValueError(
+ "generation collision world must update every rigid object under its physical UID"
+ )
+ limits = self.host.adapter.robot.get_qpos_limits().detach().clone()
+ for case, template, row_limits in zip(cases, templates, limits):
+ session.register_case(
+ case, row_limits, joint_names=template.joint_names
+ )
+ plan_checks = {}
+ reference_by_candidate = {}
+ ready_by_row = set()
+ row_offset = 0
+ needs_restore = False
+
+ def stop() -> bool:
+ return bool(
+ session.stop_reason or (should_stop is not None and should_stop())
+ )
+
+ while not stop():
+ order = [
+ (row_offset + offset) % len(cases) for offset in range(len(cases))
+ ]
+ for row in order:
+ if (
+ row in ready_by_row
+ or stop()
+ or session.snapshot()["counts"]["ready"]
+ >= self.cfg.execution.ready_high_watermark
+ ):
+ continue
+ if (
+ session.snapshot()["counts"]["proposed"]
+ >= self.cfg.collection.max_proposals
+ ):
+ break
+ case, reference = cases[row], templates[row]
+ family = None
+ if not self.cfg.augmentation.factors.spatial.enabled:
+ family = (
+ "geometry_"
+ + hashlib.sha256(
+ json.dumps(
+ (
+ case.scene_case_id,
+ case.initial_state_id,
+ reference.source_id,
+ reference.source_revision,
+ reference.template_id,
+ )
+ ).encode()
+ ).hexdigest()
+ )
+ identity, generator = session.propose(
+ case.scene_case_id,
+ case.initial_state_id,
+ source_id=reference.source_id,
+ source_revision=reference.source_revision,
+ template_id=reference.template_id,
+ operator_id="configured_factors",
+ geometry_family_id=family,
+ )
+ try:
+ variant, factors = self._variant(
+ reference, limits[row], generator
+ )
+ batch = CandidateTrajectoryBatch(
+ variant.positions.unsqueeze(0),
+ variant.dt.unsqueeze(0),
+ torch.tensor([len(variant.positions)], dtype=torch.int64),
+ (identity,),
+ variant.joint_names,
+ (variant.phases,),
+ (factors,),
+ torch.tensor([row], dtype=torch.int64),
+ )
+ collision = self.planner.validate_qpos(batch, snapshots)[0]
+ dynamics = validate_motion_limits(
+ variant,
+ velocity_limits=self.motion_limits.velocity_limits,
+ acceleration_limits=self.motion_limits.acceleration_limits,
+ )
+ validation = ValidationResult(
+ (*collision.checks, *dynamics.checks)
+ )
+ try:
+ session.add_planned(batch, validation)
+ except ValueError:
+ if any(
+ check.status in {"unavailable", "not_run"}
+ for check in validation.checks
+ ):
+ raise RuntimeError(
+ f"Required planning capability is unavailable: {validation.checks}"
+ ) from None
+ raise
+ except (ValueError, BufferError) as failure:
+ session.release(
+ identity, reason=f"planning rejected: {failure}"
+ )
+ continue
+ plan_checks[identity.candidate_id] = validation
+ reference_by_candidate[identity.candidate_id] = reference
+ ready_by_row.add(row)
+ if stop():
+ break
+ selected = [None] * len(cases)
+ for row in order:
+ case = cases[row]
+ selected[row] = session.take_ready(
+ case.scene_case_id,
+ case.initial_state_id,
+ episode_byte_budget=self._episode_budget,
+ )
+ if selected[row] is not None:
+ ready_by_row.discard(row)
+ assigned = [
+ candidate for candidate in selected if candidate is not None
+ ]
+ if not assigned:
+ if session.stop_reason:
+ break
+ if (
+ session.snapshot()["counts"]["proposed"]
+ >= self.cfg.collection.max_proposals
+ ):
+ break
+ continue
+ if stop():
+ break
+ if needs_restore:
+ binding = self.host.restore_initial()
+ verified = self.host.verify_initial(binding)
+ if not verified.accepted:
+ raise RuntimeError(f"Initial state mismatch: {verified.checks}")
+ if stop():
+ break
+ ids = {
+ candidate.identities[0].candidate_id: session.episode_ids(
+ candidate.identities[0]
+ )
+ for candidate in assigned
+ }
+ episodes = self.executor.execute(
+ binding,
+ selected,
+ ids,
+ on_started=session.mark_rollout_started,
+ should_stop=stop,
+ )
+ needs_restore = True
+ if len(episodes) != len(cases):
+ raise ValueError(
+ "executor results must preserve physical row order"
+ )
+ for row, (candidate, episode) in enumerate(zip(selected, episodes)):
+ if candidate is None:
+ if episode is not None:
+ raise ValueError(
+ "executor produced evidence for an inactive row"
+ )
+ continue
+ identity = candidate.identities[0]
+ if episode is None:
+ plan_checks.pop(identity.candidate_id, None)
+ reference_by_candidate.pop(identity.candidate_id, None)
+ session.release(
+ identity,
+ reason=self.executor.last_failures.get(
+ identity.candidate_id,
+ "execution produced no complete evidence",
+ ),
+ )
+ continue
+ if episode.identity != identity:
+ raise ValueError(
+ "executor evidence belongs to a different candidate"
+ )
+ reference = reference_by_candidate.pop(identity.candidate_id)
+ qpos = episode.observations["joint_positions"]
+ quality = self._quality(
+ qpos, episode.timestamps, reference, limits[row]
+ )
+ measured = replace(
+ reference,
+ positions=qpos,
+ dt=torch.cat(
+ (episode.timestamps.new_zeros(1), episode.timestamps.diff())
+ ),
+ phases=episode.phases,
+ )
+ dynamic = validate_motion_limits(
+ measured,
+ velocity_limits=self.motion_limits.velocity_limits,
+ acceleration_limits=self.motion_limits.acceleration_limits,
+ )
+ # Revalidate measured joints. Free motion uses the captured
+ # fixed world; PickUp uses actual target poses, with root
+ # and all other objects verified by the executor.
+ actual_snapshots = list(snapshots)
+ actual_snapshots[row] = replace(
+ snapshots[row], joint_positions=qpos[0]
+ )
+ actual_batch = CandidateTrajectoryBatch(
+ qpos.unsqueeze(0),
+ measured.dt.unsqueeze(0),
+ torch.tensor([len(qpos)], dtype=torch.int64),
+ (identity,),
+ candidate.joint_names,
+ (episode.phases,),
+ source_row_indices=torch.tensor([row], dtype=torch.int64),
+ )
+ if isinstance(self.planner, PickUpMotionValidator):
+ actual_collision = self.planner.validate_episode(
+ episode,
+ actual_snapshots[row],
+ row=row,
+ )
+ else:
+ actual_collision = self.planner.validate_qpos(
+ actual_batch,
+ actual_snapshots,
+ )[0]
+ all_checks = (
+ *tuple(
+ replace(check, check_id="planned_" + check.check_id)
+ for check in plan_checks.pop(identity.candidate_id).checks
+ ),
+ *actual_collision.checks,
+ *episode.validation.checks,
+ *quality.checks,
+ *tuple(
+ ValidationCheck(
+ "actual_" + check.check_id,
+ check.status,
+ check.detail,
+ check.metrics,
+ )
+ for check in dynamic.checks
+ ),
+ )
+ accepted = replace(episode, validation=ValidationResult(all_checks))
+ if session.accept_episode(accepted):
+ self._submit(session, accepted)
+ row_offset = (order[0] + 1) % len(cases)
+ cancelled = (
+ should_stop is not None
+ and should_stop()
+ and session.stop_reason is None
+ )
+ except BaseException as failure:
+ error = failure
+ finally:
+ try:
+ for receipt in self.sink.drain():
+ session.apply_receipt(receipt)
+ except BaseException as failure:
+ if error is None:
+ error = failure
+ for identity, state, _ in session.snapshot()["audit"]:
+ if state in {
+ "proposed",
+ "ready",
+ "assigned",
+ "running",
+ "write_failed",
+ }:
+ session.release(
+ identity,
+ reason="job stopped" if error is None else "job failed",
+ )
+ for close in (self.sink.close, self.host.close):
+ try:
+ close()
+ except BaseException as failure:
+ if error is None:
+ error = failure
+ report = {
+ **dict(session.snapshot()),
+ "configuration": self.cfg.to_dict(),
+ "motion_limits": self.motion_limits,
+ "cases": cases,
+ "version": __version__,
+ "target_reached": session.snapshot()["counts"]["committed"]
+ >= self.cfg.collection.target_committed_episodes,
+ "cancelled": cancelled,
+ "error": None if error is None else f"{type(error).__name__}: {error}",
+ }
+ try:
+ self._write_report(report)
+ except BaseException as failure:
+ if error is None:
+ error = failure
+ else:
+ add_note = getattr(error, "add_note", None)
+ if callable(add_note):
+ add_note(
+ f"Writing generation_report.json also failed: {failure}"
+ )
+ else:
+ error.__context__ = failure
+ if error is not None:
+ raise error
+ return report
diff --git a/embodichain/lab/trajectory_generation/sinks.py b/embodichain/lab/trajectory_generation/sinks.py
new file mode 100644
index 000000000..0db0623fd
--- /dev/null
+++ b/embodichain/lab/trajectory_generation/sinks.py
@@ -0,0 +1,553 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Synchronous, host-independent persistence of accepted expert episodes."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+import os
+import re
+import shutil
+import tempfile
+from collections.abc import Mapping
+from dataclasses import asdict
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import torch
+
+from embodichain.lab.sim.motion.expansion import (
+ CommitReceipt,
+ ExpertEpisode,
+)
+
+__all__ = ["LeRobotEpisodeSink"]
+
+_NUMERIC_DTYPES = {torch.float32, torch.float64, torch.int32, torch.int64, torch.uint8}
+_FEATURE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*")
+
+
+def _plain(value: Any) -> Any:
+ """Convert immutable contract metadata to JSON-native containers."""
+ if isinstance(value, Mapping):
+ return {key: _plain(item) for key, item in value.items()}
+ if isinstance(value, (set, frozenset)):
+ return [_plain(item) for item in sorted(value)]
+ if isinstance(value, (tuple, list)):
+ return [_plain(item) for item in value]
+ return value
+
+
+def _json_bytes(value: Any) -> bytes:
+ return json.dumps(
+ _plain(value), sort_keys=True, separators=(",", ":"), allow_nan=False
+ ).encode("utf-8")
+
+
+def _atomic_bytes(path: Path, data: bytes) -> None:
+ """Replace one required metadata file only after its complete write."""
+ temporary: str | None = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ dir=path.parent, prefix=f".{path.name}.", delete=False
+ ) as stream:
+ temporary = stream.name
+ stream.write(data)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, path)
+ finally:
+ if temporary is not None:
+ Path(temporary).unlink(missing_ok=True)
+
+
+class LeRobotEpisodeSink:
+ """Seal and read back one LeRobot dataset shard per accepted episode.
+
+ Each shard contains exactly ``T`` causal training frames. Required sidecars
+ retain the terminal observation, all ``T+1`` measured timestamps, candidate
+ lineage, validation, phases, and user metadata. Only shards listed in
+ ``manifest.json`` are committed. Every successful receipt follows writer
+ finalization, dataset/media decoding, sidecar verification, and manifest
+ replacement/readback. This is a synchronous, single-caller sink; ``drain``
+ has no deferred receipts.
+
+ Numeric observations must be nonempty vectors or matrices of float32,
+ float64, int32, int64, or uint8. Matrices are flattened in C order into
+ LeRobot vectors; ``observation_shapes`` in episode.json retains their source
+ layout, and terminal.npz preserves the original shape. RGB observations
+ must be uint8 arrays shaped ``(T+1,H,W,3)``.
+ Feature names are preserved below ``observation.``; already prefixed names
+ are retained. Unsupported fields are rejected before any episode write.
+ Actions must be float32 or float64 vectors. Training timestamps are the
+ nominal relative LeRobot clock; exact measured timestamps, including their
+ origin, are authoritative in the required sidecar.
+
+ One episode is the bounded shard unit. Retrying an identical commit reuses
+ a readable sealed shard; incomplete uncommitted data is rebuilt at the same
+ path. Changed payloads under the same commit ID are rejected. The sink owns
+ a new/empty output directory and does not implement process restart or
+ power-loss recovery. Do not concurrently modify its files.
+
+ Args:
+ root: New or empty output directory for the collection manifest/shards.
+ fps: Integer control frequency. All measured intervals and relative
+ timestamps must agree with this clock within ``timestamp_tolerance``.
+ repo_id: Local LeRobot repository identity; nothing is uploaded.
+ max_episode_bytes: Maximum raw tensor plus metadata bytes per episode.
+ timestamp_tolerance: Absolute seconds allowed between the measured and
+ nominal clocks; relative tolerance is zero.
+ """
+
+ def __init__(
+ self,
+ root: str | Path,
+ *,
+ fps: int,
+ repo_id: str = "embodichain/trajectory-generation",
+ max_episode_bytes: int = 256 * 1024 * 1024,
+ timestamp_tolerance: float = 1e-6,
+ ) -> None:
+ if type(fps) is not int or fps < 1:
+ raise ValueError("fps must be a positive integer.")
+ if type(max_episode_bytes) is not int or max_episode_bytes < 1:
+ raise ValueError("max_episode_bytes must be a positive integer.")
+ if not isinstance(repo_id, str) or not repo_id:
+ raise ValueError("repo_id must be a nonempty string.")
+ if (
+ isinstance(timestamp_tolerance, bool)
+ or not isinstance(timestamp_tolerance, (int, float))
+ or not math.isfinite(timestamp_tolerance)
+ or timestamp_tolerance < 0
+ ):
+ raise ValueError("timestamp_tolerance must be a finite nonnegative number.")
+ self.root = Path(root).resolve()
+ self.fps = fps
+ self.repo_id = repo_id
+ self.max_episode_bytes = max_episode_bytes
+ self.timestamp_tolerance = float(timestamp_tolerance)
+ self._closed = False
+ self._fingerprints: dict[str, str] = {}
+ self._committed: dict[str, dict[str, Any]] = {}
+ self.root.mkdir(parents=True, exist_ok=True)
+ if any(self.root.iterdir()):
+ raise ValueError(
+ "LeRobotEpisodeSink requires a new or empty root directory."
+ )
+ self._lock = self.root / ".writer.lock"
+ self._lock.touch(exist_ok=False)
+
+ def _prepare(self, episode: ExpertEpisode) -> tuple[ExpertEpisode, dict, dict, str]:
+ """Validate, bound, and own the complete submitted evidence."""
+ if not isinstance(episode, ExpertEpisode):
+ raise TypeError("episode must be an ExpertEpisode.")
+ if not episode.validation.accepted:
+ raise ValueError("Only accepted episodes may enter the expert dataset.")
+ metadata = {
+ "identity": asdict(episode.identity),
+ "episode_id": episode.episode_id,
+ "commit_id": episode.commit_id,
+ "action_representation": episode.action_representation,
+ "validation": [
+ {
+ "check_id": check.check_id,
+ "status": check.status,
+ "detail": check.detail,
+ "metrics": dict(check.metrics),
+ }
+ for check in episode.validation.checks
+ ],
+ "phases": [asdict(phase) for phase in episode.phases],
+ "metadata": _plain(episode.metadata),
+ "fps": self.fps,
+ "steps": episode.actions.shape[0],
+ "observation_features": {},
+ "observation_shapes": {},
+ }
+ # Reject unknown layouts before the CPU ownership copy or filesystem work.
+ features = {}
+ for key, value in episode.observations.items():
+ if not _FEATURE_NAME.fullmatch(key):
+ raise ValueError(f"Unsupported observation feature name: {key!r}.")
+ name = key if key.startswith("observation.") else f"observation.{key}"
+ if name in features:
+ raise ValueError(f"Observation feature names collide at {name!r}.")
+ if (
+ value.ndim in (2, 3)
+ and min(value.shape[1:]) > 0
+ and value.dtype in _NUMERIC_DTYPES
+ ):
+ features[name] = {
+ "dtype": str(value.dtype).removeprefix("torch."),
+ "shape": (math.prod(value.shape[1:]),),
+ "names": None,
+ }
+ elif (
+ value.ndim == 4
+ and value.dtype == torch.uint8
+ and value.shape[-1] == 3
+ and min(value.shape[1:3]) > 0
+ ):
+ features[name] = {
+ "dtype": "image",
+ "shape": (3, *value.shape[1:3]),
+ "names": ["channel", "height", "width"],
+ }
+ else:
+ raise ValueError(
+ f"Unsupported observation shape/dtype for {key!r}: {tuple(value.shape)}, {value.dtype}."
+ )
+ metadata["observation_features"][key] = name
+ metadata["observation_shapes"][key] = list(value.shape[1:])
+ if episode.actions.dtype not in {torch.float32, torch.float64}:
+ raise ValueError("Actions must use float32 or float64.")
+ features["action"] = {
+ "dtype": str(episode.actions.dtype).removeprefix("torch."),
+ "shape": tuple(episode.actions.shape[1:]),
+ "names": None,
+ }
+ metadata["features"] = features
+ encoded = _json_bytes(metadata)
+ tensors = (episode.actions, episode.timestamps, *episode.observations.values())
+ byte_count = len(encoded) + sum(
+ value.numel() * value.element_size() for value in tensors
+ )
+ if byte_count > self.max_episode_bytes:
+ raise ValueError("Episode exceeds max_episode_bytes.")
+ owned = ExpertEpisode(
+ identity=episode.identity,
+ observations={
+ key: value.detach().cpu() for key, value in episode.observations.items()
+ },
+ actions=episode.actions.detach().cpu(),
+ timestamps=episode.timestamps.detach().cpu(),
+ action_representation=episode.action_representation,
+ validation=episode.validation,
+ episode_id=episode.episode_id,
+ commit_id=episode.commit_id,
+ phases=episode.phases,
+ metadata=episode.metadata,
+ )
+ times = owned.timestamps.to(torch.float64)
+ relative = times - times[0]
+ nominal = torch.arange(times.numel(), dtype=torch.float64) / self.fps
+ if not torch.allclose(
+ relative, nominal, atol=self.timestamp_tolerance, rtol=0
+ ) or not torch.allclose(
+ times.diff(),
+ torch.full_like(times[1:], 1 / self.fps),
+ atol=self.timestamp_tolerance,
+ rtol=0,
+ ):
+ raise ValueError(
+ "Measured timestamps do not match the configured fixed control clock."
+ )
+ digest = hashlib.sha256(encoded)
+ for name, value in [
+ ("actions", owned.actions),
+ ("timestamps", owned.timestamps),
+ *sorted(owned.observations.items()),
+ ]:
+ digest.update(_json_bytes([name, str(value.dtype), list(value.shape)]))
+ digest.update(value.contiguous().numpy().tobytes())
+ return owned, features, json.loads(encoded), digest.hexdigest()
+
+ def _write_dataset(
+ self, path: Path, episode: ExpertEpisode, features: dict, metadata: dict
+ ) -> None:
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
+
+ dataset = LeRobotDataset.create(
+ repo_id=self.repo_id,
+ root=path,
+ fps=self.fps,
+ features=features,
+ use_videos=False,
+ metadata_buffer_size=1,
+ image_writer_threads=0,
+ image_writer_processes=0,
+ )
+ try:
+ for index, action in enumerate(episode.actions):
+ frame = {
+ "action": action.numpy(),
+ "task": str(
+ episode.metadata.get("task", episode.identity.source_id)
+ ),
+ }
+ for key, value in episode.observations.items():
+ name = metadata["observation_features"][key]
+ sample = value[index]
+ if features[name]["dtype"] == "image":
+ sample = sample.permute(2, 0, 1)
+ else:
+ sample = sample.reshape(-1)
+ frame[name] = sample.numpy()
+ dataset.add_frame(frame)
+ # LeRobot 0.4.4 validates (1,) numeric arrays but serializes them as
+ # scalar HF Values. Match that schema without NumPy 2 scalar casts.
+ for name, feature in features.items():
+ if feature["shape"] == (1,) and feature["dtype"] != "image":
+ dataset.episode_buffer[name] = [
+ value.reshape(-1)[0] for value in dataset.episode_buffer[name]
+ ]
+ dataset.save_episode()
+ finally:
+ dataset.finalize()
+
+ def _verify_dataset(
+ self, path: Path, episode: ExpertEpisode, features: dict, metadata: dict
+ ) -> None:
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
+ import pyarrow.parquet as pq
+
+ # Check local completeness before LeRobot's constructor can attempt a
+ # Hub fallback for missing metadata. Footer reads also require sealing.
+ required = [
+ path / "meta/info.json",
+ path / "meta/stats.json",
+ path / "meta/tasks.parquet",
+ ]
+ data_files = list((path / "data").rglob("*.parquet"))
+ episode_files = list((path / "meta/episodes").rglob("*.parquet"))
+ if (
+ not all(file.is_file() for file in required)
+ or not data_files
+ or not episode_files
+ ):
+ raise ValueError("LeRobot shard is not sealed and locally complete.")
+ for file in [*data_files, *episode_files, required[-1]]:
+ pq.ParquetFile(file).metadata
+ dataset = LeRobotDataset(repo_id=self.repo_id, root=path, download_videos=False)
+ if (
+ dataset.num_episodes != 1
+ or len(dataset) != len(episode.actions)
+ or dataset.fps != self.fps
+ ):
+ raise ValueError(
+ "LeRobot shard has incorrect episode, frame, or clock metadata."
+ )
+ # LeRobot's torch row transform constructs Python float lists using the
+ # default float32 dtype. Read declared float64 columns from their sealed
+ # Arrow representation to verify precision without that reader cast.
+ precise = {
+ name: []
+ for name, feature in features.items()
+ if feature["dtype"] == "float64"
+ }
+ if precise:
+ for file in sorted(data_files):
+ table = pq.read_table(file, columns=list(precise))
+ for name in precise:
+ precise[name].extend(table[name].to_pylist())
+ for index, action in enumerate(episode.actions):
+ frame = dataset[index] # Decodes every required image as well.
+ observed_action = (
+ (
+ torch.as_tensor(precise["action"][index], dtype=action.dtype)
+ if "action" in precise
+ else frame["action"]
+ )
+ .reshape(action.shape)
+ .to(action.dtype)
+ )
+ if not torch.equal(observed_action, action):
+ raise ValueError(
+ "LeRobot action readback differs from submitted commands."
+ )
+ if not torch.equal(
+ frame["timestamp"],
+ torch.tensor(index / self.fps, dtype=frame["timestamp"].dtype),
+ ):
+ raise ValueError(
+ "LeRobot frame timestamps do not match the fixed clock."
+ )
+ for key, value in episode.observations.items():
+ name = metadata["observation_features"][key]
+ current = frame[name]
+ expected = value[index]
+ if features[name]["dtype"] == "image":
+ current = (current * 255).round().to(torch.uint8).permute(1, 2, 0)
+ else:
+ if name in precise:
+ current = torch.as_tensor(
+ precise[name][index], dtype=expected.dtype
+ )
+ current = current.reshape(expected.shape).to(expected.dtype)
+ if not torch.equal(current, expected):
+ raise ValueError(
+ f"LeRobot observation readback differs for {key!r}."
+ )
+
+ def _write_evidence(
+ self, path: Path, episode: ExpertEpisode, metadata: dict
+ ) -> None:
+ _atomic_bytes(path / "episode.json", _json_bytes(metadata))
+ temporary: str | None = None
+ try:
+ with tempfile.NamedTemporaryFile(
+ dir=path, prefix=".terminal.", delete=False
+ ) as stream:
+ temporary = stream.name
+ arrays = {"timestamps": episode.timestamps.numpy()}
+ arrays.update(
+ {
+ f"observation.{key}": value[-1].numpy()
+ for key, value in episode.observations.items()
+ }
+ )
+ np.savez(stream, **arrays)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, path / "terminal.npz")
+ finally:
+ if temporary is not None:
+ Path(temporary).unlink(missing_ok=True)
+
+ def _verify_evidence(
+ self, path: Path, episode: ExpertEpisode, metadata: dict
+ ) -> None:
+ if json.loads((path / "episode.json").read_text()) != metadata:
+ raise ValueError("Episode lineage/validation metadata readback differs.")
+ expected = {"timestamps": episode.timestamps.numpy()}
+ expected.update(
+ {
+ f"observation.{key}": value[-1].numpy()
+ for key, value in episode.observations.items()
+ }
+ )
+ with np.load(path / "terminal.npz", allow_pickle=False) as arrays:
+ if set(arrays.files) != set(expected):
+ raise ValueError("Terminal evidence is incomplete.")
+ for key, value in expected.items():
+ if (
+ arrays[key].dtype != value.dtype
+ or arrays[key].shape != value.shape
+ or not np.array_equal(arrays[key], value)
+ ):
+ raise ValueError(f"Terminal evidence readback differs for {key!r}.")
+
+ def _write_manifest(self, record: dict) -> None:
+ updated = {**self._committed, record["commit_id"]: record}
+ manifest = {
+ "format": "embodichain.expert_shards",
+ "fps": self.fps,
+ "episodes": list(updated.values()),
+ }
+ _atomic_bytes(self.root / "manifest.json", _json_bytes(manifest))
+ self._verify_manifest(updated)
+ self._committed = updated
+
+ def _verify_manifest(self, records: dict) -> None:
+ expected = {
+ "format": "embodichain.expert_shards",
+ "fps": self.fps,
+ "episodes": list(records.values()),
+ }
+ if json.loads((self.root / "manifest.json").read_text()) != expected:
+ raise ValueError("Collection manifest readback differs.")
+
+ def submit(
+ self, episode: ExpertEpisode, *, submission_id: int = 0
+ ) -> CommitReceipt:
+ """Persist accepted evidence and return its final synchronous receipt.
+
+ Invalid/unsupported input and changed payloads under an existing commit
+ raise before episode writes. Persistence failures return an unconfirmed
+ receipt. Retrying uses the same ``commit_id`` and a new submission ID.
+ """
+ if self._closed:
+ raise RuntimeError("LeRobotEpisodeSink is closed.")
+ if type(submission_id) is not int or submission_id < 0:
+ raise ValueError("submission_id must be a nonnegative integer.")
+ owned, features, metadata, fingerprint = self._prepare(episode)
+ previous = self._fingerprints.setdefault(owned.commit_id, fingerprint)
+ if previous != fingerprint:
+ raise ValueError(
+ "The same commit_id cannot identify a changed episode payload."
+ )
+ path = self.root / hashlib.sha256(owned.commit_id.encode()).hexdigest()
+ dataset_path = path / "dataset"
+ confirmed, error = False, ""
+ try:
+ if owned.commit_id in self._committed:
+ self._verify_dataset(dataset_path, owned, features, metadata)
+ self._verify_evidence(path, owned, metadata)
+ self._verify_manifest(self._committed)
+ return CommitReceipt(
+ episode_id=owned.episode_id,
+ candidate_id=owned.identity.candidate_id,
+ attempt_id=owned.identity.attempt_id,
+ storage_id=str(path),
+ commit_id=owned.commit_id,
+ scene_case_id=owned.identity.scene_case_id,
+ submission_id=submission_id,
+ )
+ path.mkdir(exist_ok=True)
+ if dataset_path.exists():
+ try:
+ self._verify_dataset(dataset_path, owned, features, metadata)
+ except Exception:
+ if owned.commit_id in self._committed:
+ raise
+ shutil.rmtree(dataset_path)
+ if not dataset_path.exists():
+ self._write_dataset(dataset_path, owned, features, metadata)
+ self._verify_dataset(dataset_path, owned, features, metadata)
+ self._write_evidence(path, owned, metadata)
+ self._verify_evidence(path, owned, metadata)
+ self._write_manifest(
+ {
+ "commit_id": owned.commit_id,
+ "episode_id": owned.episode_id,
+ "candidate_id": owned.identity.candidate_id,
+ "fingerprint": fingerprint,
+ "shard": path.name,
+ }
+ )
+ confirmed = True
+ except Exception as exception:
+ error = f"{type(exception).__name__}: {exception}"[:1024]
+ return CommitReceipt(
+ episode_id=owned.episode_id,
+ candidate_id=owned.identity.candidate_id,
+ attempt_id=owned.identity.attempt_id,
+ storage_id=str(path),
+ commit_id=owned.commit_id,
+ scene_case_id=owned.identity.scene_case_id,
+ confirmed=confirmed,
+ error=error,
+ submission_id=submission_id,
+ )
+
+ def drain(self) -> tuple[CommitReceipt, ...]:
+ """Return no deferred work: each submit already returns its final receipt."""
+ return ()
+
+ def close(self) -> None:
+ """Release the writer lease; all successful submissions are already sealed."""
+ self._closed = True
+ self._lock.unlink(missing_ok=True)
+
+ def __enter__(self) -> LeRobotEpisodeSink:
+ return self
+
+ def __exit__(self, *args: object) -> None:
+ self.close()
diff --git a/examples/sim/motion/trajectory_generation/README.md b/examples/sim/motion/trajectory_generation/README.md
new file mode 100644
index 000000000..b12e4df0f
--- /dev/null
+++ b/examples/sim/motion/trajectory_generation/README.md
@@ -0,0 +1,97 @@
+# 固定场景轨迹生成与增强示例
+
+## 并行抓取固定方块:姿态和路径增强
+
+`cube_grasp_parallel.py` 在四个同步仿真环境中,让 UR5 + PGI 夹爪抓取同样的 5 cm 方块。机器人和方块的初始位姿相同,方块受重力和接触作用,夹起后能够移动。
+
+在已安装 EmbodiChain 仿真依赖、UR5/PGI 资产及 TOPPRA 的环境中运行。视频使用离屏渲染,无需桌面窗口;此例使用 UR IK 和 `ik_interp`,不需要 cuRobo 后端。
+
+```bash
+python -m pip install 'imageio[ffmpeg]'
+python examples/sim/motion/trajectory_generation/cube_grasp_parallel.py \
+ --output /tmp/cube-grasp-parallel
+```
+
+输出目录必须不存在或为空。`--seed` 默认为 `13`,控制路径残差的局部随机种子;`--cuda-device` 默认为 `0`,选择渲染显卡。
+
+| 四宫格位置 | 抓取朝向 | 自由运动路径 |
+|---|---|---|
+| 左上 | 0° 参考抓取 | 参考路径 |
+| 右上 | 0° 参考抓取 | 增强路径 |
+| 左下 | 绕方块局部 Z 轴旋转 90° | 为新抓取朝向重新规划的参考路径 |
+| 右下 | 绕方块局部 Z 轴旋转 90° | 增强路径 |
+
+姿态增强调用 `rotate_grasp_about_object_axis`,利用方块的四分之一转对称性生成新的 TCP 目标;每个目标都重新求解 IK。路径增强调用 `joint_residual`,只改变到预抓取位置之前的自由运动段,保留起终点、夹爪命令及后续接近、闭合和抬升段。现有 `MoveEndEffector` 与 `PickUp` 技能负责动作规划。
+
+四路在同一个 `SimulationManager`、同一个物理时钟下执行。每个控制时刻一起采集四个相机画面,再拼成四宫格;彩色线是实测 TCP 轨迹,标题显示抓取角度、运动阶段和方块抬升高度。
+
+输出文件:
+
+- `preview.mp4`:1280×1056、20 fps 的同步对比视频,包含初始与终态画面。
+- `rollout.npz`:四路参考/指令/实测关节轨迹、实测 TCP 和方块位姿、抓取目标与统一时间戳。轨迹数组以 `(环境, 时间, ...)` 排列;参考轨迹不含末尾额外保持段。
+- `report.json`:增强参数、阶段边界、路径差异、各路抓取验收结果。末尾保持段要求抬升至少 12 cm、方块相对 TCP 的位置漂移不超过 1 cm,且始终靠近 TCP。
+
+默认参数的实测结果为四路全部成功,保持期间抬升约 17.4–17.7 cm,两组增强/参考路径的最大 TCP 间距约 17 cm。物理运动为 13.55 秒,加上终态画面的播放间隔,272 帧视频时长为 13.60 秒。改变种子后仍会重新验收,未全部成功时返回退出码 1。
+
+这是抓取增强的物理可视化示例,不写入专家 LeRobot 数据。需要碰撞、真实接触检查和多轮采集时,使用下方 `cube_pickup_collection.py`。
+
+## 并行抓取的专家数据采集
+
+`cube_pickup_collection.py` 复用上面的四路 cube 场景和原子动作规划,接入 `FixedSceneHost → GenerationRunner/GenerationSession → LeRobotEpisodeSink`。四路各自生成自由段残差,使用 0°/90° 抓取目标;每轮结束恢复整批初态,再继续生成,直到已确认提交数量达到目标或预算耗尽。
+
+```bash
+python -m pip install -e '.[trajectory-generation]' 'imageio[ffmpeg]'
+python examples/sim/motion/trajectory_generation/cube_pickup_collection.py \
+ --output /tmp/cube-experts --episodes 8 --record-video
+```
+
+`--episodes` 默认 8,范围 1–64;`--seed` 默认 13,`--cuda-device` 默认 0。输出目录必须不存在或为空。CPU 物理 200 Hz,控制及视频 20 Hz。采集版提高机械臂刚度到 200000,并将夹爪打开目标设在关节下限内侧 1 mm,以满足既定跟踪门槛并避免自由段触及限位;重力保持开启。
+
+验收包括:
+
+- 规划使用 URDF 碰撞形状的保守凸包,按完整关节状态检查手指和 mimic 几何,并检查持物路径与桌面、地面、其他物体的碰撞。非相邻自碰撞也参与检查,结构上相距两条关节边以内的 link 对沿用现有 cuRobo 排除规则。
+- 实测关节及 cube 位姿再次检查几何,每个 5 ms 物理子步检查原生接触。只有声明的指尖、方块、支撑和固定安装部位接触被允许;接近段指尖接触限于 TCP 距方块 6 cm 的进入区域。未知物体、跨行接触、超过 2 mm 的穿透或接触数据超限都会拒绝。
+- 闭合后抬升并保持 1.5 秒,要求方块至少抬高 12 cm、两个手指各有至少 95% 的保持采样出现真实接触力,且从抬升到保持的 TCP 相对漂移不超过 1 cm / 0.15 rad。原有速度、加速度、轨迹质量和末端跟踪检查继续生效。
+- 只有验收通过、写入封存且成功回读的 episode 才进入 manifest。视频也会显示未通过的尝试,不能用视频代替数据验收。
+
+输出 `preview.mp4`(开启视频时)、`generation_report.json`、`pickup_report.json`、`manifest.json` 和逐 episode 的 LeRobot 分片。每条数据含 T 个真实控制目标及 T+1 个实测观测/时间戳。cube/TCP 位姿在 LeRobot 中按行优先展开为 16 维,`episode.json` 的 `observation_shapes` 保留 `[4, 4]`;终态 `terminal.npz` 保持矩阵形状。`commanded_joint_indices` 标识完整关节向量中真正下发的关节列。
+
+范围:单个固定基座 URDF 机器人、盒状刚体、CPU 物理。碰撞验证是有界采样,未提供连续碰撞保证。`source.kind=atomic` 表示来源是 `AtomicActionEngine.compile` 的离线计划;执行采用单独验收的 qpos 回放,不提交编译时预测的符号效果。Atomic Runtime 的 tracking/recovery 接入、Gym 接触采集与通用 YAML 启动器仍是后续工作。
+
+## 自由运动采集
+
+`free_motion.py` 使用普通重力下的纯机械臂 UR5,在固定场景中执行关节轨迹,经过规划、实测碰撞、任务和运动质量验收后保存一条 LeRobot episode。仿真物理在 CPU 上运行,cuRobo 和可选离屏相机使用 CUDA,无需桌面窗口。当前示例不包含抓取或持物。
+
+在已安装 EmbodiChain 仿真依赖、CUDA、cuRobo 和 LeRobot 的环境中,从仓库根目录运行。保存视频还需要 `imageio-ffmpeg`:
+
+```bash
+python -m pip install imageio-ffmpeg
+python examples/sim/motion/trajectory_generation/free_motion.py --output /tmp/ur5-video --record-video --duration 5 --joint-displacement 0.4
+```
+
+输出目录必须不存在或为空。上述命令记录 5 秒物理运动,使用 640×480 离屏相机流式写入 20 fps 的 H.264 视频。包含初始帧和终态帧,共 101 帧,因此视频时长为 5.05 秒。
+
+不需要视频时可直接运行默认的 1 秒轨迹:
+
+```bash
+python examples/sim/motion/trajectory_generation/free_motion.py --output /tmp/ur5-free-motion
+```
+
+可用参数:
+
+| 参数 | 含义 |
+|---|---|
+| `--record-video` | 在输出目录保存 `preview.mp4` |
+| `--duration` | 运动时长,默认 1 秒;范围 0.05–30 秒,必须是 0.05 的整数倍 |
+| `--joint-displacement` | 第一个机械臂关节的正向位移,默认 0.08 rad;范围 `(0, 0.5]` rad |
+| `--cuda-device` | CUDA 设备编号,默认 0 |
+| `--robot panda` | 运行 Panda 负例;当前锁定夹爪模型会拒绝普通重力下的实测手指漂移 |
+
+输出文件:
+
+- `preview.mp4`:启用视频时的实际执行记录,独立于数值 LeRobot 观测。
+- `generation_report.json`:计数、验收证据、失败原因及 `target_reached`。
+- `manifest.json`:已确认提交的 episode 分片目录;被拒绝的轨迹不会作为已提交数据列入。
+- 每个提交分片中的 `dataset/`、`terminal.npz` 和 `episode.json`:LeRobot 训练帧、终态/T+1 时间证据和谱系/验证信息。
+
+视频存在不代表任务或专家数据验收通过,应查看报告和 manifest。未达到采集目标时脚本返回退出码 1;改变时长或位移后也会执行全部验收检查。
diff --git a/examples/sim/motion/trajectory_generation/cube_grasp_parallel.py b/examples/sim/motion/trajectory_generation/cube_grasp_parallel.py
new file mode 100644
index 000000000..1b722a048
--- /dev/null
+++ b/examples/sim/motion/trajectory_generation/cube_grasp_parallel.py
@@ -0,0 +1,546 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Show grasp-pose and path augmentation on four synchronized cube pickups.
+
+Run from the repository root with the simulation and video dependencies::
+
+ python examples/sim/motion/trajectory_generation/cube_grasp_parallel.py --output /tmp/cube-grasp-parallel
+
+The four real physics rows share the same initial robot and cube poses. A cube
+symmetry supplies two grasp orientations; joint residuals supply two transit
+paths per orientation. Existing Atomic Skills plan the final approach, close,
+and lift. The cube moves only through physical contact with the gripper.
+
+This example saves a four-panel MP4, measured rollout arrays, and a grasp-result
+report. It does not use the free-motion-only GenerationRunner or certify a
+contact-aware expert dataset. Planning uses the existing UR IK solver and the
+MotionGenerator ik_interp strategy, without a collision-planning guarantee.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+from pathlib import Path
+import sys
+
+import imageio.v2 as imageio
+import numpy as np
+from PIL import Image, ImageDraw, ImageFont
+import torch
+
+_REPO_ROOT = Path(__file__).resolve().parents[4]
+if str(_REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(_REPO_ROOT))
+
+from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
+from embodichain.lab.sim.atomic_actions import (
+ ControlPartCommandProfile,
+ EndEffectorPoseGoal,
+ GraspGoal,
+ MotionPolicy,
+ PickUpOptions,
+ create_simulation_atomic_action_engine,
+)
+from embodichain.lab.sim.cfg import RigidBodyAttributesCfg
+from embodichain.lab.sim.material import VisualMaterialCfg
+from embodichain.lab.sim.motion.expansion import (
+ TrajectoryPhase,
+ TrajectoryTemplate,
+ joint_residual,
+ rotate_grasp_about_object_axis,
+ validate_motion_limits,
+)
+from embodichain.lab.sim.objects import RigidObjectCfg
+from embodichain.lab.sim.sensors import CameraCfg
+from embodichain.lab.sim.shapes import CubeCfg
+from embodichain.lab.visualization import VisualizationCfg
+from embodichain.utils.math import look_at_to_pose
+from scripts.tutorials.atomic_action.tutorial_utils import (
+ create_antipodal_semantics,
+ create_toppra_motion_generator,
+ create_ur5_gripper_robot_cfg,
+ get_hand_open_close_qpos,
+)
+
+__all__ = ["run_cube_grasp_parallel", "main"]
+
+_FPS = 20
+_CONTROL_DT = 1 / _FPS
+_PHYSICS_DT = 0.005
+_WIDTH, _HEIGHT = 640, 480
+_HEADER = 48
+_YAW = (0.0, 0.0, math.pi / 2, math.pi / 2)
+_COLORS = ((79, 183, 244), (250, 180, 63), (177, 143, 245), (80, 213, 160))
+_EYE, _TARGET = (-1.0, -1.0, 1.12), (-0.28, -0.08, 0.56)
+_INTRINSICS = (570.0, 570.0, 320.0, 240.0)
+
+
+def _grasp_results(
+ cube_poses: np.ndarray, tcp_poses: np.ndarray, hold_start: int
+) -> list[dict[str, object]]:
+ """Check sustained lift and object stability relative to the actual TCP."""
+ results = []
+ for row in range(4):
+ cube_hold = cube_poses[row, hold_start:]
+ tcp_hold = tcp_poses[row, hold_start:]
+ relative = np.linalg.inv(tcp_hold) @ cube_hold
+ min_lift = float((cube_hold[:, 2, 3] - cube_poses[row, 0, 2, 3]).min())
+ drift = float(
+ np.linalg.norm(relative[:, :3, 3] - relative[0, :3, 3], axis=-1).max()
+ )
+ distance = float(np.linalg.norm(relative[:, :3, 3], axis=-1).max())
+ results.append(
+ {
+ "row": row,
+ "yaw_degrees": round(math.degrees(_YAW[row])),
+ "path": "residual" if row % 2 else "reference",
+ "success": min_lift >= 0.12 and drift <= 0.01 and distance <= 0.06,
+ "min_hold_lift_m": min_lift,
+ "hold_relative_drift_m": drift,
+ "max_cube_tcp_distance_m": distance,
+ }
+ )
+ return results
+
+
+def _preview_frame(
+ rgb: np.ndarray,
+ trails: list[list[tuple[float, float]]],
+ elapsed: float,
+ phase: str,
+ lifts: np.ndarray,
+ font: ImageFont.ImageFont,
+ path_labels: tuple[str, ...] | None = None,
+) -> np.ndarray:
+ """Tile camera frames from the same simulation tick with measured TCP trails."""
+ preview = Image.new("RGB", (2 * _WIDTH, 2 * (_HEIGHT + _HEADER)), (18, 23, 32))
+ for row in range(4):
+ panel = Image.fromarray(rgb[row])
+ draw = ImageDraw.Draw(panel)
+ if len(trails[row]) > 1:
+ draw.line(trails[row], fill=_COLORS[row], width=3)
+ left, top = (row % 2) * _WIDTH, (row // 2) * (_HEIGHT + _HEADER)
+ preview.paste(panel, (left, top + _HEADER))
+ draw = ImageDraw.Draw(preview)
+ label = (
+ path_labels[row]
+ if path_labels is not None
+ else ("reference path" if row % 2 == 0 else "augmented path")
+ )
+ draw.text(
+ (left + 12, top + 3),
+ f"{row + 1} | grasp {round(math.degrees(_YAW[row]))} deg | {label}",
+ fill=_COLORS[row],
+ font=font,
+ )
+ draw.text(
+ (left + 12, top + 25),
+ f"{elapsed:5.2f} s | {phase} | cube lift {lifts[row] * 100:5.1f} cm",
+ fill=(229, 234, 241),
+ font=font,
+ )
+ return np.asarray(preview)
+
+
+def _prepare_cube_scene(
+ sim: SimulationManager, *, arm_stiffness: float = 5e4, hand_open_margin: float = 0.0
+):
+ """Share the four-row physical scene and initial state between examples."""
+ robot_cfg = create_ur5_gripper_robot_cfg(init_pos=(0.0, 0.0, 0.3), tcp_z=0.15)
+ robot_cfg.drive_pros.stiffness["arm"] = arm_stiffness
+ robot = sim.add_robot(cfg=robot_cfg)
+ sim.add_rigid_object(
+ cfg=RigidObjectCfg(
+ uid="bench",
+ body_type="kinematic",
+ init_pos=(-0.2, -0.08, 0.15),
+ shape=CubeCfg(
+ size=(0.9, 0.65, 0.3),
+ visual_material=VisualMaterialCfg(
+ uid="bench_material", base_color=[0.16, 0.20, 0.27, 1.0]
+ ),
+ ),
+ )
+ )
+ cube = sim.add_rigid_object(
+ cfg=RigidObjectCfg(
+ uid="cube",
+ shape=CubeCfg(
+ size=(0.05, 0.05, 0.05),
+ visual_material=VisualMaterialCfg(
+ uid="cube_material", base_color=[0.95, 0.23, 0.04, 1.0]
+ ),
+ ),
+ init_pos=(-0.42, -0.08, 0.325),
+ attrs=RigidBodyAttributesCfg(
+ mass=0.05, dynamic_friction=0.97, static_friction=0.99
+ ),
+ )
+ )
+ camera = sim.add_sensor(
+ CameraCfg(
+ uid="comparison_camera",
+ visualization_role="record",
+ width=_WIDTH,
+ height=_HEIGHT,
+ far=2.5,
+ intrinsics=_INTRINSICS,
+ extrinsics=CameraCfg.ExtrinsicsCfg(eye=_EYE, target=_TARGET),
+ )
+ )
+ # Settle the identical cubes before fixing the reference state. No
+ # object pose, gravity, or dynamics are changed during the rollout.
+ sim.update(step=100)
+ object_poses = cube.get_local_pose(to_matrix=True).clone()
+ torch.testing.assert_close(
+ object_poses, object_poses[0:1].expand_as(object_poses), atol=1e-5, rtol=0
+ )
+ reference_grasp = torch.eye(4, device=robot.device)
+ reference_grasp[:3, :3] = reference_grasp.new_tensor(
+ [[0.0, -1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, -1.0]]
+ )
+ reference_grasp[:3, 3] = object_poses[0, :3, 3]
+ grasps = rotate_grasp_about_object_axis(
+ object_poses[0],
+ reference_grasp,
+ axis=torch.tensor([0.0, 0.0, 1.0]),
+ angles=torch.tensor(_YAW),
+ )
+ start_pose = reference_grasp.repeat(4, 1, 1)
+ start_pose[:, :3, 3] += start_pose.new_tensor([0.12, 0.0, 0.34])
+ success, arm_qpos = robot.compute_ik(
+ pose=start_pose, joint_seed=robot.get_qpos(name="arm"), name="arm"
+ )
+ if not success.all():
+ raise RuntimeError("The common initial TCP pose is unreachable")
+ hand_open, hand_close = get_hand_open_close_qpos(robot)
+ hand_open = hand_open + hand_open_margin
+ initial_qpos = torch.tensor(robot.cfg.init_qpos, device=robot.device).repeat(4, 1)
+ initial_qpos[:, robot.get_joint_ids("arm")] = arm_qpos
+ initial_qpos[:, robot.get_joint_ids("hand")] = hand_open
+ for child, parent, multiplier, offset in zip(
+ robot.mimic_ids,
+ robot.mimic_parents,
+ robot.mimic_multipliers,
+ robot.mimic_offsets,
+ ):
+ initial_qpos[:, child] = initial_qpos[:, parent] * multiplier + offset
+ for target in (False, True):
+ robot.set_qpos(initial_qpos, target=target)
+ robot.clear_dynamics()
+
+ return robot, cube, camera, initial_qpos, grasps, hand_open, hand_close
+
+
+def _compile_cube_pickup(robot, cube, grasps, hand_open, hand_close):
+ """Compile the same atomic transit and PickUp for preview and collection."""
+ generator = create_toppra_motion_generator(robot)
+ engine = create_simulation_atomic_action_engine(
+ motion_generator=generator,
+ scene_entities=(cube,),
+ control_profiles={
+ "hand": ControlPartCommandProfile.joint_positions(
+ open=hand_open, grasp=hand_close
+ )
+ },
+ )
+ pregrasp = grasps.clone()
+ pregrasp[:, 2, 3] += 0.16
+ compiled = engine.compile(
+ (
+ engine.make_invocation(
+ "move_end_effector",
+ EndEffectorPoseGoal(pregrasp),
+ control_parts={"primary": {"motion": "arm"}},
+ motion_policy=MotionPolicy(strategy="ik_interp", sample_count=81),
+ ),
+ engine.make_invocation(
+ "pick_up",
+ GraspGoal(
+ create_antipodal_semantics(cube, label="cube"),
+ grasp_xpos=grasps,
+ ),
+ control_parts={"primary": {"motion": "arm", "grasp": "hand"}},
+ motion_policy=MotionPolicy(strategy="ik_interp", sample_count=141),
+ skill_options=PickUpOptions(
+ pre_grasp_distance=0.16,
+ lift_height=0.18,
+ hand_interp_steps=30,
+ grasp_settle_steps=20,
+ ),
+ ),
+ ),
+ engine.initial_context(control_dt=_CONTROL_DT),
+ )
+ return generator, compiled
+
+
+def run_cube_grasp_parallel(
+ output_dir: Path, *, seed: int = 13, cuda_device: int = 0
+) -> dict[str, object]:
+ """Run four simultaneous physical pickups and save their measured evidence.
+
+ Args:
+ output_dir: New or empty directory for the MP4, arrays, and report.
+ seed: Local random seed for both residual variants; no global RNG reset.
+ cuda_device: GPU used by the offscreen renderer.
+
+ Returns:
+ The persisted report with per-row grasp outcomes and augmentation factors.
+
+ Raises:
+ ValueError: If the output directory is occupied or the seed is invalid.
+ RuntimeError: If initialization, IK, or sampled motion-limit checks fail.
+ """
+ output_dir = Path(output_dir)
+ if output_dir.exists() and (not output_dir.is_dir() or any(output_dir.iterdir())):
+ raise ValueError("output_dir must be new or empty")
+ if type(seed) is not int or not 0 <= seed < 2**63:
+ raise ValueError("seed must be an integer in [0, 2**63)")
+ output_dir.mkdir(parents=True, exist_ok=True)
+ sim = SimulationManager(
+ SimulationManagerCfg(
+ headless=True,
+ sim_device="cpu",
+ num_envs=4,
+ physics_dt=_PHYSICS_DT,
+ arena_space=4.0,
+ gpu_id=cuda_device,
+ visualization=VisualizationCfg(),
+ )
+ )
+ generator = writer = None
+ try:
+ robot, cube, camera, initial_qpos, grasps, hand_open, hand_close = (
+ _prepare_cube_scene(sim)
+ )
+ generator, compiled = _compile_cube_pickup(
+ robot, cube, grasps, hand_open, hand_close
+ )
+ if not compiled.plan_success.all():
+ raise RuntimeError(
+ f"IK planning failed in rows: {compiled.plan_success.tolist()}"
+ )
+ transit_stop = compiled.action_waypoint_offset(1)
+ positions = compiled.trajectory.positions.clone()
+ # Atomic concatenation retains the next action's zero-time start.
+ # Here that duplicate becomes one explicit control-period hold so all
+ # four rows and their camera frames share a uniform execution clock.
+ torch.testing.assert_close(
+ positions[:, transit_stop],
+ positions[:, transit_stop - 1],
+ atol=1e-5,
+ rtol=0,
+ )
+ intervals = compiled.trajectory.dt.clone()
+ intervals[:, transit_stop] = _CONTROL_DT
+ baseline = positions.clone()
+ arm_ids = tuple(robot.get_joint_ids("arm"))
+ limits = robot.get_qpos_limits()[0]
+ motion_checks = []
+ for row in range(4):
+ template = TrajectoryTemplate(
+ "cube_pickup",
+ "v1",
+ f"yaw_{round(math.degrees(_YAW[row]))}",
+ tuple(robot.joint_names),
+ positions[row],
+ intervals[row],
+ (
+ TrajectoryPhase(
+ "transit",
+ 0,
+ transit_stop,
+ allowed_operators=("joint_residual",),
+ ),
+ TrajectoryPhase(
+ "pickup", transit_stop, positions.shape[1], kind="contact"
+ ),
+ ),
+ allowed_operators=("joint_residual",),
+ controlled_joint_indices=arm_ids,
+ )
+ if row % 2:
+ template = joint_residual(
+ template,
+ joint_limits=limits,
+ normalized_scale=0.025,
+ generator=torch.Generator().manual_seed(seed),
+ )
+ check = validate_motion_limits(
+ template,
+ velocity_limits=torch.full((robot.dof,), 1.5),
+ acceleration_limits=torch.full((robot.dof,), 15.0),
+ )
+ if not check.accepted:
+ raise RuntimeError(f"Motion limits failed in row {row}: {check}")
+ motion_checks.append(dict(check.checks[0].metrics))
+ positions[row] = template.positions
+ # The augmentation is confined to transit: all closing/contact/lift
+ # commands and the endpoint into the pre-grasp corridor are preserved.
+ assert torch.equal(
+ positions[:, transit_stop - 1 :], baseline[:, transit_stop - 1 :]
+ )
+ torch.testing.assert_close(positions[:, 0], robot.get_qpos(), atol=1e-5, rtol=0)
+ count = positions.shape[1]
+ hold_frames = 30
+ positions = torch.cat(
+ (positions, positions[:, -1:].repeat(1, hold_frames, 1)), dim=1
+ )
+ try:
+ font = ImageFont.truetype("DejaVuSans.ttf", 17)
+ except OSError:
+ font = ImageFont.load_default()
+ writer = imageio.get_writer(
+ str(output_dir / "preview.mp4"),
+ format="FFMPEG",
+ fps=_FPS,
+ codec="libx264",
+ pixelformat="yuv420p",
+ quality=8,
+ ffmpeg_log_level="error",
+ output_params=["-movflags", "+faststart"],
+ )
+ view = torch.linalg.inv(look_at_to_pose(_EYE, _TARGET)).squeeze(0).numpy()
+ trails: list[list[tuple[float, float]]] = [[] for _ in range(4)]
+ qpos_samples, cube_samples, tcp_samples, timestamps = [], [], [], []
+ start_time = sim.simulation_time
+ close_start = compiled.segment(1, "close").start
+ lift_start = compiled.segment(1, "lift").start
+
+ for index in range(positions.shape[1]):
+ if index:
+ robot.set_qpos(positions[:, index])
+ sim.update(step=round(_CONTROL_DT / _PHYSICS_DT))
+ elapsed = sim.simulation_time - start_time
+ if not math.isclose(elapsed, index * _CONTROL_DT, abs_tol=1e-7):
+ raise RuntimeError(
+ "The measured simulation clock diverged from the video clock"
+ )
+ qpos_samples.append(robot.get_qpos().cpu().numpy().copy())
+ cube_samples.append(
+ cube.get_local_pose(to_matrix=True).cpu().numpy().copy()
+ )
+ tcp = robot.compute_fk(
+ robot.get_qpos(name="arm"), name="arm", to_matrix=True
+ )
+ tcp_samples.append(tcp.cpu().numpy().copy())
+ timestamps.append(elapsed)
+ for row in range(4):
+ point = view @ np.r_[tcp_samples[-1][row, :3, 3], 1.0]
+ if point[2] > 0:
+ fx, fy, cx, cy = _INTRINSICS
+ trails[row].append(
+ (fx * point[0] / point[2] + cx, fy * point[1] / point[2] + cy)
+ )
+ phase = (
+ "transit"
+ if index < transit_stop
+ else (
+ "approach"
+ if index < close_start
+ else (
+ "close gripper"
+ if index < lift_start
+ else "lift" if index < count else "hold"
+ )
+ )
+ )
+ camera.update()
+ rgb = camera.get_data()["color"][..., :3].cpu().numpy().copy()
+ writer.append_data(
+ _preview_frame(
+ rgb,
+ trails,
+ elapsed,
+ phase,
+ cube_samples[-1][:, 2, 3] - cube_samples[0][:, 2, 3],
+ font,
+ )
+ )
+
+ cubes = np.stack(cube_samples, axis=1)
+ tcps = np.stack(tcp_samples, axis=1)
+ results = _grasp_results(cubes, tcps, count)
+ deviations = [
+ float(
+ np.linalg.norm(
+ tcps[row, :transit_stop, :3, 3]
+ - tcps[row - 1, :transit_stop, :3, 3],
+ axis=-1,
+ ).max()
+ )
+ for row in (1, 3)
+ ]
+ report = {
+ "success": all(result["success"] for result in results),
+ "num_envs": 4,
+ "seed": seed,
+ "normalized_residual_scale": 0.025,
+ "fps": _FPS,
+ "frame_count": len(timestamps),
+ "physical_duration_s": timestamps[-1],
+ "transit_stop": transit_stop,
+ "hold_start": count,
+ "contact_commands_preserved": True,
+ "max_path_pair_separation_m": deviations,
+ "planned_motion_limits": motion_checks,
+ "results": results,
+ "validation_scope": "physical sustained lift and TCP-relative position stability; no contact-aware collision or expert-dataset certification",
+ }
+ np.savez_compressed(
+ output_dir / "rollout.npz",
+ timestamps=np.asarray(timestamps),
+ commanded_qpos=positions.cpu().numpy(),
+ measured_qpos=np.stack(qpos_samples, axis=1),
+ cube_poses=cubes,
+ tcp_poses=tcps,
+ grasp_poses=grasps.cpu().numpy(),
+ reference_qpos=baseline.cpu().numpy(),
+ )
+ (output_dir / "report.json").write_text(json.dumps(report, indent=2) + "\n")
+ return report
+ finally:
+ try:
+ if writer is not None:
+ writer.close()
+ finally:
+ if generator is not None:
+ generator.planner.close()
+ sim.destroy(exit_process=False)
+ SimulationManager.flush_cleanup_queue()
+
+
+def main() -> None:
+ """Run the parallel comparison and return a failing exit code for failed grasps."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--seed", type=int, default=13)
+ parser.add_argument("--cuda-device", type=int, default=0)
+ args = parser.parse_args()
+ report = run_cube_grasp_parallel(
+ args.output, seed=args.seed, cuda_device=args.cuda_device
+ )
+ print(json.dumps(report))
+ if not report["success"]:
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/sim/motion/trajectory_generation/cube_pickup_collection.py b/examples/sim/motion/trajectory_generation/cube_pickup_collection.py
new file mode 100644
index 000000000..652e6f53e
--- /dev/null
+++ b/examples/sim/motion/trajectory_generation/cube_pickup_collection.py
@@ -0,0 +1,418 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Collect contact-validated parallel cube PickUp episodes and an optional video.
+
+Run from the repository root::
+
+ python examples/sim/motion/trajectory_generation/cube_pickup_collection.py --output /tmp/cube-experts --episodes 8 --record-video
+
+Requires the simulator, LeRobot, python-fcl, trimesh and yourdfpy. CPU physics
+is stepped at 200 Hz; a CUDA renderer is needed for the optional 20 Hz preview.
+Four rows share one physical clock. The complete batch restores its initial
+robot/object state between rounds. Only validated, sealed and read-back episodes
+count towards the requested target. The video also shows rejected attempts.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+import sys
+import traceback
+
+import numpy as np
+import torch
+
+_REPO_ROOT = Path(__file__).resolve().parents[4]
+if str(_REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(_REPO_ROOT))
+
+from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
+from embodichain.lab.sim.motion.expansion import (
+ SceneCase,
+ TrajectoryGenerationJobCfg,
+ ValidationCheck,
+ ValidationResult,
+)
+from embodichain.lab.visualization import VisualizationCfg
+from embodichain.lab.trajectory_generation.execution import QposRolloutExecutor
+from embodichain.lab.trajectory_generation.initial_state import (
+ FixedSceneHost,
+ InitialStateProfile,
+)
+from embodichain.lab.trajectory_generation.integrations.sim import (
+ SimInitialStateAdapter,
+)
+from embodichain.lab.trajectory_generation.integrations.atomic import (
+ export_pickup_templates,
+)
+from embodichain.lab.trajectory_generation.integrations.contact import (
+ PickUpContactProfile,
+ PickUpMotionValidator,
+)
+from embodichain.lab.trajectory_generation.runner import (
+ GenerationRunner,
+ MotionLimitsProfile,
+)
+from embodichain.lab.trajectory_generation.sinks import LeRobotEpisodeSink
+from embodichain.utils.math import look_at_to_pose
+from examples.sim.motion.trajectory_generation.cube_grasp_parallel import (
+ _prepare_cube_scene,
+ _compile_cube_pickup,
+ _preview_frame,
+ _YAW,
+ _EYE,
+ _TARGET,
+ _INTRINSICS,
+ _CONTROL_DT,
+ _PHYSICS_DT,
+)
+
+__all__ = ["run_cube_pickup_collection", "main"]
+
+
+def run_cube_pickup_collection(
+ output_dir: Path,
+ *,
+ episodes: int = 8,
+ seed: int = 13,
+ cuda_device: int = 0,
+ record_video: bool = False,
+) -> dict[str, object]:
+ """Run a bounded four-row PickUp collection with real contact acceptance.
+
+ Args:
+ output_dir: New or empty local collection directory.
+ episodes: Number of committed episodes requested, between 1 and 64.
+ seed: Local augmentation random seed.
+ cuda_device: GPU used by the simulator renderer.
+ record_video: Stream synchronized four-panel observations to preview.mp4.
+
+ Returns:
+ The persisted generation audit with committed count and target outcome.
+ """
+ if type(episodes) is not int or not 1 <= episodes <= 64:
+ raise ValueError("episodes must be an integer between 1 and 64")
+ output_dir = Path(output_dir)
+ if output_dir.exists() and (not output_dir.is_dir() or any(output_dir.iterdir())):
+ raise ValueError("output_dir must be new or empty")
+ cfg = TrajectoryGenerationJobCfg.from_mapping(
+ {
+ "source": {"kind": "atomic", "source_id": "atomic_pickup"},
+ "augmentation": {
+ "seed": seed,
+ "factors": {
+ "spatial": {
+ "enabled": True,
+ "method": "joint_residual",
+ "joint_offset_scale": 0.025,
+ }
+ },
+ },
+ "collection": {
+ "target_committed_episodes": episodes,
+ "max_proposals": max(32, episodes * 8),
+ "max_rollout_attempts": max(16, episodes * 4),
+ "max_wall_time_s": 1200.0,
+ },
+ "validation": {"path_length_ratio_max": 2.0},
+ }
+ )
+ sim = SimulationManager(
+ SimulationManagerCfg(
+ headless=True,
+ sim_device="cpu",
+ num_envs=4,
+ physics_dt=_PHYSICS_DT,
+ arena_space=4.0,
+ gpu_id=cuda_device,
+ visualization=VisualizationCfg(),
+ )
+ )
+ generator = host = sink = writer = None
+ try:
+ robot, cube, camera, initial, grasps, hand_open, hand_close = (
+ _prepare_cube_scene(sim, arm_stiffness=2e5, hand_open_margin=0.001)
+ )
+ generator, compiled = _compile_cube_pickup(
+ robot, cube, grasps, hand_open, hand_close
+ )
+ templates = export_pickup_templates(compiled, robot, control_dt=_CONTROL_DT)
+ initial_objects = {
+ uid: sim.get_rigid_object(uid).get_local_pose(to_matrix=True).clone()
+ for uid in sim.get_rigid_object_uid_list()
+ }
+ zero = torch.zeros_like(initial)
+ prepares = 0
+
+ def prepare() -> None:
+ nonlocal prepares
+ prepares += 1
+ robot.set_qpos(initial, target=False)
+ robot.set_qvel(zero, target=False)
+ robot.set_qpos(initial, target=True)
+ robot.set_qvel(zero, target=True)
+ robot.set_qf(zero)
+ for uid, pose in initial_objects.items():
+ obj = sim.get_rigid_object(uid)
+ obj.set_local_pose(pose)
+ obj.clear_dynamics()
+
+ def signature() -> str:
+ return json.dumps(
+ {
+ "robot": robot.cfg.to_dict(),
+ "scene": {
+ uid: sim.get_rigid_object(uid).cfg.to_dict()
+ for uid in initial_objects
+ },
+ "physics": sim.sim_config.physics_config.to_dict(),
+ },
+ sort_keys=True,
+ default=str,
+ )
+
+ def verify(cases) -> ValidationResult:
+ passed = torch.allclose(
+ robot.get_qpos(), initial, atol=1e-6, rtol=0
+ ) and all(
+ torch.allclose(
+ sim.get_rigid_object(uid).get_local_pose(to_matrix=True),
+ pose,
+ atol=1e-6,
+ rtol=0,
+ )
+ for uid, pose in initial_objects.items()
+ )
+ return ValidationResult(
+ (
+ ValidationCheck(
+ "task_initial_state",
+ "passed" if passed else "failed",
+ "Identical prepared cube, table and full robot state",
+ ),
+ )
+ )
+
+ host = FixedSceneHost(
+ SimInitialStateAdapter(sim, robot),
+ InitialStateProfile(
+ profile_id="fixed_scene_initial_state",
+ prepare=prepare,
+ signature=signature,
+ verify=verify,
+ physics_dt=_PHYSICS_DT,
+ settling_steps=0,
+ ),
+ )
+ # The PGI fingertips extend past the 0.15 m TCP. Their contact entry
+ # region includes the cube half-height and that physical overhang.
+ planner = PickUpMotionValidator(
+ sim, robot, profile=PickUpContactProfile(approach_contact_distance=0.06)
+ )
+ frames = 0
+ trails = [[] for _ in range(4)]
+ view = torch.linalg.inv(look_at_to_pose(_EYE, _TARGET)).squeeze(0).numpy()
+ if record_video:
+ from PIL import ImageFont
+
+ try:
+ font = ImageFont.truetype("DejaVuSans.ttf", 17)
+ except OSError:
+ font = ImageFont.load_default()
+
+ def observe() -> dict[str, torch.Tensor]:
+ nonlocal frames, trails
+ if record_video:
+ index = frames % len(templates[0].positions)
+ if index == 0:
+ trails = [[] for _ in range(4)]
+ observations = planner.observations()
+ tcp = observations["tcp_pose"].cpu().numpy()
+ for row in range(4):
+ point = view @ np.r_[tcp[row, :3, 3], 1.0]
+ if point[2] > 0:
+ fx, fy, cx, cy = _INTRINSICS
+ trails[row].append(
+ (
+ fx * point[0] / point[2] + cx,
+ fy * point[1] / point[2] + cy,
+ )
+ )
+ phase = next(
+ p.phase_id
+ for p in templates[0].phases
+ if p.start_index <= index < p.stop_index
+ )
+ camera.update()
+ rgb = camera.get_data()["color"][..., :3].cpu().numpy().copy()
+ frame = _preview_frame(
+ rgb,
+ trails,
+ index * _CONTROL_DT,
+ f"round {prepares}: {phase}",
+ (
+ observations["object_pose"][:, 2, 3]
+ - initial_objects[cube.uid][:, 2, 3]
+ )
+ .cpu()
+ .numpy(),
+ font,
+ path_labels=("augmented path",) * 4,
+ )
+ writer.append_data(frame)
+ frames += 1
+ return {
+ "joint_positions": robot.get_qpos(),
+ "joint_velocities": robot.get_qvel(),
+ }
+
+ arm_ids = tuple(robot.get_joint_ids("arm"))
+
+ def task_validator(
+ candidate, observations, actions, timestamps
+ ) -> ValidationResult:
+ measured = observations["joint_positions"]
+ expected = candidate.positions[0, : len(measured)].to(measured)
+ endpoint = float(
+ (measured[-1, arm_ids] - expected[-1, arm_ids]).abs().max()
+ )
+ tracking = float((measured[:, arm_ids] - expected[:, arm_ids]).abs().max())
+ # Physical grasp and contact checks are independent mandatory gates
+ # owned by planner; this check proves the commanded arm path executed.
+ return ValidationResult(
+ (
+ ValidationCheck(
+ "task_success",
+ "passed" if endpoint <= 0.05 and tracking <= 0.08 else "failed",
+ "Measured arm endpoint and tracking",
+ {
+ "endpoint_error_rad": endpoint,
+ "tracking_error_rad": tracking,
+ },
+ ),
+ )
+ )
+
+ budget = 1024 * 1024
+ executor = QposRolloutExecutor(
+ host,
+ control_dt=_CONTROL_DT,
+ observe=observe,
+ validator=task_validator,
+ max_episode_bytes=budget,
+ contact_validator=planner,
+ )
+ sink = LeRobotEpisodeSink(output_dir, fps=20, max_episode_bytes=budget)
+ if record_video:
+ import imageio.v2 as imageio
+
+ writer = imageio.get_writer(
+ str(output_dir / "preview.mp4"),
+ format="FFMPEG",
+ fps=20,
+ codec="libx264",
+ pixelformat="yuv420p",
+ quality=8,
+ ffmpeg_log_level="error",
+ output_params=["-movflags", "+faststart"],
+ )
+ cases = tuple(
+ SceneCase(
+ f"cube_grasp_row_{row}",
+ f"fixed_yaw_{round(np.degrees(_YAW[row]))}",
+ "cube_table_v1",
+ "cube_pickup",
+ "ur5_dh_pgi",
+ )
+ for row in range(4)
+ )
+ limits = MotionLimitsProfile(
+ torch.full((robot.dof,), 1.5), torch.full((robot.dof,), 15.0)
+ )
+ runner = GenerationRunner(
+ cfg, host, planner, executor, sink, motion_limits=limits
+ )
+ report = dict(runner.run(cases, templates))
+ (output_dir / "pickup_report.json").write_text(
+ json.dumps(
+ {
+ "committed": report["counts"]["committed"],
+ "target_reached": report["target_reached"],
+ "prepared_batches": prepares,
+ "video_frames": frames,
+ "source": "offline AtomicActionEngine MoveEndEffector + PickUp export",
+ "contact_profile": planner.profile.to_dict(),
+ },
+ indent=2,
+ )
+ )
+ return report
+ finally:
+ if writer is not None:
+ writer.close()
+ if sink is not None:
+ sink.close()
+ if host is not None:
+ host.close()
+ if generator is not None:
+ generator.planner.close()
+ sim.destroy(exit_process=False)
+ SimulationManager.flush_cleanup_queue()
+
+
+def main() -> None:
+ """Run the collection CLI and exit unsuccessfully if its target was not met."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--episodes", type=int, default=8)
+ parser.add_argument("--seed", type=int, default=13)
+ parser.add_argument("--cuda-device", type=int, default=0)
+ parser.add_argument("--record-video", action="store_true")
+ args = parser.parse_args()
+ report = None
+ try:
+ report = run_cube_pickup_collection(
+ args.output,
+ episodes=args.episodes,
+ seed=args.seed,
+ cuda_device=args.cuda_device,
+ record_video=args.record_video,
+ )
+ except Exception as error:
+ diagnostic = traceback.TracebackException.from_exception(error)
+ # Release completed rollout frames before interpreter shutdown; their
+ # locals can otherwise retain native scene objects through a traceback.
+ traceback.clear_frames(error.__traceback__)
+ print("".join(diagnostic.format()), file=sys.stderr, end="")
+ if report is None:
+ raise SystemExit(1)
+ print(
+ json.dumps(
+ {
+ "target_reached": report["target_reached"],
+ "counts": dict(report["counts"]),
+ }
+ )
+ )
+ if not report["target_reached"]:
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/sim/motion/trajectory_generation/free_motion.py b/examples/sim/motion/trajectory_generation/free_motion.py
new file mode 100644
index 000000000..eba65403d
--- /dev/null
+++ b/examples/sim/motion/trajectory_generation/free_motion.py
@@ -0,0 +1,443 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Collect one measured UR5 free-motion episode with real physics and cuRobo.
+
+Run from the repository root with CUDA, cuRobo and LeRobot installed::
+
+ python examples/sim/motion/trajectory_generation/free_motion.py --output /tmp/ur5-free-motion
+
+Save a five-second offscreen camera preview::
+
+ python examples/sim/motion/trajectory_generation/free_motion.py --output /tmp/ur5-video --record-video --duration 5 --joint-displacement 0.4
+
+The pure-arm UR5 is fixed 0.5 m above the ground, without a gripper or held
+object. A registered cuboid models the simulator's implicit ground collider.
+Physics uses CPU; cuRobo and the optional offscreen camera use CUDA. Video is
+streamed to preview.mp4 at 20 fps, including the initial and terminal observation.
+No desktop window is required. This is a free-motion benchmark, not a PickUp task.
+All expert gates remain enabled, and a rejected episode stays out of LeRobot.
+Gravity remains enabled. ``--robot panda`` is a physical negative example:
+its moving gripper/mimic coordinates violate the current locked-hand model,
+so collection rejects the evidence and exits with status 1.
+"""
+
+from __future__ import annotations
+
+import argparse
+from collections.abc import Mapping
+import json
+import math
+from pathlib import Path
+import sys
+
+import torch
+
+_REPO_ROOT = Path(__file__).resolve().parents[4]
+if str(_REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(_REPO_ROOT))
+
+from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
+from embodichain.lab.sim.cfg import RigidBodyAttributesCfg
+from embodichain.lab.sim.motion.motion_generator import MotionGenCfg, MotionGenerator
+from embodichain.lab.sim.motion.planners import CuroboPlannerCfg, CuroboWorldCfg
+from embodichain.lab.sim.motion.expansion import (
+ CandidateTrajectoryBatch,
+ SceneCase,
+ TrajectoryGenerationJobCfg,
+ TrajectoryPhase,
+ TrajectoryTemplate,
+ ValidationCheck,
+ ValidationResult,
+)
+from embodichain.lab.sim.objects import RigidObjectCfg
+from embodichain.lab.sim.robots import FrankaPandaCfg, URRobotCfg
+from embodichain.lab.sim.shapes import CubeCfg
+from embodichain.lab.sim.sensors import CameraCfg
+from embodichain.lab.visualization import VisualizationCfg
+from embodichain.lab.trajectory_generation.execution import QposRolloutExecutor
+from embodichain.lab.trajectory_generation.initial_state import (
+ FixedSceneHost,
+ InitialStateProfile,
+)
+from embodichain.lab.trajectory_generation.integrations.planning import (
+ EnvRowMotionPlanner,
+)
+from embodichain.lab.trajectory_generation.integrations.sim import (
+ SimInitialStateAdapter,
+)
+from embodichain.lab.trajectory_generation.runner import (
+ GenerationRunner,
+ MotionLimitsProfile,
+)
+from embodichain.lab.trajectory_generation.sinks import LeRobotEpisodeSink
+
+__all__ = ["run_free_motion", "main"]
+
+
+def run_free_motion(
+ output_dir: Path,
+ *,
+ robot_type: str = "ur5",
+ cuda_device: int = 0,
+ record_video: bool = False,
+ duration_s: float = 1.0,
+ joint_displacement: float = 0.08,
+) -> Mapping[str, object]:
+ """Run one bounded collection and return the persisted final audit.
+
+ Args:
+ output_dir: Dedicated local LeRobot collection directory.
+ robot_type: ``ur5`` for the pure-arm benchmark; ``panda`` for the
+ rejection example with a moving articulated gripper.
+ cuda_device: GPU used by cuRobo and the headless simulator engine.
+ record_video: Stream actual RGB observations to ``preview.mp4`` in the
+ output directory. The numeric LeRobot observations retain their schema.
+ duration_s: Motion duration in seconds, a multiple of 0.05, up to 30.
+ joint_displacement: Positive first-arm-joint displacement in radians.
+
+ Returns:
+ The Runner report, including committed counts and validation diagnostics.
+ """
+ if robot_type not in {"ur5", "panda"}:
+ raise ValueError("robot_type must be ur5 or panda")
+ physics_dt, control_dt = 0.005, 0.05
+ if not math.isfinite(duration_s) or not control_dt <= duration_s <= 30:
+ raise ValueError("duration_s must be between 0.05 and 30 seconds")
+ steps = round(duration_s / control_dt)
+ if not math.isclose(steps * control_dt, duration_s, rel_tol=0, abs_tol=1e-8):
+ raise ValueError("duration_s must be a multiple of the 0.05 s control period")
+ if not math.isfinite(joint_displacement) or not 0 < joint_displacement <= 0.5:
+ raise ValueError("joint_displacement must be in (0, 0.5] radians")
+ sim = SimulationManager(
+ SimulationManagerCfg(
+ headless=True,
+ sim_device="cpu",
+ num_envs=1,
+ physics_dt=physics_dt,
+ gpu_id=cuda_device,
+ visualization=VisualizationCfg(),
+ )
+ )
+ host = sink = generator = video_writer = None
+ try:
+ cfg_type = URRobotCfg if robot_type == "ur5" else FrankaPandaCfg
+ robot_values = {
+ "uid": f"generation_{robot_type}",
+ "robot_type": robot_type,
+ "init_pos": [0.0, 0.0, 0.5],
+ "enable_gravity": True,
+ }
+ if robot_type == "ur5":
+ robot_values["init_qpos"] = [0.0, -1.57, 1.57, -1.57, -1.57, 0.0]
+ robot = sim.add_robot(cfg=cfg_type.from_dict(robot_values))
+ # SimulationManager creates an unregistered 1000 x 1000 x 100 m ground
+ # cuboid centered at z=-50.001. This explicit matching collider gives
+ # the trusted profile and cuRobo an owned representation of that floor.
+ floor = sim.add_rigid_object(
+ cfg=RigidObjectCfg(
+ uid="ground_proxy",
+ shape=CubeCfg(size=(1000.0, 1000.0, 100.0)),
+ attrs=RigidBodyAttributesCfg(),
+ body_type="kinematic",
+ init_pos=(0.0, 0.0, -50.001),
+ )
+ )
+ camera = (
+ sim.add_sensor(
+ CameraCfg(
+ uid="preview_camera",
+ visualization_role="record",
+ width=640,
+ height=480,
+ intrinsics=(520.0, 520.0, 320.0, 240.0),
+ extrinsics=CameraCfg.ExtrinsicsCfg(
+ eye=(0.6, -1.3, 1.35),
+ target=(-0.2, 0.0, 1.0),
+ ),
+ )
+ )
+ if record_video
+ else None
+ )
+ initial = torch.tensor([robot.cfg.init_qpos], device=robot.device)
+ zeros = torch.zeros_like(initial)
+ arm_ids = tuple(robot.get_joint_ids("arm"))
+
+ def prepare() -> None:
+ robot.set_gravity(True)
+ robot.set_qpos(initial, target=False)
+ robot.set_qvel(zeros, target=False)
+ robot.set_qpos(initial, target=True)
+ robot.set_qvel(zeros, target=True)
+ robot.set_qf(zeros)
+
+ def signature() -> str:
+ return json.dumps(
+ {
+ "robot": robot.cfg.to_dict(),
+ "physics": sim.sim_config.physics_config.to_dict(),
+ "floor_size": (1000.0, 1000.0, 100.0),
+ "floor_center": (0.0, 0.0, -50.001),
+ "camera": None if camera is None else camera.cfg.to_dict(),
+ "render": sim.sim_config.render_cfg.to_dict(),
+ },
+ sort_keys=True,
+ default=str,
+ )
+
+ def verify(cases: tuple[SceneCase, ...]) -> ValidationResult:
+ passed = (
+ len(cases) == 1
+ and torch.allclose(robot.get_qpos(), initial, atol=1e-6, rtol=0)
+ and torch.allclose(robot.get_qvel(), zeros, atol=1e-6, rtol=0)
+ )
+ return ValidationResult(
+ (
+ ValidationCheck(
+ "task_initial_state",
+ "passed" if passed else "failed",
+ "Neutral full-joint position and zero initial joint velocity.",
+ ),
+ )
+ )
+
+ host = FixedSceneHost(
+ SimInitialStateAdapter(sim, robot),
+ InitialStateProfile(
+ profile_id="fixed_scene_initial_state",
+ prepare=prepare,
+ signature=signature,
+ verify=verify,
+ physics_dt=physics_dt,
+ settling_steps=0,
+ ),
+ )
+ generator = MotionGenerator(
+ MotionGenCfg(
+ planner_cfg=CuroboPlannerCfg(
+ robot_uid=robot.uid,
+ cuda_device=cuda_device,
+ use_cuda_graph=False,
+ world=CuroboWorldCfg(
+ rigid_objects={floor.uid: floor},
+ dynamic_obstacle_names=[floor.uid],
+ obstacle_representation="cuboid",
+ ),
+ )
+ )
+ )
+ planner = EnvRowMotionPlanner(
+ generator,
+ control_part="arm",
+ held_object_ids=(),
+ max_joint_step=0.01,
+ max_validation_samples=max(128, steps + 1),
+ )
+
+ def task_validator(
+ candidate: CandidateTrajectoryBatch,
+ observations: Mapping[str, torch.Tensor],
+ actions: torch.Tensor,
+ timestamps: torch.Tensor,
+ ) -> ValidationResult:
+ measured = observations["joint_positions"]
+ expected = candidate.positions[0, : int(candidate.valid_length[0])].to(
+ measured
+ )
+ endpoint_error = float((measured[-1] - expected[-1]).abs().max())
+ tracking_error = float((measured - expected).abs().max())
+ movement = float(measured[-1, arm_ids[0]] - measured[0, arm_ids[0]])
+ gripper_ids = [index for index in range(robot.dof) if index not in arm_ids]
+ gripper_drift = (
+ float((measured[:, gripper_ids] - expected[:, gripper_ids]).abs().max())
+ if gripper_ids
+ else 0.0
+ )
+ passed = (
+ endpoint_error <= 0.01
+ and tracking_error <= 0.02
+ and movement >= 0.875 * joint_displacement
+ )
+ return ValidationResult(
+ (
+ ValidationCheck(
+ "task_success",
+ "passed" if passed else "failed",
+ "Actual endpoint, measured tracking, and observed arm displacement.",
+ {
+ "endpoint_error": endpoint_error,
+ "tracking_error": tracking_error,
+ "measured_displacement": movement,
+ "max_gripper_drift": gripper_drift,
+ },
+ ),
+ )
+ )
+
+ def observe() -> Mapping[str, torch.Tensor]:
+ if camera is not None:
+ camera.update()
+ rgb = (
+ camera.get_data()["color"][0, ..., :3]
+ .cpu()
+ .contiguous()
+ .numpy()
+ .copy()
+ )
+ video_writer.append_data(rgb)
+ return {
+ "joint_positions": robot.get_qpos(),
+ "joint_velocities": robot.get_qvel(),
+ }
+
+ executor = QposRolloutExecutor(
+ host,
+ control_dt=control_dt,
+ observe=observe,
+ validator=task_validator,
+ max_episode_bytes=128 * 1024,
+ )
+ sink = LeRobotEpisodeSink(
+ Path(output_dir), fps=20, max_episode_bytes=128 * 1024
+ )
+ if record_video:
+ import imageio.v2 as imageio
+
+ video_writer = imageio.get_writer(
+ str(sink.root / "preview.mp4"),
+ format="FFMPEG",
+ fps=20,
+ codec="libx264",
+ pixelformat="yuv420p",
+ quality=8,
+ ffmpeg_log_level="error",
+ output_params=["-movflags", "+faststart"],
+ )
+ limits = MotionLimitsProfile(
+ torch.tensor(
+ [1.0 if index in arm_ids else 0.1 for index in range(robot.dof)]
+ ),
+ torch.tensor(
+ [5.0 if index in arm_ids else 1.0 for index in range(robot.dof)]
+ ),
+ )
+ runner = GenerationRunner(
+ TrajectoryGenerationJobCfg.from_mapping(
+ {
+ "collection": {
+ "target_committed_episodes": 1,
+ "max_proposals": 1,
+ "max_rollout_attempts": 1,
+ "max_wall_time_s": 180.0,
+ }
+ }
+ ),
+ host,
+ planner,
+ executor,
+ sink,
+ motion_limits=limits,
+ )
+ time = torch.linspace(0.0, 1.0, steps + 1)
+ smooth = 10 * time**3 - 15 * time**4 + 6 * time**5
+ positions = initial.repeat(steps + 1, 1)
+ positions[:, arm_ids[0]] += joint_displacement * smooth
+ dt = torch.full((steps + 1,), control_dt, dtype=torch.float64)
+ dt[0] = 0
+ template = TrajectoryTemplate(
+ "handwritten_qpos",
+ f"v1-duration={duration_s!r}-displacement={joint_displacement!r}",
+ "reference_0",
+ tuple(robot.joint_names),
+ positions,
+ dt,
+ (TrajectoryPhase("free", 0, steps + 1),),
+ validator_id="task_success",
+ controlled_joint_indices=arm_ids,
+ )
+ case = SceneCase(
+ f"{robot_type}_free_motion",
+ "reference_start",
+ f"{robot_type}_elevated_ground_gravity_enabled",
+ "free_arm_motion",
+ robot.uid,
+ )
+ return runner.run((case,), (template,))
+ finally:
+ try:
+ if video_writer is not None:
+ video_writer.close()
+ finally:
+ if sink is not None:
+ sink.close()
+ if host is not None:
+ host.close()
+ if generator is not None:
+ generator.planner.close()
+ sim.destroy(exit_process=False)
+ SimulationManager.flush_cleanup_queue()
+
+
+def main() -> None:
+ """Parse the output location and run the measured free-motion benchmark."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument("--robot", choices=("ur5", "panda"), default="ur5")
+ parser.add_argument("--cuda-device", type=int, default=0)
+ parser.add_argument(
+ "--record-video",
+ action="store_true",
+ help="Save preview.mp4 using an offscreen RGB camera",
+ )
+ parser.add_argument(
+ "--duration",
+ type=float,
+ default=1.0,
+ help="Motion duration in seconds (multiple of 0.05, max 30)",
+ )
+ parser.add_argument(
+ "--joint-displacement",
+ type=float,
+ default=0.08,
+ help="First arm joint displacement in radians (0 < value <= 0.5)",
+ )
+ args = parser.parse_args()
+ report = run_free_motion(
+ args.output,
+ robot_type=args.robot,
+ cuda_device=args.cuda_device,
+ record_video=args.record_video,
+ duration_s=args.duration,
+ joint_displacement=args.joint_displacement,
+ )
+ video_path = args.output / "preview.mp4"
+ print(
+ json.dumps(
+ {
+ "counts": dict(report["counts"]),
+ "target_reached": report["target_reached"],
+ "report": str(args.output / "generation_report.json"),
+ "video": str(video_path) if video_path.is_file() else None,
+ }
+ )
+ )
+ if not report["target_reached"]:
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/pyproject.toml b/pyproject.toml
index 299eaf49c..c59a28778 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -52,6 +52,11 @@ dependencies = [
]
[project.optional-dependencies]
+trajectory-generation = [
+ "python-fcl>=0.7,<0.8",
+ "trimesh>=4.12,<5",
+ "yourdfpy>=0.0.60,<0.1",
+]
gensim = [
"bpy",
"gradio>=6.17.3,<6.18",
diff --git a/tests/gym/envs/test_demo.py b/tests/gym/envs/test_demo.py
index 5b5c3fa5a..3413deaca 100644
--- a/tests/gym/envs/test_demo.py
+++ b/tests/gym/envs/test_demo.py
@@ -347,6 +347,125 @@ def test_handwritten_motion_generator_segment_declares_exact_progress_total() ->
env._stack_block.clear_dynamics.assert_called_once()
+@pytest.mark.parametrize("single_segment", [False, True])
+def test_execute_demo_episode_consumes_explicit_candidate_without_replanning(
+ single_segment: bool,
+) -> None:
+ env = _SegmentedEnv()
+ env.create_demo_segments = Mock(side_effect=AssertionError("must not replan"))
+ env.metadata = {"dataset": {"instruction": {"lang": "Lift the selected object"}}}
+ env._begin_demo_episode_recording = Mock()
+ env._begin_demo_segment_recording = Mock()
+ env._end_demo_episode_recording = Mock()
+ candidate = DemoSegment(actions=(2, 3), name="candidate")
+
+ result = execute_demo_episode(
+ env,
+ segments=candidate if single_segment else (candidate,),
+ episode_index=4,
+ attempt_id=2,
+ )
+
+ env.create_demo_segments.assert_not_called()
+ assert env.actions == [2, 3]
+ assert result.all_success
+ assert result.segments[0].name == "candidate"
+ assert result.segments[0].instruction == "Lift the selected object"
+ assert candidate.instruction is None
+ assert all(env.no_auto_reset_during_steps)
+ assert not env._demo_no_auto_reset
+ env._begin_demo_episode_recording.assert_called_once()
+ env._begin_demo_segment_recording.assert_called_once()
+ assert env.segment_results == list(result.segments)
+ env._end_demo_episode_recording.assert_called_once_with(result=result)
+
+
+def test_explicit_candidate_segments_and_actions_remain_lazy() -> None:
+ env = _SegmentedEnv()
+ env.create_demo_segments = Mock(side_effect=AssertionError("must not replan"))
+
+ def first_actions():
+ assert env.actions == []
+ yield 1
+ assert env.actions == [1]
+ yield 2
+
+ def candidates():
+ yield DemoSegment(actions=first_actions(), name="first")
+ assert env.actions == [1, 2]
+ yield DemoSegment(actions=(3,), name="second")
+
+ result = execute_demo_episode(env, segments=candidates())
+
+ assert result.all_success
+ assert env.actions == [1, 2, 3]
+ env.create_demo_segments.assert_not_called()
+
+
+def test_explicit_candidate_rejects_planning_arguments_before_lifecycle() -> None:
+ env = _SegmentedEnv()
+ env.create_demo_segments = Mock()
+ env._begin_demo_episode_recording = Mock()
+
+ with pytest.raises(ValueError, match="cannot be combined with plan_kwargs"):
+ execute_demo_episode(env, segments=DemoSegment(actions=(3,)), goal="other")
+
+ env.create_demo_segments.assert_not_called()
+ env._begin_demo_episode_recording.assert_not_called()
+ assert env.actions == []
+
+
+@pytest.mark.parametrize("segments", [42, "candidate", [object()]])
+def test_explicit_candidate_rejects_invalid_segment_source(segments: Any) -> None:
+ env = _SegmentedEnv()
+ env.create_demo_segments = Mock()
+
+ with pytest.raises(TypeError, match="segments must.*DemoSegment"):
+ execute_demo_episode(env, segments=segments)
+
+ env.create_demo_segments.assert_not_called()
+ assert env.actions == []
+ assert not env._demo_no_auto_reset
+
+
+def test_explicit_candidate_validates_later_segments_when_requested() -> None:
+ env = _SegmentedEnv()
+
+ def candidates():
+ yield DemoSegment(actions=(1,))
+ assert env.actions == [1]
+ yield "invalid segment"
+
+ with pytest.raises(TypeError, match="segments must yield DemoSegment"):
+ execute_demo_episode(env, segments=candidates())
+
+ assert env.actions == [1]
+ assert not env._demo_no_auto_reset
+
+
+def test_empty_explicit_candidates_do_not_fall_back_to_task_planning() -> None:
+ env = _SegmentedEnv()
+ env.create_demo_segments = Mock()
+
+ result = execute_demo_episode(env, segments=())
+
+ env.create_demo_segments.assert_not_called()
+ assert env.actions == []
+ assert result.length == 0
+ assert not result.all_success
+
+
+def test_none_segments_preserves_forwarding_legacy_planning_arguments() -> None:
+ env = _SegmentedEnv()
+ env.create_demo_segments = Mock(return_value=DemoSegment(actions=(3,)))
+
+ result = execute_demo_episode(env, segments=None, target_uid="object_a")
+
+ env.create_demo_segments.assert_called_once_with(target_uid="object_a")
+ assert env.actions == [3]
+ assert result.all_success
+
+
class _LifecycleMetadataEnv:
"""Populate one shared metadata mapping at lazy lifecycle boundaries."""
@@ -472,11 +591,15 @@ def step(self, action: int):
)
-def test_action_generator_failure_safe_stops_before_propagating() -> None:
+@pytest.mark.parametrize("explicit_segments", [False, True])
+def test_action_generator_failure_safe_stops_before_propagating(
+ explicit_segments: bool,
+) -> None:
env = _GeneratorFailureEnv()
+ segments = env.create_demo_segments() if explicit_segments else None
with pytest.raises(RuntimeError, match="action generation") as error:
- execute_demo_episode(env)
+ execute_demo_episode(env, segments=segments)
assert isinstance(error.value.__cause__, ValueError)
assert env.actions == [1, 0]
@@ -551,11 +674,15 @@ def is_task_success(self) -> torch.Tensor:
return torch.ones(self.num_envs, dtype=torch.bool)
-def test_execute_demo_episode_supports_staggered_vector_success() -> None:
+@pytest.mark.parametrize("explicit_segments", [False, True])
+def test_execute_demo_episode_supports_staggered_vector_success(
+ explicit_segments: bool,
+) -> None:
"""A completed row freezes while unfinished rows continue their shared plan."""
env = _StaggeredVectorEnv()
- result = execute_demo_episode(env)
+ segments = env.create_demo_segments() if explicit_segments else None
+ result = execute_demo_episode(env, segments=segments)
assert env.actions == [1, 2, 3]
assert env.masked_actions == [(2, (False, True)), (3, (False, True))]
@@ -849,11 +976,17 @@ def _validate(self) -> torch.Tensor:
return torch.ones(1, dtype=torch.bool)
-def test_cancellation_after_last_action_does_not_advance_lazy_plan() -> None:
+@pytest.mark.parametrize("explicit_segments", [False, True])
+def test_cancellation_after_last_action_does_not_advance_lazy_plan(
+ explicit_segments: bool,
+) -> None:
"""Cancellation is observed before validation or requesting another segment."""
env = _CancellationEnv()
- result = execute_demo_episode(env, should_stop=lambda: bool(env.actions))
+ segments = env.create_demo_segments() if explicit_segments else None
+ result = execute_demo_episode(
+ env, segments=segments, should_stop=lambda: bool(env.actions)
+ )
assert env.actions == [1]
assert not env.validator_called
diff --git a/tests/gym/envs/test_fixed_scene_preparation.py b/tests/gym/envs/test_fixed_scene_preparation.py
new file mode 100644
index 000000000..ff9db6229
--- /dev/null
+++ b/tests/gym/envs/test_fixed_scene_preparation.py
@@ -0,0 +1,523 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Controlled Gym preparation without a simulator or implicit reset events."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import numpy as np
+import pytest
+import torch
+from tensordict import TensorDict
+
+from embodichain.lab.gym.envs.embodied_env import EmbodiedEnv
+from embodichain.lab.gym.envs.managers.record import record_camera_data
+from embodichain.lab.gym.utils.profiler import EnvProfiler
+from embodichain.lab.sim.motion.expansion import (
+ SceneCase,
+ ValidationCheck,
+ ValidationResult,
+)
+from embodichain.lab.trajectory_generation.initial_state import (
+ FixedSceneHost,
+ InitialStateProfile,
+)
+
+
+class _PreparationEnv(EmbodiedEnv):
+ """Exercise real preparation, reset, and step with CPU host ports."""
+
+ def __init__(self) -> None:
+ self.calls: list[str] = []
+ self._num_envs = 2
+ self.cfg = SimpleNamespace(
+ events=True,
+ observations=True,
+ rewards=True,
+ dataset=True,
+ trajectory_auto_save=False,
+ ignore_terminations=False,
+ sim_steps_per_control=1,
+ seed=None,
+ )
+ self.sim_cfg = SimpleNamespace(physics_dt=0.01)
+ self.sim = SimpleNamespace(
+ device=torch.device("cpu"),
+ num_envs=2,
+ update=Mock(),
+ reset_objects_state=Mock(),
+ capture_visualization_safely=Mock(),
+ )
+ self.robot = object()
+ self._profiler = EnvProfiler(None, torch.device("cpu"))
+ self._elapsed_steps = torch.full((2,), 7, dtype=torch.long)
+ self._task_success = torch.ones(2, dtype=torch.bool)
+ self.episode_success_status = torch.ones(2, dtype=torch.bool)
+ self._detached_uids_for_reset = []
+ self.max_episode_steps = 1
+ self.physical_value = torch.tensor([[90.0], [91.0]])
+ self.camera = object.__new__(record_camera_data)
+ self.camera.discard_and_clear = Mock(
+ side_effect=lambda **kwargs: self.calls.append("camera_discard")
+ )
+ self.camera.save_and_clear = Mock()
+ self.camera.finalize = Mock()
+ self.event_manager = SimpleNamespace(
+ _mode_functor_cfgs={"interval": [SimpleNamespace(func=self.camera)]},
+ available_modes=["reset"],
+ active_functors={},
+ apply=Mock(side_effect=lambda **kwargs: self.calls.append("event_reset")),
+ set_seed=Mock(),
+ reset=Mock(),
+ )
+ self.observation_manager = SimpleNamespace(
+ reset=Mock(side_effect=lambda **kwargs: self.calls.append("obs_reset"))
+ )
+ self.reward_manager = SimpleNamespace(
+ reset=Mock(side_effect=lambda **kwargs: self.calls.append("reward_reset"))
+ )
+ self.dataset_manager = SimpleNamespace(
+ reset=Mock(side_effect=lambda **kwargs: self.calls.append("dataset_reset")),
+ apply=Mock(),
+ available_modes=["save"],
+ save_failed_episodes=True,
+ )
+ self.rollout_buffer = TensorDict(
+ {
+ "obs": TensorDict({"state": torch.full((2, 3, 1), -1.0)}, [2, 3]),
+ "valid": torch.ones(2, 3, dtype=torch.bool),
+ },
+ [2, 3],
+ device="cpu",
+ )
+ self._rollout_buffer_mode = "expert"
+ self._max_rollout_steps = 3
+ self.rollout_steps = torch.full((2,), 2, dtype=torch.long)
+ self.current_rollout_step = 2
+ self._traj_steps = torch.full((2,), 2, dtype=torch.long)
+ self._traj_buffer = None
+ self._traj_raw_action = object()
+ self._demo_active_segment_ids = torch.full((2,), 3, dtype=torch.long)
+ self._demo_active_mask = torch.zeros(2, dtype=torch.bool)
+ self._demo_segment_participants = torch.ones(2, dtype=torch.bool)
+ self._demo_active_segment_start_steps = torch.full((2,), 2, dtype=torch.long)
+ self._demo_active_rollout_start_steps = torch.full((2,), 2, dtype=torch.long)
+ self._demo_steps = torch.full((2,), 2, dtype=torch.long)
+ self._demo_episode_metadata = [{"completed": True}, {"completed": True}]
+ self._active_task_program_bridge = object()
+ self._demo_no_auto_reset = False
+ self._replay_no_auto_reset = False
+ self._np_random = np.random.default_rng(17)
+
+ def get_obs(self, **kwargs):
+ self.calls.append("get_obs")
+ return TensorDict({"state": self.physical_value.clone()}, [2], device="cpu")
+
+ def compute_task_state(self, **kwargs):
+ self.calls.append("task_state")
+ return torch.zeros(2, dtype=torch.bool), torch.zeros(2, dtype=torch.bool), {}
+
+ def _seed_recording_state(self, obs, env_ids):
+ self.calls.append("seed_recording")
+ super()._seed_recording_state(obs, env_ids)
+
+ def _preprocess_action(self, action):
+ return action
+
+ def _step_action(self, action):
+ return action
+
+ def _postprocess_action(self, action):
+ return action
+
+ def get_reward(self, **kwargs):
+ return torch.zeros(2)
+
+ def _extend_reward(self, rewards, **kwargs):
+ return rewards
+
+ def _hook_after_sim_step(self, **kwargs):
+ pass
+
+ def check_truncated(self, **kwargs):
+ return torch.zeros(2, dtype=torch.bool)
+
+ def is_task_success(self, **kwargs):
+ return torch.zeros(2, dtype=torch.bool)
+
+
+def _prepare(env: _PreparationEnv, owner: object, *, verification=None):
+ def prepare():
+ env.calls.append("prepare")
+ assert env._active_task_program_bridge is None
+ assert env._elapsed_steps.tolist() == [0, 0]
+ assert not env.rollout_buffer["valid"].any()
+
+ def restore():
+ env.calls.append("restore")
+ env.physical_value = torch.tensor([[1.0], [2.0]])
+
+ def settle():
+ env.calls.append("settle")
+ env.physical_value += 1.0
+
+ def verify():
+ env.calls.append("verify")
+ if verification is not None:
+ return verification
+ return ValidationResult((ValidationCheck("initial_state", "passed"),))
+
+ return env.prepare_generation_episode(
+ owner, prepare=prepare, restore=restore, settle=settle, verify=verify
+ )
+
+
+def test_preparation_orders_cleanup_restore_verification_and_first_frame() -> None:
+ env = _PreparationEnv()
+ owner = object()
+ env.acquire_generation_lease(owner)
+ rng_state = env.np_random.bit_generator.state
+ torch_rng_state = torch.random.get_rng_state().clone()
+
+ obs, info = _prepare(env, owner)
+
+ assert env.calls == [
+ "camera_discard",
+ "prepare",
+ "restore",
+ "settle",
+ "obs_reset",
+ "reward_reset",
+ "dataset_reset",
+ "verify",
+ "get_obs",
+ "task_state",
+ "seed_recording",
+ ]
+ assert obs["state"].tolist() == [[2.0], [3.0]]
+ assert torch.equal(env.rollout_buffer["obs", "state"][:, 0], obs["state"])
+ assert info["elapsed_steps"].tolist() == [0, 0]
+ for counter in (env.rollout_steps, env._traj_steps, env._demo_steps):
+ assert counter.tolist() == [0, 0]
+ assert env.current_rollout_step == 0
+ assert not env.episode_success_status.any()
+ assert not env._task_success.any()
+ assert not env.rollout_buffer["valid"].any()
+ assert env._demo_active_mask.all()
+ assert not env._demo_segment_participants.any()
+ assert all(not metadata["completed"] for metadata in env._demo_episode_metadata)
+ assert env._traj_raw_action is None
+ assert env.np_random.bit_generator.state == rng_state
+ assert torch.equal(torch.random.get_rng_state(), torch_rng_state)
+ env.event_manager.apply.assert_not_called()
+ env.event_manager.reset.assert_not_called()
+ env.event_manager.set_seed.assert_not_called()
+ env.dataset_manager.apply.assert_not_called()
+ env.camera.save_and_clear.assert_not_called()
+ env.camera.finalize.assert_not_called()
+ env.sim.update.assert_not_called()
+ env.sim.reset_objects_state.assert_not_called()
+ assert env.observation_manager.reset.call_args.kwargs["env_ids"].tolist() == [0, 1]
+
+
+def test_lease_identity_blocks_foreign_calls_without_side_effects() -> None:
+ env = _PreparationEnv()
+ owner = object()
+ env.acquire_generation_lease(owner)
+ epoch = env.generation_epoch
+ env.acquire_generation_lease(owner)
+ assert env.generation_epoch == epoch
+ assert env.sim._trajectory_generation_owner is owner
+ for operation in (
+ lambda: env.acquire_generation_lease(object()),
+ lambda: env.release_generation_lease(object()),
+ lambda: _prepare(env, object()),
+ lambda: env.reset(seed=9),
+ lambda: env.step(torch.zeros(2, 1)),
+ ):
+ with pytest.raises(RuntimeError):
+ operation()
+ assert env.generation_epoch == epoch
+ assert env.sim._trajectory_generation_owner is owner
+ assert env.calls == []
+ env.event_manager.set_seed.assert_not_called()
+ env.sim.update.assert_not_called()
+
+
+@pytest.mark.parametrize(
+ "status", ["failed", "unavailable", "not_run", "empty", "wrong"]
+)
+def test_unverified_preparation_does_not_publish_first_frame_or_allow_step(
+ status,
+) -> None:
+ env = _PreparationEnv()
+ owner = object()
+ env.acquire_generation_lease(owner)
+ previous_epoch = env.generation_epoch
+ validation = (
+ object()
+ if status == "wrong"
+ else (
+ ValidationResult(())
+ if status == "empty"
+ else ValidationResult((ValidationCheck("initial_state", status),))
+ )
+ )
+ with pytest.raises((RuntimeError, TypeError), match="verification"):
+ _prepare(env, owner, verification=validation)
+ assert env.generation_epoch > previous_epoch
+ assert "get_obs" not in env.calls
+ assert "seed_recording" not in env.calls
+ assert not env._generation_preparing
+ assert not env.rollout_buffer["valid"].any()
+ with pytest.raises(RuntimeError, match="before stepping"):
+ env.step(torch.zeros(2, 1))
+ _prepare(env, owner)
+ assert env._generation_prepared
+
+
+def test_failed_restore_invalidates_previous_prepared_epoch() -> None:
+ env = _PreparationEnv()
+ owner = object()
+ env.acquire_generation_lease(owner)
+ _prepare(env, owner)
+ old_epoch = env.generation_epoch
+ env.calls.clear()
+ restore = Mock(side_effect=ValueError("restore failed"))
+ settle, verify = Mock(), Mock()
+ with pytest.raises(ValueError, match="restore failed"):
+ env.prepare_generation_episode(
+ owner, prepare=Mock(), restore=restore, settle=settle, verify=verify
+ )
+ assert env.generation_epoch > old_epoch
+ assert not env._generation_prepared
+ assert "seed_recording" not in env.calls
+ settle.assert_not_called()
+ verify.assert_not_called()
+ with pytest.raises(RuntimeError, match="before stepping"):
+ env.step(torch.zeros(2, 1))
+
+
+def test_generation_disables_auto_reset_until_release_without_touching_demo_flags() -> (
+ None
+):
+ env = _PreparationEnv()
+ owner = object()
+ env.acquire_generation_lease(owner)
+ _prepare(env, owner)
+ env.step(torch.zeros(2, 1))
+ assert env._elapsed_steps.tolist() == [1, 1]
+ env.sim.reset_objects_state.assert_not_called()
+ old_epoch = env.generation_epoch
+ env.calls.clear()
+ env._demo_no_auto_reset = True
+ env.release_generation_lease(owner)
+ assert env.generation_epoch > old_epoch
+ assert env._demo_no_auto_reset
+ assert not env._generation_no_auto_reset
+ assert env.calls == []
+ env.sim.reset_objects_state.assert_not_called()
+ env._demo_no_auto_reset = False
+ env.step(torch.zeros(2, 1))
+ env.sim.reset_objects_state.assert_called_once()
+ assert env._elapsed_steps.tolist() == [0, 0]
+
+
+def test_generation_lease_rejects_none_owner() -> None:
+ env = _PreparationEnv()
+ with pytest.raises(ValueError, match="None"):
+ env.acquire_generation_lease(None)
+ with pytest.raises(RuntimeError, match="lease"):
+ env.release_generation_lease(None)
+
+
+class _PhysicalAdapter:
+ """Fake physical port connected to the real Gym and host lifecycle."""
+
+ def __init__(self, env: _PreparationEnv) -> None:
+ self.env = env
+ self.sim = env.sim
+ self.robot = env.robot
+ self.accept_state = True
+
+ def signature(self) -> str:
+ return "fixed-two-row-robot"
+
+ def capture(self) -> torch.Tensor:
+ return self.env.physical_value.clone()
+
+ def restore(self, state: torch.Tensor) -> None:
+ self.env.physical_value.copy_(state)
+
+ def verify(self, state: torch.Tensor) -> ValidationResult:
+ accepted = self.accept_state and torch.equal(self.env.physical_value, state)
+ return ValidationResult(
+ (ValidationCheck("physical_initial", "passed" if accepted else "failed"),)
+ )
+
+
+def _host_fixture(env: _PreparationEnv):
+ adapter = _PhysicalAdapter(env)
+ profile = InitialStateProfile(
+ profile_id="fixed-test",
+ prepare=lambda: None,
+ signature=lambda: "fixed-conditions",
+ verify=lambda cases: ValidationResult(
+ (ValidationCheck("task_initial", "passed"),)
+ ),
+ settling_steps=1,
+ )
+ cases = tuple(
+ SceneCase(f"scene-{row}", f"initial-{row}", f"signature-{row}", "task", "robot")
+ for row in range(2)
+ )
+ return adapter, profile, cases
+
+
+def test_host_and_gym_share_settled_first_frame_and_current_epoch() -> None:
+ env = _PreparationEnv()
+ adapter, profile, cases = _host_fixture(env)
+ env.sim.update.side_effect = lambda dt, steps: env.physical_value.add_(0.5)
+ with FixedSceneHost(adapter, profile, env=env) as host:
+ first = host.acquire_case(cases)
+ settled = env.physical_value.clone()
+ assert settled.tolist() == [[90.5], [91.5]]
+ assert torch.equal(env.rollout_buffer["obs", "state"][:, 0], settled)
+ assert first.epoch == env.generation_epoch
+ assert host.verify_initial(first).accepted
+ calls_before_observation = list(env.calls)
+ observation = host.initial_observation(first)
+ assert torch.equal(observation["state"], settled)
+ observation["state"].zero_()
+ assert torch.equal(host.initial_observation(first)["state"], settled)
+ assert env.calls == calls_before_observation
+ env.sim.update.side_effect = None
+ env.physical_value += 10.0
+ restored = host.restore_initial()
+ assert torch.equal(env.physical_value, settled)
+ assert torch.equal(env.rollout_buffer["obs", "state"][:, 0], settled)
+ assert restored.epoch == env.generation_epoch > first.epoch
+ with pytest.raises(RuntimeError, match="obsolete"):
+ host.assert_current(first)
+ with pytest.raises(RuntimeError, match="obsolete"):
+ host.initial_observation(first)
+ host.assert_current(restored)
+ env.sim.reset_objects_state.assert_not_called()
+ env.event_manager.apply.assert_not_called()
+ assert not env._generation_no_auto_reset
+
+
+@pytest.mark.parametrize("operation", ["acquire", "restore"])
+def test_host_physical_verification_failure_never_seeds_gym_episode(operation) -> None:
+ env = _PreparationEnv()
+ adapter, profile, cases = _host_fixture(env)
+ with FixedSceneHost(adapter, profile, env=env) as host:
+ previous = host.acquire_case(cases) if operation == "restore" else None
+ adapter.accept_state = False
+ env.calls.clear()
+ with pytest.raises(RuntimeError, match="verification"):
+ if operation == "restore":
+ host.restore_initial()
+ else:
+ host.acquire_case(cases)
+ assert "get_obs" not in env.calls
+ assert "seed_recording" not in env.calls
+ assert not env.rollout_buffer["valid"].any()
+ with pytest.raises(RuntimeError, match="before stepping"):
+ env.step(torch.zeros(2, 1))
+ if previous is not None:
+ with pytest.raises(RuntimeError, match="obsolete"):
+ host.assert_current(previous)
+ adapter.accept_state = True
+ current = (
+ host.restore_initial()
+ if operation == "restore"
+ else host.acquire_case(cases)
+ )
+ host.assert_current(current)
+
+
+def test_host_verification_observes_reset_standard_manager_state() -> None:
+ env = _PreparationEnv()
+ adapter, profile, cases = _host_fixture(env)
+ env.reward_manager.dirty = True
+ env.reward_manager.reset.side_effect = lambda **kwargs: setattr(
+ env.reward_manager, "dirty", False
+ )
+ profile.verify = lambda cases: ValidationResult(
+ (
+ ValidationCheck(
+ "manager_initial", "failed" if env.reward_manager.dirty else "passed"
+ ),
+ )
+ )
+ with FixedSceneHost(adapter, profile, env=env) as host:
+ initial = host.acquire_case(cases)
+ assert host.verify_initial(initial).accepted
+ env.reward_manager.dirty = True
+ restored = host.restore_initial()
+ assert host.verify_initial(restored).accepted
+ assert env.reward_manager.reset.call_count == 2
+
+
+def test_gym_lease_prevents_a_second_pure_sim_host() -> None:
+ env = _PreparationEnv()
+ adapter, profile, _ = _host_fixture(env)
+ owner = object()
+ env.acquire_generation_lease(owner)
+ try:
+ with pytest.raises(RuntimeError, match="already has"):
+ FixedSceneHost(adapter, profile)
+ assert env._generation_lease_owner is owner
+ assert env.sim._trajectory_generation_owner is owner
+ finally:
+ env.release_generation_lease(owner)
+ assert env.sim._trajectory_generation_owner is None
+ with FixedSceneHost(adapter, profile):
+ pass
+
+
+def test_pure_sim_host_prevents_a_second_gym_lease() -> None:
+ env = _PreparationEnv()
+ adapter, profile, _ = _host_fixture(env)
+ with FixedSceneHost(adapter, profile) as host:
+ with pytest.raises(RuntimeError, match="already has"):
+ env.acquire_generation_lease(object())
+ assert env.sim._trajectory_generation_owner is host
+ assert getattr(env, "_generation_lease_owner", None) is None
+ owner = object()
+ env.acquire_generation_lease(owner)
+ env.release_generation_lease(owner)
+
+
+def test_foreign_sim_owner_cannot_be_cleared_by_stale_gym_lease() -> None:
+ env = _PreparationEnv()
+ owner, foreign = object(), object()
+ env.acquire_generation_lease(owner)
+ old_epoch = env.generation_epoch
+ env.sim._trajectory_generation_owner = foreign
+ for operation in (
+ lambda: env.acquire_generation_lease(owner),
+ lambda: env.release_generation_lease(owner),
+ ):
+ with pytest.raises(RuntimeError, match="does not own"):
+ operation()
+ assert env._generation_lease_owner is owner
+ assert env.sim._trajectory_generation_owner is foreign
+ assert env.generation_epoch == old_epoch
diff --git a/tests/lab/trajectory_generation/__init__.py b/tests/lab/trajectory_generation/__init__.py
new file mode 100644
index 000000000..3818cfd8e
--- /dev/null
+++ b/tests/lab/trajectory_generation/__init__.py
@@ -0,0 +1,19 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+__all__ = []
diff --git a/tests/lab/trajectory_generation/test_atomic_source.py b/tests/lab/trajectory_generation/test_atomic_source.py
new file mode 100644
index 000000000..e10ca2904
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_atomic_source.py
@@ -0,0 +1,103 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Atomic export preserves source ownership, phase boundaries and passive geometry."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from embodichain.lab.trajectory_generation.integrations.atomic import (
+ export_pickup_templates,
+)
+
+
+def _inputs():
+ q = torch.zeros(2, 9, 3)
+ q[:, :3, 0] = torch.tensor([0.0, 0.1, 0.2])
+ q[:, 3:, 0] = 0.2
+ q[:, 5:, 1] = 0.03
+ dt = torch.full((2, 9), 0.05, dtype=torch.float64)
+ dt[:, (0, 3)] = 0.0
+ compiled = SimpleNamespace(
+ action_plans=(
+ SimpleNamespace(skill_id="move_end_effector"),
+ SimpleNamespace(skill_id="pick_up"),
+ ),
+ plan_success=torch.ones(2, dtype=torch.bool),
+ trajectory=SimpleNamespace(positions=q, dt=dt, env_ids=torch.arange(2)),
+ action_waypoint_offset=lambda index: 3,
+ segment=lambda index, name: {
+ "approach": SimpleNamespace(start=3, stop=5),
+ "close": SimpleNamespace(start=5, stop=7),
+ "lift": SimpleNamespace(start=7, stop=9),
+ }[name],
+ )
+ robot = SimpleNamespace(
+ num_instances=2,
+ dof=3,
+ joint_names=("arm", "finger", "mimic"),
+ mimic_ids=(2,),
+ mimic_parents=(1,),
+ mimic_multipliers=(-1.0,),
+ mimic_offsets=(0.0,),
+ get_joint_ids=lambda name: (0,),
+ )
+ return compiled, robot
+
+
+def test_export_keeps_atomic_commands_and_declares_real_hold_and_mimic_geometry():
+ compiled, robot = _inputs()
+ originals = compiled.trajectory.positions.clone(), compiled.trajectory.dt.clone()
+ templates = export_pickup_templates(compiled, robot, control_dt=0.05, hold_steps=3)
+ assert len(templates) == 2
+ for template in templates:
+ assert template.positions.shape == (12, 3)
+ torch.testing.assert_close(template.positions[:9, :2], originals[0][0, :, :2])
+ torch.testing.assert_close(template.positions[:, 2], -template.positions[:, 1])
+ assert template.dt[0] == 0 and torch.all(template.dt[1:] == 0.05)
+ assert template.phases[0].allowed_operators == ("joint_residual",)
+ assert all(not p.allowed_operators for p in template.phases[1:])
+ assert template.phases[-1].start_index == 9
+ assert torch.equal(compiled.trajectory.positions, originals[0])
+ assert torch.equal(compiled.trajectory.dt, originals[1])
+ templates[0].positions.zero_()
+ assert templates[1].positions.abs().sum() > 0
+
+
+@pytest.mark.parametrize(
+ "failure",
+ ["failed_plan", "boundary", "clock", "row_order", "skill", "missing_segment"],
+)
+def test_export_rejects_incompatible_atomic_plan(failure):
+ compiled, robot = _inputs()
+ if failure == "failed_plan":
+ compiled.plan_success[0] = False
+ if failure == "boundary":
+ compiled.trajectory.positions[:, 3, 0] += 0.1
+ if failure == "clock":
+ compiled.trajectory.dt[:, 5] = 0.1
+ if failure == "row_order":
+ compiled.trajectory.env_ids = torch.tensor([1, 0])
+ if failure == "skill":
+ compiled.action_plans[1].skill_id = "place"
+ if failure == "missing_segment":
+ compiled.segment = lambda index, name: SimpleNamespace(start=0, stop=1)
+ with pytest.raises(ValueError):
+ export_pickup_templates(compiled, robot, control_dt=0.05)
diff --git a/tests/lab/trajectory_generation/test_contact.py b/tests/lab/trajectory_generation/test_contact.py
new file mode 100644
index 000000000..97f263c59
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_contact.py
@@ -0,0 +1,268 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Physical contact evidence, phase permissions and complete collision geometry."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from types import SimpleNamespace
+
+import numpy as np
+import pytest
+import torch
+
+from embodichain.lab.sim.motion.expansion import (
+ CandidateIdentity,
+ CandidateTrajectoryBatch,
+ MotionSnapshot,
+ SceneCase,
+ TrajectoryPhase,
+)
+from embodichain.lab.trajectory_generation.integrations.contact import (
+ PickUpContactProfile,
+ PickUpMotionValidator,
+)
+
+
+def _monitor():
+ validator = object.__new__(PickUpMotionValidator)
+ validator.profile = PickUpContactProfile()
+ validator.world = SimpleNamespace(adjacent_pairs=set())
+ validator.robot = SimpleNamespace(num_instances=2)
+ validator._users = {
+ 1: (0, "cube"),
+ 2: (0, "gripper_finger1_link_1"),
+ 3: (0, "gripper_finger2_link_1"),
+ 4: (0, "bench"),
+ 5: (0, "forearm"),
+ 11: (1, "cube"),
+ 12: (1, "gripper_finger1_link_1"),
+ }
+ data = np.zeros((2, 11))
+ data[:, 9] = 0.001
+ native = {"data": data, "users": np.array([[1, 2], [3, 1]])}
+ validator._physics = SimpleNamespace(
+ get_cpu_contact_buffer=lambda: (native["data"], native["users"])
+ )
+ pose = torch.eye(4).repeat(2, 1, 1)
+ pose[:, 2, 3] = 0.505
+ observed = {"object_pose": pose.clone(), "tcp_pose": pose.clone()}
+ validator.observations = lambda: observed
+ phases = (
+ TrajectoryPhase("transit", 0, 1),
+ TrajectoryPhase("approach", 1, 2, kind="contact"),
+ TrajectoryPhase("close", 2, 3, kind="contact"),
+ TrajectoryPhase("lift", 3, 4, kind="contact"),
+ TrajectoryPhase("hold", 4, 6, kind="hold"),
+ )
+ cases = [
+ SceneCase(f"case_{i}", "initial", "scene", "pickup", "robot") for i in range(2)
+ ]
+ initial = torch.eye(4)
+ initial[2, 3] = 0.325
+ snapshots = [
+ MotionSnapshot(
+ case,
+ ("joint",),
+ torch.zeros(1),
+ torch.zeros(1),
+ torch.eye(4),
+ {"cube": initial},
+ )
+ for case in cases
+ ]
+ candidates = [
+ CandidateTrajectoryBatch(
+ torch.zeros(1, 6, 1),
+ torch.tensor([[0.0, 0.05, 0.05, 0.05, 0.05, 0.05]]),
+ torch.tensor([6]),
+ (
+ CandidateIdentity(
+ case.scene_case_id,
+ "initial",
+ f"candidate_{i}",
+ f"family_{i}",
+ "source",
+ "v1",
+ "template",
+ ),
+ ),
+ ("joint",),
+ (phases,),
+ source_row_indices=torch.tensor([i]),
+ )
+ for i, case in enumerate(cases)
+ ]
+ validator.begin_rollout(candidates, snapshots)
+ return validator, native, observed
+
+
+def _hold(validator):
+ for _ in range(5):
+ validator.observe_substep(4, (True, False), physics_dt=0.25)
+ return validator.rollout_validation(0)
+
+
+def test_stable_lift_requires_both_real_finger_contacts():
+ validator, native, _ = _monitor()
+ assert _hold(validator).accepted
+ validator, native, _ = _monitor()
+ native["data"][1, 9] = 0.0 # Geometric proximity without normal impulse.
+ checks = {c.check_id: c for c in _hold(validator).checks}
+ assert checks["physical_contacts"].status == "passed"
+ assert checks["held_object_stability"].status == "failed"
+ assert checks["held_object_stability"].metrics["finger_1_contact_fraction"] == 0
+
+
+@pytest.mark.parametrize(
+ "failure", ["table", "robot", "unknown", "cross_row", "penetration"]
+)
+def test_forbidden_contact_rejects_even_a_stable_lift(failure):
+ validator, native, _ = _monitor()
+ if failure == "table":
+ native["users"][0] = (1, 4)
+ if failure == "robot":
+ native["users"][0] = (1, 5)
+ if failure == "unknown":
+ native["users"][0] = (1, 9999)
+ if failure == "cross_row":
+ native["users"][0] = (1, 11)
+ if failure == "penetration":
+ native["data"][0, 10] = -0.003
+ checks = {c.check_id: c for c in _hold(validator).checks}
+ assert checks["physical_contacts"].status == "failed"
+
+
+@pytest.mark.parametrize("failure", ["slip", "rotate", "fall", "empty", "short"])
+def test_hold_rejects_slipping_rotating_falling_and_empty_grasps(failure):
+ validator, _, observed = _monitor()
+ validator.observe_substep(4, (True, False), physics_dt=0.25)
+ if failure == "slip":
+ observed["object_pose"][0, 0, 3] += 0.02
+ if failure == "rotate":
+ a = 0.25
+ observed["object_pose"][0, :2, :2] = torch.tensor(
+ [[np.cos(a), -np.sin(a)], [np.sin(a), np.cos(a)]]
+ )
+ if failure == "fall":
+ observed["object_pose"][0, 2, 3] = 0.325
+ if failure == "empty":
+ observed["object_pose"][0, 0, 3] += 0.1
+ result = validator.rollout_validation(0) if failure == "short" else _hold(validator)
+ assert result.checks[1].status == "failed"
+
+
+def test_contact_permissions_depend_on_phase_and_grasp_entry_distance():
+ validator, _, _ = _monitor()
+ finger = validator.profile.finger_links[0]
+ assert not validator._allowed("cube", finger, "transit", 0.0, 0.01)
+ assert not validator._allowed("cube", finger, "approach", 0.0, 0.05)
+ assert validator._allowed("cube", finger, "approach", 0.0, 0.01)
+ assert validator._allowed("cube", finger, "close", 0.0)
+ assert not validator._allowed("cube", "forearm", "close", 0.0)
+ assert validator._allowed("cube", "bench", "lift", 0.001)
+ assert not validator._allowed("cube", "bench", "hold", 0.001)
+
+
+@pytest.mark.parametrize("failure", ["overflow", "nan", "shape"])
+def test_missing_or_unbounded_native_evidence_raises(failure):
+ validator, native, _ = _monitor()
+ if failure == "overflow":
+ validator.profile.max_contacts_per_step = 1
+ if failure == "nan":
+ native["data"][0, 9] = np.nan
+ if failure == "shape":
+ native["users"] = np.zeros((1, 2))
+ with pytest.raises(ValueError, match="Native contact evidence"):
+ _hold(validator)
+
+
+def test_unobserved_hold_cannot_pass():
+ validator, _, _ = _monitor()
+ assert not validator.rollout_validation(0).accepted
+
+
+def test_full_state_collision_uses_collision_shapes_and_moving_fingers(tmp_path):
+ pytest.importorskip("fcl")
+ pytest.importorskip("yourdfpy")
+ import pytorch_kinematics as pk
+ from embodichain.lab.sim.shapes import CubeCfg
+ from embodichain.lab.trajectory_generation.integrations._collision import (
+ _FullStateCollisionWorld,
+ )
+
+ # Deliberately no visual mesh: a render-geometry fallback would miss this finger.
+ urdf = """
+
+ """
+ path = tmp_path / "robot.urdf"
+ path.write_text(urdf)
+ obj = SimpleNamespace(
+ cfg=SimpleNamespace(shape=CubeCfg(size=(0.05, 0.05, 0.05))),
+ get_body_scale=lambda: torch.ones(1, 3),
+ )
+ sim = SimpleNamespace(
+ get_rigid_object_uid_list=lambda: ("cube", "bench"),
+ get_rigid_object=lambda uid: obj,
+ )
+ robot = SimpleNamespace(
+ cfg=SimpleNamespace(body_scale=(1.0, 1.0, 1.0), fpath=str(path)),
+ device=torch.device("cpu"),
+ joint_names=("slide",),
+ pk_chain=pk.build_chain_from_urdf(urdf),
+ )
+ world = _FullStateCollisionWorld(sim, robot)
+ root = torch.eye(4)
+ root[2, 3] = 1.0
+ poses = world.link_poses(torch.tensor([[0.0], [0.1]]), root)
+ cube = np.eye(4)
+ cube[:3, 3] = (0.2, 0.0, 1.0)
+ bench = np.eye(4)
+ bench[:3, 3] = (0.5, 0.0, 1.0)
+ objects = {"cube": cube, "bench": bench}
+ assert (
+ world.collisions(
+ {"finger": poses["finger"][0]}, objects, "cube", lambda a, b: False
+ )
+ == "finger / cube"
+ )
+ assert (
+ world.collisions(
+ {"finger": poses["finger"][1]}, objects, "cube", lambda a, b: False
+ )
+ is None
+ )
+ objects["cube"] = bench.copy() # Held object can collide while the arm clears.
+ assert (
+ world.collisions(
+ {"finger": poses["finger"][1]}, objects, "cube", lambda a, b: False
+ )
+ == "cube / bench"
+ )
+
+
+def test_cross_row_contact_rejects_active_second_body_when_first_row_is_idle():
+ validator, native, _ = _monitor()
+ native["users"][0] = (11, 1)
+ assert _hold(validator).checks[0].status == "failed"
+
+
+def test_slip_during_lift_is_not_hidden_by_a_stable_terminal_hold():
+ validator, _, observed = _monitor()
+ validator.observe_substep(3, (True, False), physics_dt=0.25)
+ observed["object_pose"][0, 0, 3] += 0.02
+ assert _hold(validator).checks[1].status == "failed"
diff --git a/tests/lab/trajectory_generation/test_cube_grasp_parallel.py b/tests/lab/trajectory_generation/test_cube_grasp_parallel.py
new file mode 100644
index 000000000..cff6fd9e0
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_cube_grasp_parallel.py
@@ -0,0 +1,132 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Physical cube-pickup augmentation, sustained-grasp checks, and video evidence."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import subprocess
+import sys
+
+import numpy as np
+import pytest
+
+
+def test_grasp_results_reject_fallen_slipping_and_distant_objects() -> None:
+ from examples.sim.motion.trajectory_generation.cube_grasp_parallel import (
+ _grasp_results,
+ )
+
+ cubes = np.tile(np.eye(4), (4, 6, 1, 1))
+ cubes[:, 0, 2, 3] = 0.325
+ cubes[:, 1:, 2, 3] = 0.505
+ tcps = cubes.copy()
+ cubes[1, -1, 2, 3] = 0.325 # Fell after initially reaching the lift target.
+ cubes[2, -1, 0, 3] += 0.02 # Slipped relative to the TCP during the hold.
+ cubes[3, 1:, 0, 3] += 0.1 # Elevated, but never near the gripper.
+ results = _grasp_results(cubes, tcps, hold_start=3)
+ assert [result["success"] for result in results] == [True, False, False, False]
+
+
+@pytest.mark.gpu
+@pytest.mark.slow
+@pytest.mark.requires_sim
+def test_parallel_cube_pickups_preserve_contact_commands_and_save_synchronized_video(
+ tmp_path: Path,
+) -> None:
+ av = pytest.importorskip("av")
+ root = Path(__file__).resolve().parents[3]
+ output = tmp_path / "parallel"
+ # Run the real entry point in a child process to exercise native cleanup
+ # and the exit status as well as the physics and recorder.
+ process = subprocess.run(
+ [
+ sys.executable,
+ "examples/sim/motion/trajectory_generation/cube_grasp_parallel.py",
+ "--output",
+ str(output),
+ ],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ timeout=180,
+ )
+ assert process.returncode == 0, (process.stdout + process.stderr)[-8000:]
+ report = json.loads((output / "report.json").read_text())
+ assert report["success"]
+ assert [row["yaw_degrees"] for row in report["results"]] == [0, 0, 90, 90]
+ assert all(
+ row["success"] and row["min_hold_lift_m"] >= 0.12 for row in report["results"]
+ )
+ assert min(report["max_path_pair_separation_m"]) > 0.03
+ with np.load(output / "rollout.npz") as saved:
+ targets = saved["commanded_qpos"]
+ reference = saved["reference_qpos"]
+ transit_stop = report["transit_stop"]
+ np.testing.assert_array_equal(
+ targets[:, transit_stop - 1 : reference.shape[1]],
+ reference[:, transit_stop - 1 :],
+ )
+ assert not np.array_equal(
+ targets[1, 1 : transit_stop - 1], reference[1, 1 : transit_stop - 1]
+ )
+ assert targets.shape == saved["measured_qpos"].shape
+ np.testing.assert_allclose(
+ saved["measured_qpos"][:, 0],
+ np.repeat(saved["measured_qpos"][0:1, 0], 4, axis=0),
+ atol=1e-6,
+ rtol=0,
+ )
+ np.testing.assert_allclose(
+ saved["cube_poses"][:, 0],
+ np.repeat(saved["cube_poses"][0:1, 0], 4, axis=0),
+ atol=1e-5,
+ rtol=0,
+ )
+ # The quarter-turn changes the closing orientation while preserving
+ # the centered grasp position and downward approach direction.
+ grasps = saved["grasp_poses"]
+ np.testing.assert_allclose(
+ grasps[:, :3, 3], np.repeat(grasps[0:1, :3, 3], 4, axis=0), atol=1e-6
+ )
+ assert abs(np.dot(grasps[0, :3, 0], grasps[2, :3, 0])) < 1e-5
+ actual_rotations = saved["tcp_poses"][:, -1, :3, :3]
+ assert abs(np.dot(actual_rotations[0, :, 0], actual_rotations[2, :, 0])) < 0.05
+ times = saved["timestamps"]
+ np.testing.assert_allclose(
+ times, np.arange(len(times)) / report["fps"], atol=1e-7
+ )
+ with av.open(str(output / "preview.mp4")) as video:
+ stream = video.streams.video[0]
+ assert stream.codec_context.name == "h264"
+ assert (stream.width, stream.height) == (1280, 1056)
+ assert stream.average_rate == report["fps"] == 20
+ frames = list(video.decode(video=0))
+ assert len(frames) == report["frame_count"] == len(times)
+ np.testing.assert_allclose(
+ [float(frame.pts * frame.time_base) for frame in frames], times, atol=1e-7
+ )
+ first = frames[0].to_ndarray(format="rgb24")
+ last = frames[-1].to_ndarray(format="rgb24")
+ for row in range(4):
+ top = (row // 2) * 528 + 48 # Exclude the changing text header.
+ left = (row % 2) * 640
+ initial = first[top : top + 480, left : left + 640].astype(float)
+ final = last[top : top + 480, left : left + 640].astype(float)
+ assert initial.std() > 1
+ assert np.abs(initial - final).mean() > 1
diff --git a/tests/lab/trajectory_generation/test_episode_sinks.py b/tests/lab/trajectory_generation/test_episode_sinks.py
new file mode 100644
index 000000000..eb959eed6
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_episode_sinks.py
@@ -0,0 +1,331 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Real LeRobot episode sealing, causal evidence, and submission retries."""
+
+from __future__ import annotations
+
+import json
+from dataclasses import replace
+from pathlib import Path
+
+import numpy as np
+import pytest
+import torch
+
+from embodichain.lab.sim.motion.expansion import (
+ CandidateIdentity,
+ ExpertEpisode,
+ ValidationCheck,
+ ValidationResult,
+)
+from embodichain.lab.trajectory_generation.sinks import LeRobotEpisodeSink
+
+_FPS = 20
+_STEPS = 3
+
+
+def _episode(commit: str = "commit-1", *, rgb: bool = False) -> ExpertEpisode:
+ observations = {
+ "joint_positions": torch.arange((_STEPS + 1) * 2, dtype=torch.float32).reshape(
+ _STEPS + 1, 2
+ )
+ / 10,
+ "scalar": torch.arange(_STEPS + 1, dtype=torch.int64).reshape(-1, 1),
+ }
+ if rgb:
+ observations["images.front"] = torch.arange(
+ (_STEPS + 1) * 4 * 5 * 3, dtype=torch.uint8
+ ).reshape(_STEPS + 1, 4, 5, 3)
+ return ExpertEpisode(
+ identity=CandidateIdentity(
+ "case",
+ "initial",
+ f"candidate-{commit}",
+ "family",
+ "source",
+ "revision",
+ "template",
+ ),
+ observations=observations,
+ actions=torch.tensor([[0.1], [0.2], [0.3]]),
+ timestamps=7 + torch.arange(_STEPS + 1, dtype=torch.float64) / _FPS,
+ action_representation="qpos",
+ validation=ValidationResult((ValidationCheck("task_success", "passed"),)),
+ episode_id=f"episode-{commit}",
+ commit_id=commit,
+ metadata={"task": "move the cube", "nested": {"actual": True}},
+ )
+
+
+def test_real_lerobot_sealing_preserves_causal_frames_rgb_and_terminal_evidence(
+ tmp_path: Path,
+) -> None:
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
+
+ episode = _episode(rgb=True)
+ with LeRobotEpisodeSink(tmp_path / "collection", fps=_FPS) as sink:
+ receipt = sink.submit(episode)
+ assert receipt.confirmed, receipt.error
+ assert sink.drain() == ()
+ shard = Path(receipt.storage_id)
+ dataset = LeRobotDataset(
+ repo_id="embodichain/trajectory-generation", root=shard / "dataset"
+ )
+ assert dataset.num_episodes == 1
+ assert len(dataset) == _STEPS
+ assert torch.equal(
+ dataset[0]["observation.joint_positions"],
+ episode.observations["joint_positions"][0],
+ )
+ assert dataset[_STEPS - 1]["action"].item() == pytest.approx(
+ episode.actions[-1].item()
+ )
+ assert dataset[0]["observation.images.front"].shape == (3, 4, 5)
+ with np.load(shard / "terminal.npz", allow_pickle=False) as evidence:
+ np.testing.assert_array_equal(
+ evidence["timestamps"], episode.timestamps.numpy()
+ )
+ np.testing.assert_array_equal(
+ evidence["observation.joint_positions"],
+ episode.observations["joint_positions"][-1].numpy(),
+ )
+ np.testing.assert_array_equal(
+ evidence["observation.images.front"],
+ episode.observations["images.front"][-1].numpy(),
+ )
+ metadata = json.loads((shard / "episode.json").read_text())
+ assert metadata["identity"]["candidate_id"] == episode.identity.candidate_id
+ assert metadata["metadata"] == {"task": "move the cube", "nested": {"actual": True}}
+ assert metadata["validation"][0]["status"] == "passed"
+ manifest = json.loads((shard.parent / "manifest.json").read_text())
+ assert [item["commit_id"] for item in manifest["episodes"]] == [episode.commit_id]
+
+
+def test_same_commit_is_idempotent_and_new_commits_continue_after_finalization(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ with LeRobotEpisodeSink(tmp_path / "collection", fps=_FPS) as sink:
+ calls = []
+ original = sink._write_dataset
+
+ def write(*args):
+ calls.append(args[0])
+ return original(*args)
+
+ monkeypatch.setattr(sink, "_write_dataset", write)
+ first = sink.submit(_episode())
+ duplicate = sink.submit(_episode(), submission_id=1)
+ second = sink.submit(_episode("commit-2"))
+ assert first.confirmed and duplicate.confirmed and second.confirmed
+ assert first.storage_id == duplicate.storage_id
+ assert duplicate.submission_id == 1
+ assert len(calls) == 2
+ manifest = json.loads((sink.root / "manifest.json").read_text())
+ assert len(manifest["episodes"]) == 2
+
+
+@pytest.mark.parametrize(
+ "failure_stage",
+ ["_write_dataset", "_write_evidence", "_write_manifest", "_verify_evidence"],
+)
+def test_failure_receipt_retry_reuses_one_logical_episode(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure_stage: str
+) -> None:
+ with LeRobotEpisodeSink(tmp_path / "collection", fps=_FPS) as sink:
+ original = getattr(sink, failure_stage)
+
+ def fail_after_success(*args):
+ original(*args)
+ raise OSError("injected persistence failure")
+
+ monkeypatch.setattr(sink, failure_stage, fail_after_success)
+ failed = sink.submit(_episode())
+ assert not failed.confirmed
+ assert "injected persistence failure" in failed.error
+ monkeypatch.setattr(sink, failure_stage, original)
+
+ # The first attempt already sealed the shard even when a later barrier
+ # failed. Retrying must never create another training episode.
+ def reject_rewrite(*args):
+ raise AssertionError("A sealed shard must be reused")
+
+ monkeypatch.setattr(sink, "_write_dataset", reject_rewrite)
+ accepted = sink.submit(_episode(), submission_id=1)
+ assert accepted.confirmed, accepted.error
+ assert accepted.storage_id == failed.storage_id
+ assert (
+ len(json.loads((sink.root / "manifest.json").read_text())["episodes"]) == 1
+ )
+
+
+def test_partial_writer_failure_can_rebuild_same_uncommitted_shard(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
+
+ original = LeRobotDataset.add_frame
+ with LeRobotEpisodeSink(tmp_path / "collection", fps=_FPS) as sink:
+
+ def fail_frame(*args):
+ raise OSError("frame write failed")
+
+ monkeypatch.setattr(LeRobotDataset, "add_frame", fail_frame)
+ failed = sink.submit(_episode())
+ assert not failed.confirmed
+ assert not (sink.root / "manifest.json").exists()
+ monkeypatch.setattr(LeRobotDataset, "add_frame", original)
+ accepted = sink.submit(_episode(), submission_id=1)
+ assert accepted.confirmed, accepted.error
+ assert accepted.storage_id == failed.storage_id
+
+
+def test_changed_payload_under_same_commit_is_rejected(tmp_path: Path) -> None:
+ with LeRobotEpisodeSink(tmp_path / "collection", fps=_FPS) as sink:
+ episode = _episode()
+ assert sink.submit(episode).confirmed
+ changed = replace(episode, actions=episode.actions + 0.1)
+ with pytest.raises(ValueError, match="changed episode payload"):
+ sink.submit(changed, submission_id=1)
+ assert (
+ len(json.loads((sink.root / "manifest.json").read_text())["episodes"]) == 1
+ )
+
+
+@pytest.mark.parametrize(
+ "observation",
+ [
+ torch.zeros(_STEPS + 1, 0, 2),
+ torch.zeros(_STEPS + 1, 2, dtype=torch.bool),
+ torch.zeros(_STEPS + 1, 4, 5, 1, dtype=torch.uint8),
+ torch.zeros(_STEPS + 1, 4, 5, 3),
+ ],
+)
+def test_unsupported_observations_fail_before_shard_write(
+ tmp_path: Path, observation: torch.Tensor
+) -> None:
+ with LeRobotEpisodeSink(tmp_path / "collection", fps=_FPS) as sink:
+ episode = replace(_episode(), observations={"unsupported": observation})
+ with pytest.raises(ValueError, match="Unsupported observation"):
+ sink.submit(episode)
+ assert tuple(sink.root.iterdir()) == (sink.root / ".writer.lock",)
+
+
+def test_nonuniform_clock_size_and_failed_validation_rejected_before_writes(
+ tmp_path: Path,
+) -> None:
+ with LeRobotEpisodeSink(tmp_path / "collection", fps=_FPS) as sink:
+ episode = _episode()
+ times = episode.timestamps.clone()
+ times[-1] += 0.01
+ with pytest.raises(ValueError, match="fixed control clock"):
+ sink.submit(replace(episode, timestamps=times))
+ failed = ValidationResult((ValidationCheck("task_success", "failed"),))
+ with pytest.raises(ValueError, match="accepted episodes"):
+ sink.submit(replace(episode, validation=failed))
+ with LeRobotEpisodeSink(
+ tmp_path / "bounded", fps=_FPS, max_episode_bytes=1
+ ) as sink:
+ with pytest.raises(ValueError, match="max_episode_bytes"):
+ sink.submit(_episode())
+
+
+def test_terminal_corruption_cannot_return_confirmed_receipt(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ with LeRobotEpisodeSink(tmp_path / "collection", fps=_FPS) as sink:
+ original = sink._write_evidence
+
+ def corrupt(path, *args):
+ original(path, *args)
+ (path / "terminal.npz").write_bytes(b"invalid")
+
+ monkeypatch.setattr(sink, "_write_evidence", corrupt)
+ receipt = sink.submit(_episode())
+ assert not receipt.confirmed
+ assert not (sink.root / "manifest.json").exists()
+
+
+def test_sink_rejects_existing_collection_and_closed_submissions(
+ tmp_path: Path,
+) -> None:
+ root = tmp_path / "collection"
+ sink = LeRobotEpisodeSink(root, fps=_FPS)
+ with pytest.raises(ValueError, match="new or empty"):
+ LeRobotEpisodeSink(root, fps=_FPS)
+ sink.close()
+ sink.close()
+ with pytest.raises(RuntimeError, match="closed"):
+ sink.submit(_episode())
+
+
+def test_float64_storage_preserves_precision_beyond_torch_reader_defaults(
+ tmp_path: Path,
+) -> None:
+ precise = torch.tensor(
+ [[0.123456789123456, 0.987654321987654]], dtype=torch.float64
+ )
+ episode = replace(
+ _episode(),
+ observations={"precise": precise.repeat(_STEPS + 1, 1)},
+ actions=precise.repeat(_STEPS, 1),
+ )
+ with LeRobotEpisodeSink(tmp_path / "collection", fps=_FPS) as sink:
+ receipt = sink.submit(episode)
+ assert receipt.confirmed, receipt.error
+
+
+def test_confirmed_duplicate_only_reads_existing_artifacts(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ with LeRobotEpisodeSink(tmp_path / "collection", fps=_FPS) as sink:
+ first = sink.submit(_episode())
+ assert first.confirmed, first.error
+
+ def no_rewrite(*args):
+ raise AssertionError("Confirmed duplicates must not write again")
+
+ monkeypatch.setattr(sink, "_write_dataset", no_rewrite)
+ monkeypatch.setattr(sink, "_write_evidence", no_rewrite)
+ monkeypatch.setattr(sink, "_write_manifest", no_rewrite)
+ duplicate = sink.submit(_episode(), submission_id=1)
+ assert duplicate.confirmed, duplicate.error
+
+
+@pytest.mark.parametrize("dtype", [torch.float32, torch.float64])
+def test_numeric_pose_matrices_roundtrip_with_original_shape(
+ tmp_path: Path, dtype: torch.dtype
+) -> None:
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
+
+ poses = torch.eye(4, dtype=dtype).repeat(_STEPS + 1, 1, 1)
+ poses[:, :3, 3] = torch.arange((_STEPS + 1) * 3, dtype=dtype).reshape(-1, 3) / 7
+ episode = replace(_episode(), observations={"object_pose": poses})
+ with LeRobotEpisodeSink(tmp_path / "matrix", fps=_FPS) as sink:
+ receipt = sink.submit(episode)
+ assert receipt.confirmed, receipt.error
+ shard = Path(receipt.storage_id)
+ metadata = json.loads((shard / "episode.json").read_text())
+ assert metadata["observation_shapes"]["object_pose"] == [4, 4]
+ assert metadata["features"]["observation.object_pose"]["shape"] == [16]
+ dataset = LeRobotDataset(
+ repo_id="embodichain/trajectory-generation", root=shard / "dataset"
+ )
+ assert dataset[0]["observation.object_pose"].shape == (16,)
+ with np.load(shard / "terminal.npz", allow_pickle=False) as terminal:
+ np.testing.assert_array_equal(
+ terminal["observation.object_pose"], poses[-1].numpy()
+ )
diff --git a/tests/lab/trajectory_generation/test_execution.py b/tests/lab/trajectory_generation/test_execution.py
new file mode 100644
index 000000000..2ccb51140
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_execution.py
@@ -0,0 +1,684 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Causal execution through real Gym control ports and deterministic CPU physics."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+import torch
+from tensordict import TensorDict
+
+from embodichain.lab.gym.envs.embodied_env import EmbodiedEnv
+from embodichain.lab.gym.utils.profiler import EnvProfiler
+from embodichain.lab.sim.motion.expansion import (
+ CandidateIdentity,
+ CandidateTrajectoryBatch,
+ SceneCase,
+ TrajectoryPhase,
+ ValidationCheck,
+ ValidationResult,
+)
+from embodichain.lab.trajectory_generation.execution import QposRolloutExecutor
+
+
+def _passed(*args) -> ValidationResult:
+ return ValidationResult((ValidationCheck("task_success", "passed"),))
+
+
+class _Robot:
+ def __init__(self) -> None:
+ self.joint_names = ("arm", "locked")
+ self.active_joint_ids = [0]
+ self.qpos = torch.tensor([[0.0, 0.3], [0.0, 0.3]])
+ self.target = self.qpos.clone()
+ self.target_velocity = torch.zeros_like(self.qpos)
+ self.force = torch.zeros_like(self.qpos)
+ self.pose = torch.eye(4).repeat(2, 1, 1)
+ self.commands = []
+ self.offset = 0.0
+
+ def get_qpos(self, target=False):
+ return self.target if target else self.qpos
+
+ def get_qpos_limits(self):
+ return torch.tensor([-1.0, 1.0]).repeat(2, 2, 1)
+
+ def get_qvel(self, target=False):
+ assert target
+ return self.target_velocity
+
+ def get_qf(self):
+ return self.force
+
+ def get_local_pose(self, to_matrix=False):
+ assert to_matrix
+ return self.pose
+
+ def set_qpos(self, qpos, *, joint_ids, target=True):
+ assert target
+ self.target[:, joint_ids] = qpos + self.offset
+ self.commands.append(self.target.clone())
+
+ def set_qvel(self, qvel, *, joint_ids, target=True):
+ self.target_velocity[:, joint_ids] = qvel
+
+ def set_qf(self, qf, *, joint_ids):
+ self.force[:, joint_ids] = qf
+
+
+class _Sim:
+ num_envs = 2
+ device = torch.device("cpu")
+
+ def __init__(self, robot):
+ self.robot = robot
+ self.simulation_time = 11.0
+ self.updates = []
+ self.fail_at = None
+ self.clock_scale = 1.0
+ self.after_update = lambda: None
+ self.cube = SimpleNamespace(pose=torch.eye(4).repeat(2, 1, 1))
+ self.cube.get_local_pose = lambda to_matrix: self.cube.pose
+
+ def update(self, dt, steps):
+ self.updates.append((dt, steps))
+ if len(self.updates) == self.fail_at:
+ raise RuntimeError("physics failed")
+ self.robot.qpos += 0.5 * (self.robot.target - self.robot.qpos)
+ self.simulation_time += dt * steps * self.clock_scale
+ self.after_update()
+
+ def get_rigid_object_uid_list(self):
+ return ["cube"]
+
+ def get_rigid_object(self, uid):
+ assert uid == "cube"
+ return self.cube
+
+
+class _Env(EmbodiedEnv):
+ """Use BaseEnv.step and normal EmbodiedEnv action/demo machinery unchanged."""
+
+ def __init__(self, sim, robot):
+ self.sim, self.robot = sim, robot
+ self._num_envs = 2
+ self.active_joint_ids = [0]
+ self.cfg = SimpleNamespace(
+ sim_steps_per_control=2,
+ events=False,
+ dataset=False,
+ ignore_terminations=False,
+ trajectory_auto_save=False,
+ )
+ self.sim_cfg = SimpleNamespace(physics_dt=0.01)
+ self.max_episode_steps = 100
+ self._profiler = EnvProfiler(None, sim.device)
+ self._elapsed_steps = torch.zeros(2, dtype=torch.long)
+ self._traj_buffer = None
+ self.rollout_buffer = None
+ self.rollout_steps = torch.zeros(2, dtype=torch.long)
+ self._demo_steps = torch.zeros(2, dtype=torch.long)
+ self._demo_active_segment_ids = torch.zeros(2, dtype=torch.long)
+ self._demo_active_mask = torch.ones(2, dtype=torch.bool)
+ self._demo_segment_participants = torch.zeros(2, dtype=torch.bool)
+ self.obs_calls = 0
+ self.fail_observation_at = None
+ self.terminate_after = [None, None]
+ self.post_target_offset = 0.0
+ self.action_manager = Mock(process_action=Mock(side_effect=self._post))
+ self.recorded_masks = []
+
+ def _post(self, action, mode):
+ assert mode == "post", "ControllerAction must bypass policy preprocessing"
+ self.robot.target[:, 0] += self.post_target_offset
+ return action + 8.0 # Postprocessor output is not the command label.
+
+ def get_obs(self, **kwargs):
+ self.obs_calls += 1
+ if self.obs_calls == self.fail_observation_at:
+ raise RuntimeError("camera unavailable")
+ return TensorDict({"state": self.robot.qpos.clone()}, batch_size=[2])
+
+ def get_info(self, **kwargs):
+ return {
+ "success": torch.tensor(
+ [
+ limit is not None and len(self.sim.updates) >= limit
+ for limit in self.terminate_after
+ ]
+ ),
+ "fail": torch.zeros(2, dtype=torch.bool),
+ }
+
+ def get_reward(self, **kwargs):
+ return torch.zeros(2)
+
+ def _extend_reward(self, rewards, **kwargs):
+ return rewards
+
+ def check_truncated(self, **kwargs):
+ return torch.zeros(2, dtype=torch.bool)
+
+ def is_task_success(self, **kwargs):
+ return torch.ones(2, dtype=torch.bool)
+
+ def _hook_after_sim_step(self, **kwargs):
+ self.recorded_masks.append(self._demo_active_mask.clone())
+ self._demo_steps += self._demo_active_mask
+
+
+@pytest.fixture(params=[False, True], ids=["pure_sim", "gym"])
+def scene(request):
+ robot = _Robot()
+ sim = _Sim(robot)
+ env = _Env(sim, robot) if request.param else None
+ cases = tuple(
+ SceneCase(f"case-{i}", f"initial-{i}", "scene", "task", "robot")
+ for i in range(2)
+ )
+ binding = SimpleNamespace(cases=cases)
+ initial = env.get_obs() if env is not None else None
+ snapshots = tuple(
+ SimpleNamespace(
+ root_pose=robot.pose[i].clone(),
+ entity_poses={"cube": sim.cube.pose[i].clone()},
+ )
+ for i in range(2)
+ )
+ host = SimpleNamespace(
+ adapter=SimpleNamespace(robot=robot, sim=sim, atol=1e-5),
+ env=env,
+ profile=SimpleNamespace(physics_dt=0.01),
+ assert_current=Mock(),
+ verify_initial=Mock(return_value=_passed()),
+ snapshots=Mock(return_value=snapshots),
+ initial_observation=lambda value: (
+ initial.clone() if initial is not None else None
+ ),
+ )
+ if env is not None:
+ env.acquire_generation_lease(host)
+ env._generation_prepared = True
+ yield host, binding
+ if env is not None:
+ env.release_generation_lease(host)
+
+
+def _candidate(slot, values=(0.0, 0.2, 0.4, 0.6)):
+ positions = torch.tensor([[value, 0.3] for value in values]).unsqueeze(0)
+ return CandidateTrajectoryBatch(
+ positions=positions,
+ dt=torch.tensor([[0.0] + [0.02] * (len(values) - 1)], dtype=torch.float64),
+ valid_length=torch.tensor([len(values)]),
+ identities=(
+ CandidateIdentity(
+ f"case-{slot}",
+ f"initial-{slot}",
+ f"candidate-{slot}",
+ "family",
+ "source",
+ "rev",
+ "template",
+ ),
+ ),
+ joint_names=("arm", "locked"),
+ phases=((TrajectoryPhase("move", 0, len(values), "free"),),),
+ )
+
+
+def _executor(host, **kwargs):
+ kwargs.setdefault("validator", _passed)
+ kwargs.setdefault("observe", lambda: {"state": host.adapter.robot.qpos})
+ return QposRolloutExecutor(host, control_dt=0.02, **kwargs)
+
+
+def _run(executor, binding, candidates, **kwargs):
+ kwargs.setdefault("on_started", Mock())
+ return executor.execute(
+ binding,
+ candidates,
+ {
+ value.identities[0].candidate_id: (f"episode-{i}", f"commit-{i}")
+ for i, value in enumerate(candidates)
+ if value is not None
+ },
+ **kwargs,
+ )
+
+
+def test_causal_targets_terminal_frame_ragged_rows_and_owned_payload(scene):
+ host, binding = scene
+ robot, sim, env = host.adapter.robot, host.adapter.sim, host.env
+ if env is not None:
+ env.post_target_offset = 0.01
+ validator = Mock(
+ side_effect=lambda *args: (
+ _passed() if len(sim.updates) == 3 else pytest.fail("early validation")
+ )
+ )
+ started = Mock()
+ episodes = _run(
+ _executor(host, validator=validator),
+ binding,
+ [_candidate(0, (0.0, 0.2)), _candidate(1)],
+ on_started=started,
+ )
+ assert [len(value.actions) for value in episodes] == [1, 3]
+ assert started.call_count == 2
+ assert sim.updates == [(0.01, 2)] * 3
+ assert all(episode.validation.accepted for episode in episodes)
+ assert episodes[0].observations["state"].shape == (2, 2)
+ torch.testing.assert_close(episodes[1].actions[:, 0], torch.tensor([0.2, 0.4, 0.6]))
+ torch.testing.assert_close(
+ episodes[1].observations["joint_positions"][:, 0],
+ torch.tensor([0.0, 0.1, 0.25, 0.425]),
+ )
+ torch.testing.assert_close(
+ episodes[1].timestamps, torch.tensor([0, 0.02, 0.04, 0.06], dtype=torch.float64)
+ )
+ assert robot.commands[2][0, 0] == pytest.approx(0.1) # measured hold
+ assert robot.target[1, 0] == robot.qpos[1, 0] # final safe hold
+ if env is not None:
+ assert env.obs_calls == 4 # initial preparation + actual transitions
+ assert env._demo_steps.tolist() == [1, 3]
+ assert [mask.tolist() for mask in env.recorded_masks] == [
+ [True, True],
+ [False, True],
+ [False, True],
+ ]
+ assert env.action_manager.process_action.call_count == 3
+ assert env._generation_command_observer is None
+ robot.qpos.zero_()
+ assert episodes[1].observations["state"][-1, 0] == pytest.approx(0.425)
+
+
+def test_idle_row_never_starts_or_records(scene):
+ host, binding = scene
+ started = Mock()
+ episodes = _run(_executor(host), binding, [None, _candidate(1)], on_started=started)
+ assert episodes[0] is None
+ assert episodes[1].validation.accepted
+ started.assert_called_once()
+ assert host.adapter.robot.qpos[0, 0] == 0
+
+
+@pytest.mark.parametrize("stop_after", [0, 1])
+def test_stop_counts_only_submitted_commands_and_rejects_partial_episode(
+ scene, stop_after
+):
+ host, binding = scene
+ started = Mock()
+ executor = _executor(host)
+ episodes = _run(
+ executor,
+ binding,
+ [_candidate(0), None],
+ on_started=started,
+ should_stop=lambda: len(host.adapter.sim.updates) >= stop_after,
+ )
+ assert started.call_count == int(stop_after > 0)
+ assert executor.last_failures == {
+ "candidate-0": "execution cancelled by should_stop"
+ }
+ if stop_after == 0:
+ assert episodes == (None, None)
+ else:
+ assert len(episodes[0].actions) == 1
+ assert not episodes[0].validation.accepted
+ assert episodes[0].phases[0].stop_index == 2
+
+
+@pytest.mark.parametrize("fail_at", [1, 2])
+def test_physics_exception_counts_attempt_without_fabricating_transition(
+ scene, fail_at
+):
+ host, binding = scene
+ host.adapter.sim.fail_at = fail_at
+ started = Mock()
+ executor = _executor(host)
+ episodes = _run(executor, binding, [_candidate(0), None], on_started=started)
+ started.assert_called_once()
+ assert executor.last_failures == {"candidate-0": "RuntimeError: physics failed"}
+ if fail_at == 1:
+ assert episodes[0] is None
+ else:
+ assert len(episodes[0].actions) == 1
+ assert not episodes[0].validation.accepted
+ assert host.adapter.robot.target[0, 0] == host.adapter.robot.qpos[0, 0]
+
+
+def test_observation_failure_after_command_still_counts_attempt(scene):
+ host, binding = scene
+ if host.env is not None:
+ host.env.fail_observation_at = 2
+ executor = _executor(host)
+ else:
+ observe = Mock(
+ side_effect=[
+ {"state": host.adapter.robot.qpos.clone()},
+ RuntimeError("camera unavailable"),
+ ]
+ )
+ executor = _executor(host, observe=observe)
+ started = Mock()
+ assert _run(executor, binding, [_candidate(0), None], on_started=started) == (
+ None,
+ None,
+ )
+ started.assert_called_once()
+ assert executor.last_failures == {"candidate-0": "RuntimeError: camera unavailable"}
+
+
+def test_changed_controller_target_is_recorded_and_rejected(scene):
+ host, binding = scene
+ host.adapter.robot.offset = 0.05
+ episode = _run(_executor(host), binding, [_candidate(0), None])[0]
+ torch.testing.assert_close(episode.actions[:, 0], torch.tensor([0.25, 0.45, 0.65]))
+ assert not episode.validation.accepted
+
+
+def test_changed_obstacle_or_root_never_passes_static_collision_contract(scene):
+ host, binding = scene
+ host.adapter.sim.after_update = lambda: host.adapter.sim.cube.pose[0, 0, 3].add_(
+ 0.01
+ )
+ episodes = _run(_executor(host), binding, [_candidate(0), _candidate(1)])
+ assert not episodes[0].validation.accepted
+ assert episodes[1].validation.accepted
+ assert (
+ next(
+ check
+ for check in episodes[0].validation.checks
+ if check.check_id == "fixed_collision_world"
+ ).status
+ == "failed"
+ )
+
+
+def test_actual_clock_disagreement_rejects_without_fabricated_timestamps(scene):
+ host, binding = scene
+ host.adapter.sim.clock_scale = 2
+ started = Mock()
+ episodes = _run(_executor(host), binding, [_candidate(0), None], on_started=started)
+ assert episodes == (None, None)
+ started.assert_called_once()
+
+
+@pytest.mark.parametrize("fault", ["initial", "inactive", "timing", "case", "budget"])
+def test_preflight_failure_submits_no_command(scene, fault):
+ host, binding = scene
+ candidate = _candidate(0)
+ kwargs = {}
+ if fault == "initial":
+ candidate.positions[0, 0, 0] = 0.1
+ elif fault == "inactive":
+ candidate.positions[0, 1, 1] = 0.4
+ elif fault == "timing":
+ candidate.dt[0, 1] = 0.03
+ elif fault == "case":
+ candidate = _candidate(1)
+ else:
+ kwargs["max_episode_bytes"] = 1
+ with pytest.raises(ValueError):
+ _run(_executor(host, **kwargs), binding, [candidate, None])
+ assert not host.adapter.sim.updates
+ assert not host.adapter.robot.commands
+
+
+def test_exhaustion_requires_explicit_final_task_success(scene):
+ host, binding = scene
+ validator = lambda *args: ValidationResult(
+ (ValidationCheck("task_success", "failed"),)
+ )
+ episode = _run(
+ _executor(host, validator=validator), binding, [_candidate(0), None]
+ )[0]
+ assert len(episode.actions) == 3
+ assert not episode.validation.accepted
+
+
+def test_early_gym_termination_freezes_terminal_observation_and_holds_row(scene):
+ host, binding = scene
+ if host.env is None:
+ pytest.skip("Gym-specific terminal semantics")
+ host.env.terminate_after[0] = 1
+ episodes = _run(_executor(host), binding, [_candidate(0), _candidate(1)])
+ assert len(episodes[0].actions) == 1
+ assert not episodes[0].validation.accepted
+ assert len(episodes[1].actions) == 3
+ assert episodes[1].validation.accepted
+ assert episodes[0].observations["state"][-1, 0] == pytest.approx(0.1)
+ assert host.env._demo_steps.tolist() == [1, 3]
+
+
+def test_schema_growth_is_rejected_before_copying_or_retaining_changed_frame(scene):
+ host, binding = scene
+ if host.env is None:
+
+ def observe():
+ return {
+ "state": (
+ torch.zeros(2, 3)
+ if host.adapter.sim.updates
+ else host.adapter.robot.qpos
+ )
+ }
+
+ executor = _executor(host, observe=observe)
+ else:
+ executor = _executor(
+ host,
+ encode_observation=lambda raw: {
+ "state": torch.zeros(2, 3) if host.adapter.sim.updates else raw["state"]
+ },
+ )
+ started = Mock()
+ assert _run(executor, binding, [_candidate(0), None], on_started=started) == (
+ None,
+ None,
+ )
+ started.assert_called_once()
+
+
+def test_oversized_validator_metadata_rejects_with_a_bounded_failure(scene):
+ host, binding = scene
+ validator = lambda *args: ValidationResult(
+ (ValidationCheck("task_success", "passed", "x" * 70000),)
+ )
+ episode = _run(
+ _executor(host, validator=validator, max_episode_bytes=70000),
+ binding,
+ [_candidate(0), None],
+ )[0]
+ assert not episode.validation.accepted
+ assert len(episode.validation.checks[0].detail) < 100
+
+
+def test_running_guard_covers_final_task_validator(scene):
+ host, binding = scene
+ executor = _executor(host)
+
+ def validate(*args):
+ with pytest.raises(RuntimeError, match="serialized"):
+ _run(executor, binding, [_candidate(0), None])
+ return _passed()
+
+ executor.validator = validate
+ assert _run(executor, binding, [_candidate(0), None])[0].validation.accepted
+
+
+@pytest.mark.requires_sim
+def test_real_cpu_sim_qpos_rollout_records_measured_state_and_time(
+ tmp_path, monkeypatch
+):
+ from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
+ from embodichain.lab.sim.cfg import RobotCfg, RigidObjectCfg
+ from embodichain.lab.sim.shapes import CubeCfg
+ from embodichain.lab.trajectory_generation.initial_state import (
+ FixedSceneHost,
+ InitialStateProfile,
+ )
+ from embodichain.lab.trajectory_generation.integrations.sim import (
+ SimInitialStateAdapter,
+ )
+
+ monkeypatch.setenv("EMBODICHAIN_SIM_EXIT_PROCESS", "0")
+ urdf = tmp_path / "execution_robot.urdf"
+ urdf.write_text(
+ ''
+ ''
+ ''
+ ""
+ ''
+ ''
+ ""
+ ''
+ ''
+ '',
+ encoding="utf8",
+ )
+ sim = SimulationManager(
+ SimulationManagerCfg(headless=True, sim_device="cpu", num_envs=1)
+ )
+ try:
+ sim.enable_physics(True)
+ robot = sim.add_robot(RobotCfg(uid="robot", fpath=str(urdf), fix_base=True))
+ sim.add_rigid_object(
+ RigidObjectCfg(
+ uid="cube",
+ shape=CubeCfg(size=[0.05, 0.05, 0.05]),
+ init_pos=[2, 0, 1],
+ body_type="static",
+ )
+ )
+ adapter = SimInitialStateAdapter(sim, robot)
+ profile = InitialStateProfile(
+ profile_id="test",
+ prepare=lambda: None,
+ signature=lambda: "fixed",
+ verify=lambda cases: _passed(),
+ )
+ with FixedSceneHost(adapter, profile) as host:
+ binding = host.acquire_case(
+ [SceneCase("case", "initial", "fixed", "move", "robot")]
+ )
+ initial = robot.get_qpos().clone()
+ candidate = CandidateTrajectoryBatch(
+ positions=torch.stack((initial, initial + 0.01, initial + 0.02), dim=1),
+ dt=torch.tensor([[0, 0.02, 0.02]], dtype=torch.float64),
+ valid_length=torch.tensor([3]),
+ identities=(
+ CandidateIdentity(
+ "case",
+ "initial",
+ "candidate",
+ "family",
+ "source",
+ "rev",
+ "template",
+ ),
+ ),
+ joint_names=tuple(robot.joint_names),
+ )
+ executor = QposRolloutExecutor(
+ host,
+ control_dt=0.02,
+ observe=lambda: {"state": robot.get_qpos()},
+ validator=_passed,
+ )
+ started = Mock()
+ episode = executor.execute(
+ binding,
+ [candidate],
+ {"candidate": ("episode", "commit")},
+ on_started=started,
+ )[0]
+ assert episode is not None
+ assert episode.validation.accepted, episode.validation.checks
+ assert episode.actions.shape == (2, 1)
+ torch.testing.assert_close(episode.actions, candidate.positions[0, 1:])
+ torch.testing.assert_close(
+ episode.observations["joint_positions"][-1], robot.get_qpos()[0]
+ )
+ torch.testing.assert_close(
+ episode.timestamps, torch.tensor([0, 0.02, 0.04], dtype=torch.float64)
+ )
+ started.assert_called_once()
+ finally:
+ sim.destroy()
+ SimulationManager.flush_cleanup_queue()
+
+
+def test_idle_rows_clear_residual_velocity_targets_and_effort_before_physics(scene):
+ host, binding = scene
+ robot = host.adapter.robot
+ robot.target_velocity.fill_(0.7)
+ robot.force.fill_(0.8)
+
+ def verify_idle_hold():
+ assert robot.target_velocity[0, 0] == 0
+ assert robot.force[0, 0] == 0
+
+ host.adapter.sim.after_update = verify_idle_hold
+ assert _run(_executor(host), binding, [None, _candidate(1)])[1].validation.accepted
+
+
+def test_started_callback_failure_counts_each_submitted_row_and_stops_before_physics(
+ scene,
+):
+ host, binding = scene
+ started = Mock(side_effect=RuntimeError("accounting failed"))
+ episodes = _run(
+ _executor(host), binding, [_candidate(0), _candidate(1)], on_started=started
+ )
+ assert episodes == (None, None)
+ assert started.call_count == 2
+ assert not host.adapter.sim.updates
+ torch.testing.assert_close(host.adapter.robot.target, host.adapter.robot.qpos)
+
+
+def test_measured_inactive_joint_movement_rejects_the_row(scene):
+ host, binding = scene
+ host.adapter.sim.after_update = lambda: host.adapter.robot.qpos[0, 1].add_(0.01)
+ episode = _run(_executor(host), binding, [_candidate(0), None])[0]
+ assert not episode.validation.accepted
+ assert "inactive" in episode.validation.checks[0].detail
+
+
+def test_task_validator_cannot_overwrite_execution_check(scene):
+ host, _ = scene
+ with pytest.raises(ValueError, match="execution check"):
+ _executor(host, validator_id="execution_complete")
+
+
+def test_last_failures_are_bounded_read_only_owned_and_cleared_for_the_next_batch(
+ scene,
+):
+ host, binding = scene
+ host.adapter.sim.update = Mock(side_effect=RuntimeError("x" * 2000))
+ executor = _executor(host)
+ assert _run(executor, binding, [_candidate(0), None]) == (None, None)
+ previous = executor.last_failures
+ assert len(previous["candidate-0"]) == 512
+ with pytest.raises(TypeError):
+ previous["candidate-0"] = "changed"
+ assert _run(executor, binding, [None, None]) == (None, None)
+ assert executor.last_failures == {}
+ assert len(previous["candidate-0"]) == 512
diff --git a/tests/lab/trajectory_generation/test_initial_state_host.py b/tests/lab/trajectory_generation/test_initial_state_host.py
new file mode 100644
index 000000000..479a968d4
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_initial_state_host.py
@@ -0,0 +1,266 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from embodichain.lab.sim.motion.expansion import (
+ SceneCase,
+ ValidationCheck,
+ ValidationResult,
+)
+from embodichain.lab.trajectory_generation.initial_state import (
+ FixedSceneHost,
+ InitialStateProfile,
+)
+
+
+def _passed(name: str = "task_initial") -> ValidationResult:
+ return ValidationResult((ValidationCheck(name, "passed"),))
+
+
+class _Adapter:
+ def __init__(self) -> None:
+ self.position = torch.tensor([[1.0], [2.0]])
+ self.robot = object()
+ self.sim = SimpleNamespace(num_envs=2, update=self._settle)
+ self.settle_calls = 0
+ self.restore_calls = 0
+ self.settle_delta = 0.0
+ self.read_failed = False
+
+ def _settle(self, dt: float, steps: int) -> None:
+ self.settle_calls += steps
+ self.position += self.settle_delta
+
+ def signature(self) -> str:
+ return "two-row-robot"
+
+ def capture(self) -> torch.Tensor:
+ if self.read_failed:
+ raise ValueError("backend cannot capture")
+ return self.position.clone()
+
+ def restore(self, value: torch.Tensor) -> None:
+ self.restore_calls += 1
+ self.position.copy_(value)
+
+ def verify(self, value: torch.Tensor) -> ValidationResult:
+ return ValidationResult(
+ (
+ ValidationCheck(
+ "initial_state",
+ "passed" if torch.equal(self.position, value) else "failed",
+ ),
+ )
+ )
+
+
+def _cases() -> tuple[SceneCase, ...]:
+ return tuple(
+ SceneCase(f"case-{row}", f"initial-{row}", f"scene-{row}", "pick", "robot")
+ for row in range(2)
+ )
+
+
+def _profile(**kwargs: object) -> InitialStateProfile:
+ values = dict(
+ profile_id="fixed_cube",
+ prepare=lambda: None,
+ signature=lambda: "fixed-properties",
+ verify=lambda cases: _passed(),
+ settling_steps=1,
+ )
+ values.update(kwargs)
+ return InitialStateProfile(**values)
+
+
+def test_repeated_full_batch_restoration_preserves_case_rows_and_invalidates_tokens() -> (
+ None
+):
+ adapter = _Adapter()
+ with FixedSceneHost(adapter, _profile()) as host:
+ previous = host.acquire_case(_cases())
+ for _ in range(3):
+ adapter.position += 10
+ current = host.restore_initial()
+ assert torch.equal(adapter.position, torch.tensor([[1.0], [2.0]]))
+ assert host.verify_initial(current).accepted
+ assert current.cases == _cases()
+ assert current.epoch > previous.epoch
+ with pytest.raises(RuntimeError, match="obsolete"):
+ host.assert_current(previous)
+ previous = current
+ assert adapter.restore_calls == 3
+ assert adapter.settle_calls == 4
+ with pytest.raises(RuntimeError, match="own"):
+ host.assert_current(previous)
+ assert adapter.sim._trajectory_generation_owner is None
+
+
+def test_acquire_captures_after_preparation_and_settling() -> None:
+ adapter = _Adapter()
+ adapter.settle_delta = 0.5
+ observed = []
+ profile = _profile(
+ prepare=lambda: adapter.position.fill_(3),
+ verify=lambda cases: (observed.append(adapter.position.clone()) or _passed()),
+ )
+ with FixedSceneHost(adapter, profile) as host:
+ binding = host.acquire_case(_cases())
+ assert torch.equal(observed[0], torch.full((2, 1), 3.5))
+ adapter.settle_delta = 0
+ adapter.position.fill_(9)
+ binding = host.restore_initial()
+ assert torch.equal(adapter.position, torch.full((2, 1), 3.5))
+ host.assert_current(binding)
+
+
+def test_fixed_condition_change_rejects_before_prepare_or_restore() -> None:
+ adapter = _Adapter()
+ state = {"signature": "initial"}
+ calls = []
+ with FixedSceneHost(
+ adapter,
+ _profile(
+ signature=lambda: state["signature"],
+ prepare=lambda: calls.append("prepare"),
+ ),
+ ) as host:
+ old = host.acquire_case(_cases())
+ state["signature"] = "changed-material"
+ with pytest.raises(RuntimeError, match="conditions changed"):
+ host.restore_initial()
+ assert calls == ["prepare"]
+ assert adapter.restore_calls == 0
+ with pytest.raises(RuntimeError, match="obsolete"):
+ host.assert_current(old)
+
+
+def test_failed_verification_does_not_publish_binding_and_can_retry_saved_state() -> (
+ None
+):
+ adapter = _Adapter()
+ with FixedSceneHost(adapter, _profile()) as host:
+ old = host.acquire_case(_cases())
+ adapter.settle_delta = 1
+ with pytest.raises(RuntimeError, match="verification failed"):
+ host.restore_initial()
+ with pytest.raises(RuntimeError, match="obsolete"):
+ host.assert_current(old)
+ adapter.settle_delta = 0
+ assert host.verify_initial(host.restore_initial()).accepted
+
+
+def test_failed_capture_does_not_create_a_restorable_initial_state() -> None:
+ adapter = _Adapter()
+ adapter.read_failed = True
+ with FixedSceneHost(adapter, _profile()) as host:
+ with pytest.raises(ValueError, match="cannot capture"):
+ host.acquire_case(_cases())
+ with pytest.raises(RuntimeError, match="acquire_case"):
+ host.restore_initial()
+ adapter.read_failed = False
+ host.assert_current(host.acquire_case(_cases()))
+
+
+def test_second_host_cannot_take_batch_and_closed_host_can_be_replaced() -> None:
+ adapter = _Adapter()
+ first = FixedSceneHost(adapter, _profile())
+ with pytest.raises(RuntimeError, match="already has"):
+ FixedSceneHost(adapter, _profile())
+ first.close()
+ first.close()
+ with FixedSceneHost(adapter, _profile()) as second:
+ second.assert_current(second.acquire_case(_cases()))
+
+
+def test_partial_batch_is_rejected_without_running_preparation() -> None:
+ adapter = _Adapter()
+ calls = []
+ with FixedSceneHost(adapter, _profile(prepare=lambda: calls.append(True))) as host:
+ with pytest.raises(ValueError, match="every simulator row"):
+ host.acquire_case(_cases()[:1])
+ assert not calls
+ host.acquire_case(_cases())
+ with pytest.raises(RuntimeError, match="already acquired"):
+ host.acquire_case(_cases())
+
+
+@pytest.mark.parametrize("status", ["failed", "unavailable", "not_run"])
+def test_profile_must_run_and_pass_all_required_checks(status: str) -> None:
+ adapter = _Adapter()
+ profile = _profile(
+ verify=lambda cases: ValidationResult(
+ (ValidationCheck("scene_signature", status, "cannot certify"),)
+ )
+ )
+ with FixedSceneHost(adapter, profile) as host:
+ with pytest.raises(RuntimeError, match="scene_signature"):
+ host.acquire_case(_cases())
+
+
+@pytest.mark.parametrize(
+ "override",
+ [
+ {"physics_dt": 0},
+ {"physics_dt": float("nan")},
+ {"settling_steps": -1},
+ {"settling_steps": True},
+ {"prepare": None},
+ {"profile_id": ""},
+ {"allowed_interval_events": ("push", "push")},
+ ],
+)
+def test_invalid_preparation_profile_is_rejected(override: dict) -> None:
+ with pytest.raises(ValueError):
+ _profile(**override)
+
+
+def test_gym_unknown_interval_event_is_rejected_before_acquiring_lease() -> None:
+ adapter = _Adapter()
+ env = SimpleNamespace(
+ sim=adapter.sim,
+ robot=adapter.robot,
+ num_envs=2,
+ physics_dt=0.01,
+ event_manager=SimpleNamespace(active_functors={"interval": ["random_push"]}),
+ )
+ with pytest.raises(ValueError, match="uncertified interval"):
+ FixedSceneHost(adapter, _profile(), env=env)
+ assert not hasattr(adapter.sim, "_trajectory_generation_owner")
+
+
+def test_normal_sim_reset_is_blocked_until_host_releases_batch() -> None:
+ from embodichain.lab.sim.sim_manager import SimulationManager as SimManager
+
+ adapter = _Adapter()
+ with FixedSceneHost(adapter, _profile()):
+ with pytest.raises(RuntimeError, match="generation host owns"):
+ SimManager.reset_objects_state(adapter.sim)
+
+
+def test_topology_change_invalidates_prepared_execution() -> None:
+ adapter = _Adapter()
+ with FixedSceneHost(adapter, _profile()) as host:
+ binding = host.acquire_case(_cases())
+ adapter.signature = lambda: "new-robot-control-layout"
+ with pytest.raises(RuntimeError, match="topology"):
+ host.assert_current(binding)
diff --git a/tests/lab/trajectory_generation/test_pickup_collection.py b/tests/lab/trajectory_generation/test_pickup_collection.py
new file mode 100644
index 000000000..93e24e6ea
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_pickup_collection.py
@@ -0,0 +1,176 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""End-to-end parallel PickUp collection with physical contacts and sealed data."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+import subprocess
+import sys
+
+import numpy as np
+import pytest
+
+
+@pytest.mark.gpu
+@pytest.mark.slow
+@pytest.mark.requires_sim
+@pytest.mark.subprocess_sim
+def test_pickup_collection(tmp_path: Path) -> None:
+ av = pytest.importorskip("av")
+ import pyarrow.parquet as pq
+
+ root = Path(__file__).resolve().parents[3]
+ output = tmp_path / "collection"
+ process = subprocess.run(
+ [
+ sys.executable,
+ "examples/sim/motion/trajectory_generation/cube_pickup_collection.py",
+ "--output",
+ str(output),
+ "--episodes",
+ "8",
+ "--record-video",
+ ],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ timeout=480,
+ )
+ assert process.returncode == 0, process.stdout[-4000:] + process.stderr[-4000:]
+ report = json.loads((output / "generation_report.json").read_text())
+ pickup = json.loads((output / "pickup_report.json").read_text())
+ manifest = json.loads((output / "manifest.json").read_text())
+ assert report["target_reached"] and report["error"] is None
+ assert report["counts"]["committed"] == len(manifest["episodes"]) == 8
+ assert pickup["prepared_batches"] >= 2
+ assert report["configuration"]["source"]["kind"] == "atomic"
+ initial_joints, initial_objects, cases, closing_axes, paths = [], [], set(), {}, {}
+ per_case_initial = {}
+ for record in manifest["episodes"]:
+ shard = output / record["shard"]
+ evidence = json.loads((shard / "episode.json").read_text())
+ case = evidence["identity"]["scene_case_id"]
+ cases.add(case)
+ checks = {check["check_id"]: check for check in evidence["validation"]}
+ assert all(check["status"] == "passed" for check in checks.values())
+ assert {
+ "planned_path_collision",
+ "path_collision",
+ "physical_contacts",
+ "held_object_stability",
+ "actual_motion_limits",
+ "task_success",
+ "fixed_collision_world",
+ } <= checks.keys()
+ metrics = checks["held_object_stability"]["metrics"]
+ assert metrics["minimum_lift_m"] >= 0.12
+ assert (
+ min(
+ metrics["finger_0_contact_fraction"],
+ metrics["finger_1_contact_fraction"],
+ )
+ >= 0.95
+ )
+ assert metrics["max_relative_translation_m"] <= 0.01
+ assert metrics["hold_seconds"] >= 1.0
+ assert checks["physical_contacts"]["metrics"]["physics_samples"] == 2710
+ assert evidence["observation_shapes"]["object_pose"] == [4, 4]
+ assert len(evidence["metadata"]["commanded_joint_indices"]) == 7
+ assert len(evidence["metadata"]["joint_names"]) == 8
+ files = sorted((shard / "dataset" / "data").rglob("*.parquet"))
+ table = pq.read_table(files)
+ assert table.num_rows == 271
+ joints = np.array(table["observation.joint_positions"].to_pylist())
+ objects = np.array(table["observation.object_pose"].to_pylist()).reshape(
+ -1, 4, 4
+ )
+ tcp = np.array(table["observation.tcp_pose"].to_pylist()).reshape(-1, 4, 4)
+ initial_joints.append(joints[0])
+ initial_objects.append(objects[0])
+ if case in per_case_initial:
+ np.testing.assert_allclose(
+ objects[0], per_case_initial[case], atol=1e-6, rtol=0
+ )
+ else:
+ per_case_initial[case] = objects[0]
+ paths.setdefault(case, []).append(tcp[:81, :3, 3])
+ with np.load(shard / "terminal.npz", allow_pickle=False) as terminal:
+ np.testing.assert_allclose(
+ np.diff(terminal["timestamps"]), 0.05, atol=1e-8, rtol=0
+ )
+ assert terminal["timestamps"].shape == (272,)
+ assert terminal["observation.object_pose"][2, 3] - objects[0, 2, 3] >= 0.12
+ closing_axes[case] = terminal["observation.tcp_pose"][:3, 0]
+ assert cases == {f"cube_grasp_row_{row}" for row in range(4)}
+ np.testing.assert_allclose(
+ initial_joints, np.broadcast_to(initial_joints[0], (8, 8)), atol=1e-6, rtol=0
+ )
+ np.testing.assert_allclose(
+ initial_objects,
+ np.broadcast_to(initial_objects[0], (8, 4, 4)),
+ atol=1e-5,
+ rtol=0,
+ )
+ assert (
+ abs(np.dot(closing_axes["cube_grasp_row_0"], closing_axes["cube_grasp_row_2"]))
+ < 0.1
+ )
+ assert any(
+ len(values) >= 2 and np.linalg.norm(values[0] - values[1], axis=-1).max() > 0.01
+ for values in paths.values()
+ )
+ with av.open(str(output / "preview.mp4")) as video:
+ stream = video.streams.video[0]
+ assert stream.codec_context.name == "h264"
+ assert (stream.width, stream.height) == (1280, 1056)
+ assert float(stream.average_rate) == 20.0
+ frames = sum(1 for _ in video.decode(stream))
+ assert frames == pickup["video_frames"] == pickup["prepared_batches"] * 272
+
+
+@pytest.mark.gpu
+@pytest.mark.slow
+@pytest.mark.requires_sim
+@pytest.mark.subprocess_sim
+def test_pickup_cli_releases_native_scene_on_integration_failure(
+ tmp_path: Path,
+) -> None:
+ root = Path(__file__).resolve().parents[3]
+ script = """
+import sys
+from examples.sim.motion.trajectory_generation.cube_pickup_collection import main
+from embodichain.lab.trajectory_generation.integrations.contact import PickUpMotionValidator
+
+def refuse(self, *args, **kwargs):
+ raise ValueError("injected contact backend failure")
+
+PickUpMotionValidator.__init__ = refuse
+sys.argv = ["cube_pickup_collection.py", "--output", sys.argv[1], "--episodes", "1"]
+main()
+"""
+ process = subprocess.run(
+ [sys.executable, "-c", script, str(tmp_path / "failed")],
+ cwd=root,
+ capture_output=True,
+ text=True,
+ timeout=90,
+ )
+ assert process.returncode == 1, process.stdout[-2000:] + process.stderr[-2000:]
+ assert "injected contact backend failure" in process.stderr
+ assert not (tmp_path / "failed" / "manifest.json").exists()
diff --git a/tests/lab/trajectory_generation/test_planning.py b/tests/lab/trajectory_generation/test_planning.py
new file mode 100644
index 000000000..3afdc1ea6
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_planning.py
@@ -0,0 +1,571 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Real-batch mapping and failure isolation for the free-motion adapter."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from embodichain.lab.sim.motion.motion_generator import MotionGenerator
+from embodichain.lab.sim.motion.planners import CollisionWorldInfo
+from embodichain.lab.sim.motion.expansion import (
+ CandidateIdentity,
+ CandidateTrajectoryBatch,
+ MotionSnapshot,
+ SceneCase,
+ TrajectoryPhase,
+)
+from embodichain.lab.trajectory_generation.integrations.planning import (
+ EEFPath,
+ EnvRowMotionPlanner,
+)
+
+
+@pytest.mark.gpu
+@pytest.mark.slow
+def test_real_curobo_free_path_and_dynamic_obstacle_with_cpu_physics():
+ """Exercise the actual joint-bound, self, and world checker on one Panda."""
+ pytest.importorskip("curobo")
+ from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
+ from embodichain.lab.sim.cfg import RigidBodyAttributesCfg
+ from embodichain.lab.sim.motion.motion_generator import MotionGenCfg
+ from embodichain.lab.sim.motion.planners.curobo.curobo_planner import (
+ CuroboPlannerCfg,
+ CuroboWorldCfg,
+ )
+ from embodichain.lab.sim.objects import RigidObjectCfg
+ from embodichain.lab.sim.robots import FrankaPandaCfg
+ from embodichain.lab.sim.shapes import CubeCfg
+
+ sim = SimulationManager(
+ SimulationManagerCfg(headless=True, sim_device="cpu", num_envs=1)
+ )
+ generator = None
+ try:
+ robot = sim.add_robot(
+ cfg=FrankaPandaCfg.from_dict(
+ {"uid": "free_path_panda", "robot_type": "panda"}
+ )
+ )
+ robot.set_qpos(
+ torch.tensor([robot.cfg.init_qpos], device=robot.device), target=False
+ )
+ obstacle = sim.add_rigid_object(
+ cfg=RigidObjectCfg(
+ uid="path_obstacle",
+ shape=CubeCfg(size=(0.3, 0.3, 0.3)),
+ attrs=RigidBodyAttributesCfg(),
+ body_type="kinematic",
+ init_pos=(3.0, 0.0, 1.0),
+ )
+ )
+ generator = MotionGenerator(
+ MotionGenCfg(
+ planner_cfg=CuroboPlannerCfg(
+ robot_uid=robot.uid,
+ world=CuroboWorldCfg(
+ rigid_objects={"obstacle": obstacle},
+ dynamic_obstacle_names=["obstacle"],
+ obstacle_representation="cuboid",
+ ),
+ use_cuda_graph=False,
+ cuda_device=0,
+ )
+ )
+ )
+ planner = EnvRowMotionPlanner(
+ generator,
+ control_part="arm",
+ held_object_ids=(),
+ max_joint_step=0.01,
+ max_validation_samples=8,
+ )
+ snapshot = MotionSnapshot(
+ SceneCase("real-case", "real-initial", "real-scene", "move", "panda"),
+ tuple(robot.joint_names),
+ robot.get_qpos()[0],
+ robot.get_qvel()[0],
+ robot.get_local_pose(to_matrix=True)[0],
+ {"obstacle": obstacle.get_local_pose(to_matrix=True)[0]},
+ )
+ positions = snapshot.joint_positions.repeat(1, 3, 1)
+ positions[0, :, planner.joint_ids[0]] += torch.tensor([0.0, 0.01, 0.02])
+ identity = CandidateIdentity(
+ "real-case",
+ "real-initial",
+ "real-candidate",
+ "real-family",
+ "source",
+ "r1",
+ "template",
+ )
+ batch = CandidateTrajectoryBatch(
+ positions,
+ torch.tensor([[0.0, 0.1, 0.1]]),
+ torch.tensor([3]),
+ (identity,),
+ snapshot.joint_names,
+ ((TrajectoryPhase("free", 0, 3),),),
+ source_row_indices=torch.tensor([0]),
+ )
+ result = planner.validate_qpos(batch, (snapshot,))[0]
+ assert result.accepted, (
+ result.checks,
+ snapshot.joint_positions,
+ robot.cfg.init_qpos,
+ planner.fixed_ids,
+ )
+ assert robot.device.type == "cpu"
+ assert generator.planner._curobo_device.type == "cuda"
+ assert len(generator.planner._backend_cache) == 1
+
+ blocked_pose = robot.compute_fk(
+ qpos=robot.get_qpos(name="arm"), name="arm", to_matrix=True
+ )
+ obstacle.set_local_pose(blocked_pose)
+ blocked = replace(snapshot, entity_poses={"obstacle": blocked_pose[0]})
+ rejected = planner.validate_qpos(batch, (blocked,))[0]
+ assert rejected.checks[0].status == "failed", rejected.checks
+ assert len(generator.planner._backend_cache) == 1
+ finally:
+ if generator is not None:
+ generator.planner.close()
+ sim.destroy(exit_process=False)
+ SimulationManager.flush_cleanup_queue()
+
+
+class _Robot:
+ device = torch.device("cpu")
+ num_instances = 4
+ joint_names = ("arm", "gripper")
+ cfg = SimpleNamespace(init_qpos=[0.0, 0.25])
+
+ def __init__(self):
+ self.roots = torch.eye(4).repeat(self.num_instances, 1, 1)
+ self.roots[:, 0, 3] = torch.arange(self.num_instances) * 10.0
+ self.ik_calls = []
+ self.fk_calls = []
+ self.failed_ik_rows = set()
+
+ def get_joint_ids(self, name):
+ assert name == "arm"
+ return [0]
+
+ def get_local_pose(self, *, to_matrix):
+ assert to_matrix
+ return self.roots.clone()
+
+ def get_qpos_limits(self):
+ return torch.tensor([[-10.0, 10.0], [0.0, 1.0]]).repeat(
+ self.num_instances, 1, 1
+ )
+
+ def compute_fk(self, *, qpos, name, to_matrix):
+ assert qpos.shape == (self.num_instances, 1)
+ assert torch.isfinite(qpos).all()
+ self.fk_calls.append(qpos.clone())
+ poses = self.roots.clone()
+ poses[:, 0, 3] += qpos[:, 0]
+ return poses
+
+ def compute_ik(self, *, pose, name, joint_seed):
+ assert pose.shape == (self.num_instances, 4, 4)
+ assert torch.isfinite(joint_seed).all()
+ self.ik_calls.append((pose.clone(), joint_seed.clone()))
+ solved = (pose[:, 0, 3] - self.roots[:, 0, 3]).unsqueeze(1)
+ mask = torch.ones(self.num_instances, dtype=torch.bool)
+ for row in self.failed_ik_rows:
+ solved[row] = float("nan")
+ mask[row] = False
+ return mask, solved
+
+
+class _Backend:
+ supports_joint_trajectory_validation = True
+ collision_world_info = CollisionWorldInfo(
+ ("obstacle",), ("obstacle",), "per_env", True
+ )
+
+ def __init__(self):
+ self.queries = []
+ self.block_middle = False
+ self.reject_padding_after = {}
+
+ def validate_joint_trajectory(self, trajectory, *, control_part, obstacle_poses):
+ assert trajectory.shape[0] == 4
+ assert torch.isfinite(trajectory).all()
+ self.queries.append((trajectory.clone(), obstacle_poses))
+ mask = torch.ones(trajectory.shape[:2], dtype=torch.bool)
+ if self.block_middle:
+ mask &= ~((trajectory[..., 0] > 0.04) & (trajectory[..., 0] < 0.06))
+ for row, start in self.reject_padding_after.items():
+ mask[row, start:] = False
+ return mask
+
+
+def _fixture(**kwargs):
+ robot, backend = _Robot(), _Backend()
+ generator = object.__new__(MotionGenerator)
+ generator.robot = robot
+ generator.planner = backend
+ planner = EnvRowMotionPlanner(
+ generator, control_part="arm", held_object_ids=(), **kwargs
+ )
+ snapshots = tuple(
+ MotionSnapshot(
+ SceneCase(f"case-{row}", f"initial-{row}", f"scene-{row}", "move", "robot"),
+ robot.joint_names,
+ torch.tensor([0.0, 0.25]),
+ torch.zeros(2),
+ robot.roots[row],
+ {"obstacle": robot.roots[row]},
+ )
+ for row in range(robot.num_instances)
+ )
+ return planner, robot, backend, snapshots
+
+
+def _identity(index, row):
+ return CandidateIdentity(
+ f"case-{row}",
+ f"initial-{row}",
+ f"candidate-{index}",
+ f"family-{index}",
+ "source",
+ "revision",
+ "template",
+ )
+
+
+def _batch(rows, *, displacements=None, phases=None):
+ count = len(rows)
+ values = [0.1] * count if displacements is None else displacements
+ q = torch.tensor([[[0.0, 0.25], [value, 0.25]] for value in values]).reshape(
+ count, 2, 2
+ )
+ return CandidateTrajectoryBatch(
+ q,
+ torch.tensor([[0.0, 0.1]] * count).reshape(count, 2),
+ torch.full((count,), 2, dtype=torch.int64),
+ tuple(_identity(index, row) for index, row in enumerate(rows)),
+ ("arm", "gripper"),
+ (
+ tuple((TrajectoryPhase("free", 0, 2),) for _ in rows)
+ if phases is None
+ else phases
+ ),
+ source_row_indices=torch.tensor(rows, dtype=torch.int64),
+ )
+
+
+def _eef_paths(rows, robot):
+ paths = []
+ for index, row in enumerate(rows):
+ poses = robot.roots[row].repeat(3, 1, 1)
+ poses[:, 0, 3] += torch.tensor([0.0, 0.02, 0.04 + index * 0.003])
+ paths.append(
+ EEFPath(
+ _identity(index, row),
+ row,
+ poses,
+ torch.tensor([0.0, 0.1, 0.2]),
+ (TrajectoryPhase("free", 0, 3),),
+ )
+ )
+ return paths
+
+
+def test_eleven_candidates_use_four_real_rows_across_three_rounds():
+ planner, robot, backend, snapshots = _fixture()
+ rows = [0, 0, 1, 2, 3, 3, 3, 1, 2, 0, 2]
+ batch = _batch(rows, displacements=[0.03 + index * 0.001 for index in range(11)])
+ original = batch.positions.clone()
+ results = planner.validate_qpos(batch, snapshots)
+ assert all(result.accepted for result in results)
+ assert len(backend.queries) == 3
+ assert robot.num_instances == 4
+ assert torch.equal(batch.positions, original)
+ expected_groups = [
+ {0: 0, 1: 2, 2: 3, 3: 4},
+ {0: 1, 1: 7, 2: 8, 3: 5},
+ {0: 9, 2: 10, 3: 6},
+ ]
+ for (query, obstacles), expected in zip(backend.queries, expected_groups):
+ assert query.shape[0] == 4
+ assert torch.equal(obstacles["obstacle"], robot.roots)
+ for row in range(4):
+ assert query[row, -1, 0] == pytest.approx(
+ batch.positions[expected[row], -1, 0].item() if row in expected else 0.0
+ )
+
+
+def test_eef_mapping_retains_all_rows_times_phases_and_uncontrolled_joints():
+ planner, robot, backend, snapshots = _fixture()
+ rows = [0, 0, 1, 2, 3, 3, 3, 1, 2, 0, 2]
+ paths = _eef_paths(rows, robot)
+ batch, results = planner.plan_eef(paths, snapshots)
+ assert all(result.accepted for result in results)
+ assert len(robot.ik_calls) == 6
+ assert batch.source_row_indices.tolist() == rows
+ for index, path in enumerate(paths):
+ assert torch.allclose(
+ batch.positions[index, :, 0],
+ path.poses[:, 0, 3] - robot.roots[rows[index], 0, 3],
+ )
+ assert torch.equal(batch.dt[index], path.dt)
+ assert batch.phases[index] == path.phases
+ assert torch.equal(batch.positions[..., 1], torch.full((11, 3), 0.25))
+
+
+def test_fixed_solved_joint_samples_skip_ik_and_remain_exact():
+ planner, robot, backend, snapshots = _fixture()
+ path = _eef_paths([2], robot)[0]
+ solved = torch.tensor([[0.0], [0.02], [0.04]])
+ path = replace(path, solved_joint_targets=solved)
+ batch, results = planner.plan_eef([path], snapshots)
+ assert results[0].accepted
+ assert not robot.ik_calls
+ assert torch.equal(batch.positions[0, :, 0], solved[:, 0])
+ bad_path = replace(path, solved_joint_targets=solved + 0.1)
+ _, rejected = planner.plan_eef([bad_path], snapshots)
+ assert not rejected[0].accepted
+ assert not robot.ik_calls
+
+
+def test_failed_ik_row_never_contaminates_later_seeds_or_other_rows():
+ planner, robot, backend, snapshots = _fixture()
+ robot.failed_ik_rows = {1}
+ batch, results = planner.plan_eef(_eef_paths([0, 1, 2], robot), snapshots)
+ assert [value.accepted for value in results] == [True, False, True]
+ assert torch.equal(batch.positions[1], snapshots[1].joint_positions.expand(3, -1))
+ assert all(torch.isfinite(seed).all() for _, seed in robot.ik_calls)
+
+
+def test_collision_between_anchors_and_across_phase_boundary_is_rejected():
+ planner, robot, backend, snapshots = _fixture(max_joint_step=0.01)
+ backend.block_middle = True
+ phases = ((TrajectoryPhase("first", 0, 1), TrajectoryPhase("second", 1, 2)),)
+ result = planner.validate_qpos(_batch([0], phases=phases), snapshots)[0]
+ assert result.checks[0].status == "failed"
+ assert backend.queries[0][0].shape[1] > 2
+
+
+def test_inactive_rows_and_padded_samples_do_not_affect_acceptance():
+ planner, robot, backend, snapshots = _fixture(max_joint_step=0.02)
+ backend.reject_padding_after = {0: 2, 2: 0, 3: 0}
+ results = planner.validate_qpos(
+ _batch([0, 1], displacements=[0.01, 0.1]), snapshots
+ )
+ assert all(value.accepted for value in results)
+ query = backend.queries[0][0]
+ assert torch.equal(query[2:], torch.zeros_like(query[2:]))
+
+
+@pytest.mark.parametrize("kind", ["contact", "hold", "missing"])
+def test_unsupported_phase_semantics_never_enter_collision_backend(kind):
+ planner, robot, backend, snapshots = _fixture()
+ phases = ((),) if kind == "missing" else ((TrajectoryPhase("phase", 0, 2, kind),),)
+ result = planner.validate_qpos(_batch([0], phases=phases), snapshots)[0]
+ assert result.checks[0].status == "unavailable"
+ assert not backend.queries
+
+
+def test_held_objects_and_moving_or_noninitial_grippers_are_unavailable():
+ planner, robot, backend, snapshots = _fixture()
+ batch = _batch([0])
+ planner.held_object_ids = ("payload",)
+ assert planner.validate_qpos(batch, snapshots)[0].checks[0].status == "unavailable"
+ planner.held_object_ids = ()
+ batch.positions[0, 1, 1] = 0.4
+ assert planner.validate_qpos(batch, snapshots)[0].checks[0].status == "unavailable"
+ batch.positions[0, :, 1] = 0.4
+ snapshots = (
+ replace(snapshots[0], joint_positions=torch.tensor([0.0, 0.4])),
+ *snapshots[1:],
+ )
+ assert planner.validate_qpos(batch, snapshots)[0].checks[0].status == "unavailable"
+ assert not backend.queries
+
+
+def test_sampling_budget_and_missing_backend_capability_are_unavailable():
+ planner, robot, backend, snapshots = _fixture(max_validation_samples=2)
+ assert (
+ planner.validate_qpos(_batch([0]), snapshots)[0].checks[0].status
+ == "unavailable"
+ )
+ backend.supports_joint_trajectory_validation = False
+ assert (
+ planner.validate_qpos(_batch([0]), snapshots)[0].checks[0].status
+ == "unavailable"
+ )
+ assert not backend.queries
+
+
+def test_empty_qpos_and_eef_inputs_do_not_invoke_backend():
+ planner, robot, backend, snapshots = _fixture()
+ assert planner.validate_qpos(_batch([]), snapshots) == ()
+ batch, results = planner.plan_eef([], snapshots)
+ assert batch.positions.shape == (0, 0, 2)
+ assert results == ()
+ assert not backend.queries and not robot.ik_calls
+
+
+def test_nan_candidate_is_failed_before_backend_and_eef_constructor_rejects_nan():
+ planner, robot, backend, snapshots = _fixture()
+ batch = _batch([0])
+ batch.positions[0, 1, 0] = float("nan")
+ assert planner.validate_qpos(batch, snapshots)[0].checks[0].status == "failed"
+ path = _eef_paths([0], robot)[0]
+ poses = path.poses.clone()
+ poses[1, 0, 0] = float("nan")
+ with pytest.raises(ValueError, match="finite"):
+ replace(path, poses=poses)
+ assert not backend.queries
+
+
+def test_missing_source_mapping_foreign_case_and_stale_roots_are_rejected():
+ planner, robot, backend, snapshots = _fixture()
+ with pytest.raises(ValueError, match="source_row_indices"):
+ planner.validate_qpos(replace(_batch([0]), source_row_indices=None), snapshots)
+ with pytest.raises(ValueError, match="identity"):
+ planner.validate_qpos(
+ replace(_batch([0]), source_row_indices=torch.tensor([1])), snapshots
+ )
+ robot.roots[0, 0, 3] += 1
+ with pytest.raises(ValueError, match="root pose"):
+ planner.validate_qpos(_batch([0]), snapshots)
+ assert not backend.queries
+
+
+def test_collision_densification_is_interleaved_with_real_batch_queries(monkeypatch):
+ """The adapter never materializes dense samples for every logical candidate."""
+ planner, robot, backend, snapshots = _fixture()
+ batch = _batch([0, 0, 1, 2, 3, 3, 3, 1, 2, 0, 2])
+ events = []
+ real_arange = torch.arange
+ real_validate = backend.validate_joint_trajectory
+
+ def arange(*args, **kwargs):
+ events.append("densify")
+ return real_arange(*args, **kwargs)
+
+ def validate(*args, **kwargs):
+ events.append("query")
+ return real_validate(*args, **kwargs)
+
+ monkeypatch.setattr(torch, "arange", arange)
+ monkeypatch.setattr(backend, "validate_joint_trajectory", validate)
+ assert all(result.accepted for result in planner.validate_qpos(batch, snapshots))
+ dense_since_query = 0
+ for event in events:
+ if event == "densify":
+ dense_since_query += 1
+ assert dense_since_query <= robot.num_instances
+ else:
+ dense_since_query = 0
+ assert events[-1] == "query"
+
+
+def test_eef_keeps_float64_arrival_intervals_and_solved_joint_samples():
+ planner, robot, backend, snapshots = _fixture()
+ path = _eef_paths([0], robot)[0]
+ dt = torch.tensor([0.0, 0.10000000001, 0.20000000003], dtype=torch.float64)
+ solved = torch.tensor(
+ [[0.0], [0.02000000001], [0.04000000003]], dtype=torch.float64
+ )
+ batch, results = planner.plan_eef(
+ [replace(path, dt=dt, solved_joint_targets=solved)], snapshots
+ )
+ assert results[0].accepted
+ assert batch.dt.dtype == torch.float64
+ assert torch.equal(batch.dt[0], dt)
+ assert torch.equal(batch.positions[0, :, 0], solved[:, 0])
+
+
+def test_missing_locked_joint_configuration_is_unavailable():
+ planner, robot, backend, snapshots = _fixture()
+ robot.cfg = SimpleNamespace(init_qpos=None)
+ result = planner.validate_qpos(_batch([0]), snapshots)[0]
+ assert result.checks[0].status == "unavailable"
+ assert not backend.queries
+
+
+def test_post_rollout_qpos_validation_uses_snapshots_without_reading_current_joints():
+ planner, robot, backend, snapshots = _fixture()
+
+ def forbid_live_qpos(*args, **kwargs):
+ raise AssertionError("post-rollout current joints do not represent the path")
+
+ robot.get_qpos = forbid_live_qpos
+ measured = _batch([0])
+ result = planner.validate_qpos(measured, snapshots)[0]
+ assert result.accepted
+ query, obstacles = backend.queries[0]
+ assert query[0, -1, 0] == measured.positions[0, -1, 0]
+ assert torch.equal(obstacles["obstacle"], robot.roots)
+
+
+@pytest.mark.parametrize(
+ "start_error, accepted", [(0.0, True), (5e-7, True), (5e-6, False)]
+)
+def test_measured_initial_anchor_has_a_stricter_tolerance_than_host_default(
+ start_error, accepted
+):
+ planner, robot, backend, snapshots = _fixture()
+ measured = _batch([0])
+ measured.positions[0, 0, 0] += start_error
+ result = planner.validate_qpos(measured, snapshots)[0]
+ assert result.accepted is accepted
+ if not accepted:
+ assert result.checks[0].status == "failed"
+ assert "does not start" in result.checks[0].detail
+ assert not backend.queries
+
+
+def test_changed_runtime_lock_model_is_unavailable_even_for_matching_new_snapshot():
+ from embodichain.lab.sim.motion.planners.curobo.curobo_planner import (
+ CuroboPlanner,
+ CuroboPlannerCfg,
+ )
+ from embodichain.lab.sim.motion.planners import MoveType
+
+ planner, robot, _, snapshots = _fixture()
+ robot.control_parts = {"arm": ["arm"]}
+ curobo = object.__new__(CuroboPlanner)
+ curobo.robot = robot
+ curobo.cfg = CuroboPlannerCfg(robot_uid="cached-model")
+ curobo._curobo_device = torch.device("cpu")
+ curobo._backend_cache = {
+ ("arm", 4, False, MoveType.JOINT_MOVE): SimpleNamespace(
+ robot_lock_signature=curobo._locked_joint_signature("arm")
+ )
+ }
+ planner.motion_generator.planner = curobo
+ robot.cfg = SimpleNamespace(init_qpos=[0.0, 0.3])
+ snapshots = tuple(
+ replace(snapshot, joint_positions=torch.tensor([0.0, 0.3]))
+ for snapshot in snapshots
+ )
+ candidate = _batch([0])
+ candidate.positions[:, :, 1] = 0.3
+ result = planner.validate_qpos(candidate, snapshots)[0]
+ assert result.checks[0].status == "unavailable"
+ assert "locked-joint configuration changed" in result.checks[0].detail
diff --git a/tests/lab/trajectory_generation/test_runner.py b/tests/lab/trajectory_generation/test_runner.py
new file mode 100644
index 000000000..b319a2043
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_runner.py
@@ -0,0 +1,506 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Runner scheduling/accounting with tensor hosts and real local LeRobot writes.
+
+These tests isolate orchestration; the tensor executor does not certify physics.
+"""
+
+from __future__ import annotations
+
+from dataclasses import replace
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from embodichain.lab.sim.motion.expansion import (
+ ExpertEpisode,
+ MotionSnapshot,
+ SceneCase,
+ TrajectoryGenerationJobCfg,
+ TrajectoryPhase,
+ TrajectoryTemplate,
+ ValidationCheck,
+ ValidationResult,
+)
+from embodichain.lab.trajectory_generation.runner import (
+ GenerationRunner,
+ MotionLimitsProfile,
+)
+from embodichain.lab.trajectory_generation.sinks import LeRobotEpisodeSink
+
+
+def _checks(name="task_success", status="passed"):
+ return ValidationResult((ValidationCheck(name, status),))
+
+
+class _Host:
+ def __init__(self, rows):
+ self.robot = SimpleNamespace(
+ joint_names=("a", "b"),
+ get_qpos_limits=lambda: torch.tensor([[-2.0, 2.0], [-2.0, 2.0]]).repeat(
+ rows, 1, 1
+ ),
+ )
+ self.adapter = SimpleNamespace(
+ robot=self.robot,
+ sim=SimpleNamespace(num_envs=rows),
+ tolerances_profile_id="fixed_scene_tolerances",
+ )
+ self.profile = SimpleNamespace(profile_id="fixed_scene_initial_state")
+ self.prepared = 0
+ self.closed = False
+ self.cases = ()
+ self.fail_restore = False
+
+ def acquire_case(self, cases):
+ self.cases = cases
+ self.prepared += 1
+ return self.prepared
+
+ def restore_initial(self):
+ self.prepared += 1
+ if self.fail_restore:
+ raise RuntimeError("restoration mismatch")
+ return self.prepared
+
+ def snapshots(self, binding):
+ return tuple(
+ MotionSnapshot(
+ case,
+ self.robot.joint_names,
+ torch.zeros(2),
+ torch.zeros(2),
+ torch.eye(4),
+ )
+ for case in self.cases
+ )
+
+ def verify_initial(self, binding):
+ return _checks("initial_state")
+
+ def close(self):
+ self.closed = True
+
+
+class _Planner:
+ def __init__(self, host):
+ self.robot = host.robot
+ self.motion_generator = SimpleNamespace(
+ collision_world_entity_ids=(), dynamic_collision_entity_ids=()
+ )
+ self.calls = []
+ self.planning_status = "passed"
+ self.actual_status = "passed"
+ self.executed = False
+ self.reject_first = 0
+
+ def validate_qpos(self, batch, snapshots):
+ self.calls.append(batch)
+ status = self.actual_status if self.executed else self.planning_status
+ if len(self.calls) <= self.reject_first:
+ status = "failed"
+ return (_checks("path_collision", status),)
+
+
+class _Executor:
+ control_dt = 0.05
+ validator_id = "task_success"
+ validation_profile_id = "verified_motion"
+ max_episode_bytes = 16384
+
+ def __init__(self, host, planner):
+ self.host, self.planner = host, planner
+ self.calls = []
+ self.episodes = []
+ self.fail_before = False
+ self.fail_after = False
+ self.status = "passed"
+ self.actual_middle = None
+ self.unsupported_observation = False
+ self.after_first = lambda: None
+ self.last_failures = {}
+ self.no_transition_error = None
+
+ def execute(self, binding, candidates, episode_ids, *, on_started, should_stop):
+ self.calls.append(tuple(candidate is not None for candidate in candidates))
+ if self.fail_before:
+ raise RuntimeError("before command")
+ episodes = []
+ for candidate in candidates:
+ if candidate is None or should_stop():
+ episodes.append(None)
+ continue
+ identity = candidate.identities[0]
+ on_started(identity)
+ if self.no_transition_error:
+ self.last_failures[identity.candidate_id] = self.no_transition_error
+ episodes.append(None)
+ continue
+ if self.fail_after:
+ raise RuntimeError("after command")
+ count = int(candidate.valid_length[0])
+ actual = candidate.positions[0, :count].clone()
+ if self.actual_middle is not None:
+ actual[1] = self.actual_middle
+ observations = {"joint_positions": actual}
+ if self.unsupported_observation:
+ observations["unsupported"] = torch.ones(count, 2, dtype=torch.bool)
+ episode = ExpertEpisode(
+ identity,
+ observations,
+ candidate.positions[0, 1:count],
+ torch.arange(count, dtype=torch.float64) * self.control_dt,
+ "qpos",
+ ValidationResult(
+ (
+ ValidationCheck("execution", "passed"),
+ ValidationCheck("fixed_collision_world", "passed"),
+ ValidationCheck("task_success", self.status),
+ )
+ ),
+ *episode_ids[identity.candidate_id],
+ phases=candidate.phases[0],
+ )
+ episodes.append(episode)
+ self.episodes.append(episode)
+ self.after_first()
+ self.planner.executed = True
+ return tuple(episodes)
+
+
+def _job(tmp_path, *, rows=1, configuration=None):
+ cfg = TrajectoryGenerationJobCfg.from_mapping(configuration or {})
+ host = _Host(rows)
+ planner = _Planner(host)
+ executor = _Executor(host, planner)
+ sink = LeRobotEpisodeSink(tmp_path / "collection", fps=20, max_episode_bytes=16384)
+ runner = GenerationRunner(
+ cfg,
+ host,
+ planner,
+ executor,
+ sink,
+ motion_limits=MotionLimitsProfile(
+ torch.full((2,), 100.0), torch.full((2,), 10000.0)
+ ),
+ )
+ cases = tuple(
+ SceneCase(f"case-{row}", f"initial-{row}", "scene", "move", "robot")
+ for row in range(rows)
+ )
+ templates = tuple(
+ TrajectoryTemplate(
+ "handwritten_qpos",
+ "v1",
+ "reference_0",
+ host.robot.joint_names,
+ torch.tensor([[0.0, 0.0], [0.2, 0.1], [0.4, 0.0]]),
+ torch.tensor([0.0, 0.05, 0.05], dtype=torch.float64),
+ (
+ TrajectoryPhase(
+ "free", 0, 3, allowed_operators=("joint_residual", "retime")
+ ),
+ ),
+ ("joint_residual", "retime"),
+ validator_id="task_success",
+ controlled_joint_indices=(0, 1),
+ )
+ for _ in range(rows)
+ )
+ return runner, cases, templates
+
+
+def _report(runner):
+ return json.loads((runner.sink.root / "generation_report.json").read_text())
+
+
+def test_multiple_rounds_and_tail_commit_only_reserved_actual_evidence(tmp_path):
+ runner, cases, templates = _job(
+ tmp_path,
+ rows=2,
+ configuration={
+ "augmentation": {
+ "factors": {"spatial": {"enabled": True, "joint_offset_scale": 0.01}},
+ "coverage": {"joint_dedup_normalized_tol": 0.000001},
+ },
+ "collection": {"target_committed_episodes": 3, "max_proposals": 8},
+ },
+ )
+ report = runner.run(cases, templates)
+ assert report["target_reached"]
+ assert report["counts"]["committed"] == report["counts"]["rollout_attempted"] == 3
+ assert runner.executor.calls == [(True, True), (False, True)]
+ assert runner.host.prepared == 2
+ assert runner.host.closed and not (runner.sink.root / ".writer.lock").exists()
+ assert report["pending_reserved_bytes"] == 0
+ manifest = json.loads((runner.sink.root / "manifest.json").read_text())
+ assert len(manifest["episodes"]) == 3
+ for record in manifest["episodes"]:
+ evidence = json.loads(
+ (runner.sink.root / record["shard"] / "episode.json").read_text()
+ )
+ checks = {check["check_id"] for check in evidence["validation"]}
+ assert {
+ "planned_path_collision",
+ "path_collision",
+ "actual_motion_limits",
+ "motion_quality",
+ "task_success",
+ } <= checks
+ assert _report(runner)["counts"]["committed"] == 3
+ with pytest.raises(RuntimeError, match="single-use"):
+ runner.run(cases, templates)
+
+
+def test_planning_failures_do_not_restore_or_count_rollouts(tmp_path):
+ runner, cases, templates = _job(tmp_path)
+ runner.planner.reject_first = 2
+ report = runner.run(cases, templates)
+ assert report["counts"]["proposed"] == 3
+ assert report["counts"]["rollout_attempted"] == 1
+ assert report["counts"]["committed"] == 1
+ assert runner.host.prepared == 1
+
+
+def test_required_backend_unavailable_fails_immediately_with_audit(tmp_path):
+ runner, cases, templates = _job(tmp_path)
+ runner.planner.planning_status = "unavailable"
+ with pytest.raises(RuntimeError, match="capability is unavailable"):
+ runner.run(cases, templates)
+ report = _report(runner)
+ assert report["counts"]["proposed"] == 1
+ assert report["counts"]["rollout_attempted"] == 0
+ assert (
+ report["diagnostics"][report["audit"][0][0]["candidate_id"]][
+ "planning_validation"
+ ]["checks"][0]["status"]
+ == "unavailable"
+ )
+ assert runner.host.closed
+
+
+@pytest.mark.parametrize("after", [False, True])
+def test_failure_counts_only_commands_actually_started_and_releases_capacity(
+ tmp_path, after
+):
+ runner, cases, templates = _job(tmp_path)
+ runner.executor.fail_after, runner.executor.fail_before = after, not after
+ with pytest.raises(RuntimeError, match="command"):
+ runner.run(cases, templates)
+ report = _report(runner)
+ assert report["counts"]["rollout_attempted"] == int(after)
+ assert report["counts"]["committed"] == 0
+ assert report["pending_reserved_bytes"] == 0
+ assert runner.host.closed
+
+
+@pytest.mark.parametrize("reason", ["task", "collision", "quality", "dynamics"])
+def test_actual_motion_gates_reject_before_expert_write(tmp_path, reason):
+ runner, cases, templates = _job(
+ tmp_path,
+ configuration={"collection": {"max_proposals": 1, "max_rollout_attempts": 1}},
+ )
+ if reason == "task":
+ runner.executor.status = "failed"
+ elif reason == "collision":
+ runner.planner.actual_status = "failed"
+ elif reason == "quality":
+ runner.executor.actual_middle = torch.tensor([-1.0, -1.0])
+ else:
+ # Planned peak speed is 4; the measured excursion has speed 8.
+ runner.motion_limits.velocity_limits.fill_(5)
+ runner.executor.actual_middle = torch.tensor([0.4, 0.1])
+ report = runner.run(cases, templates)
+ assert report["counts"]["rollout_attempted"] == 1
+ assert report["counts"]["committed"] == 0
+ assert report["stop_reason"] == "rollout_budget_exhausted"
+ assert not (runner.sink.root / "manifest.json").exists()
+ if reason == "collision":
+ assert len(runner.planner.calls) == 2
+ assert torch.equal(
+ runner.planner.calls[-1].positions[0],
+ runner.executor.episodes[0].observations["joint_positions"],
+ )
+
+
+def test_real_persistence_failure_retries_without_another_rollout(
+ tmp_path, monkeypatch
+):
+ runner, cases, templates = _job(tmp_path)
+ write = runner.sink._write_manifest
+ calls = []
+
+ def fail_once(record):
+ calls.append(record["commit_id"])
+ if len(calls) == 1:
+ raise OSError("injected writer failure")
+ write(record)
+
+ monkeypatch.setattr(runner.sink, "_write_manifest", fail_once)
+ report = runner.run(cases, templates)
+ assert calls[0] == calls[1]
+ assert report["counts"]["rollout_attempted"] == report["counts"]["committed"] == 1
+ assert report["audit"][0][2] == 1
+ assert report["pending_reserved_bytes"] == 0
+
+
+def test_sink_input_error_releases_pending_payload_and_preserves_error(tmp_path):
+ runner, cases, templates = _job(tmp_path)
+ runner.executor.unsupported_observation = True
+ with pytest.raises(ValueError, match="Unsupported observation"):
+ runner.run(cases, templates)
+ report = _report(runner)
+ assert report["counts"]["committed"] == 0
+ assert report["pending_reserved_bytes"] == 0
+ assert not (runner.sink.root / "manifest.json").exists()
+
+
+def test_cancellation_stops_new_commands_and_commits_completed_row(tmp_path):
+ runner, cases, templates = _job(
+ tmp_path, rows=2, configuration={"collection": {"target_committed_episodes": 2}}
+ )
+ stop = {"value": False}
+ runner.executor.after_first = lambda: stop.update(value=True)
+ report = runner.run(cases, templates, should_stop=lambda: stop["value"])
+ assert report["cancelled"]
+ assert report["counts"]["rollout_attempted"] == report["counts"]["committed"] == 1
+ assert report["pending_reserved_bytes"] == 0
+
+
+def test_restore_mismatch_stops_before_next_rollout(tmp_path):
+ runner, cases, templates = _job(
+ tmp_path, configuration={"collection": {"target_committed_episodes": 2}}
+ )
+ runner.host.fail_restore = True
+ with pytest.raises(RuntimeError, match="restoration mismatch"):
+ runner.run(cases, templates)
+ report = _report(runner)
+ assert report["counts"]["rollout_attempted"] == report["counts"]["committed"] == 1
+ assert report["pending_reserved_bytes"] == 0
+
+
+def test_template_validator_cannot_be_changed_by_job(tmp_path):
+ runner, cases, templates = _job(tmp_path)
+ with pytest.raises(ValueError, match="validator IDs"):
+ runner.run(cases, (replace(templates[0], validator_id="weaker"),))
+ assert runner.host.prepared == 0
+ assert runner.host.closed
+
+
+@pytest.mark.parametrize("static_only", [False, True])
+def test_missing_rigid_collision_geometry_is_rejected_before_proposal(
+ tmp_path, static_only
+):
+ runner, cases, templates = _job(tmp_path)
+ if static_only:
+ runner.planner.motion_generator.collision_world_entity_ids = (
+ "unmodelled_cube",
+ )
+ snapshots = runner.host.snapshots
+ runner.host.snapshots = lambda binding: tuple(
+ replace(value, entity_poses={"unmodelled_cube": torch.eye(4)})
+ for value in snapshots(binding)
+ )
+ with pytest.raises(ValueError, match="every rigid object"):
+ runner.run(cases, templates)
+ assert _report(runner)["counts"]["proposed"] == 0
+ assert not runner.executor.calls
+
+
+def test_wall_budget_expiring_during_planning_does_not_start_commands(tmp_path):
+ runner, cases, templates = _job(tmp_path)
+ clock = {"time": 0.0}
+ runner.clock = lambda: clock["time"]
+ validate = runner.planner.validate_qpos
+
+ def slow_plan(*args):
+ clock["time"] = 61.0
+ return validate(*args)
+
+ runner.planner.validate_qpos = slow_plan
+ report = runner.run(cases, templates)
+ assert report["stop_reason"] == "wall_time_exhausted"
+ assert report["counts"]["rollout_attempted"] == 0
+ assert report["pending_reserved_bytes"] == 0
+
+
+def test_report_failure_does_not_replace_original_execution_error(
+ tmp_path, monkeypatch
+):
+ runner, cases, templates = _job(tmp_path)
+ runner.executor.fail_before = True
+
+ def fail_report(report):
+ raise OSError("report disk failure")
+
+ monkeypatch.setattr(runner, "_write_report", fail_report)
+ with pytest.raises(RuntimeError, match="before command") as caught:
+ runner.run(cases, templates)
+ assert "report disk failure" in caught.value.__notes__[0]
+ assert runner.host.closed
+
+
+def test_cancel_during_second_planning_round_does_not_restore_or_execute(tmp_path):
+ runner, cases, templates = _job(
+ tmp_path, configuration={"collection": {"target_committed_episodes": 2}}
+ )
+ stop = {"value": False}
+ validate = runner.planner.validate_qpos
+
+ def cancel_on_next_plan(*args):
+ result = validate(*args)
+ if len(runner.planner.calls) == 3: # initial plan, actual path, second plan
+ stop["value"] = True
+ return result
+
+ runner.planner.validate_qpos = cancel_on_next_plan
+ report = runner.run(cases, templates, should_stop=lambda: stop["value"])
+ assert report["cancelled"]
+ assert runner.host.prepared == 1
+ assert len(runner.executor.calls) == 1
+ assert report["counts"]["committed"] == 1
+ assert report["pending_reserved_bytes"] == 0
+
+
+def test_clock_mismatch_fails_before_initial_preparation(tmp_path):
+ runner, cases, templates = _job(tmp_path)
+ template = replace(
+ templates[0],
+ dt=torch.tensor([0.0, 0.05000005, 0.05000005], dtype=torch.float64),
+ )
+ with pytest.raises(ValueError, match="timing must match"):
+ runner.run(cases, (template,))
+ assert runner.host.prepared == 0
+ assert not runner.executor.calls
+
+
+def test_incomplete_transition_keeps_actual_backend_failure_in_audit(tmp_path):
+ runner, cases, templates = _job(
+ tmp_path, configuration={"collection": {"max_rollout_attempts": 1}}
+ )
+ runner.executor.no_transition_error = "RuntimeError: physics integration failed"
+ report = runner.run(cases, templates)
+ assert report["counts"]["rollout_attempted"] == 1
+ assert report["counts"]["committed"] == 0
+ candidate_id = report["audit"][0][0].candidate_id
+ assert (
+ report["diagnostics"][candidate_id]["reason"]
+ == runner.executor.no_transition_error
+ )
diff --git a/tests/lab/trajectory_generation/test_runner_real.py b/tests/lab/trajectory_generation/test_runner_real.py
new file mode 100644
index 000000000..4a9cd7012
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_runner_real.py
@@ -0,0 +1,101 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Measured UR5 acceptance and articulated Panda rejection through the real stack."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+
+@pytest.mark.gpu
+@pytest.mark.slow
+@pytest.mark.requires_sim
+@pytest.mark.parametrize("robot_type, accepted", [("ur5", True), ("panda", False)])
+def test_real_runner_accepts_only_validated_measured_free_motion(
+ tmp_path: Path, robot_type: str, accepted: bool
+) -> None:
+ pytest.importorskip("curobo")
+ pytest.importorskip("lerobot")
+ av = pytest.importorskip("av")
+ from examples.sim.motion.trajectory_generation.free_motion import run_free_motion
+
+ output = tmp_path / "collection"
+ report = run_free_motion(output, robot_type=robot_type, record_video=accepted)
+ assert report["counts"]["rollout_attempted"] == 1
+ assert report["pending_reserved_bytes"] == 0
+ assert not (output / ".writer.lock").exists()
+ if not accepted:
+ assert not report["target_reached"]
+ assert report["counts"]["committed"] == 0
+ assert not (output / "manifest.json").exists()
+ assert not (output / "preview.mp4").exists()
+ diagnostics = next(iter(report["diagnostics"].values()))
+ checks = {
+ check.check_id: check for check in diagnostics["rollout_validation"].checks
+ }
+ assert checks["path_collision"].status == "unavailable"
+ assert checks["task_success"].metrics["max_gripper_drift"] > 1e-6
+ return
+ assert report["target_reached"], report["diagnostics"]
+ assert report["counts"]["committed"] == report["counts"]["rollout_attempted"] == 1
+ assert report["pending_reserved_bytes"] == 0
+ assert not (output / ".writer.lock").exists()
+ manifest = json.loads((output / "manifest.json").read_text())
+ assert len(manifest["episodes"]) == 1
+ shard = output / manifest["episodes"][0]["shard"]
+ evidence = json.loads((shard / "episode.json").read_text())
+ checks = {check["check_id"]: check for check in evidence["validation"]}
+ assert {
+ "planned_path_collision",
+ "path_collision",
+ "execution_complete",
+ "fixed_collision_world",
+ "task_success",
+ "actual_motion_limits",
+ "motion_quality",
+ } <= set(checks)
+ assert all(check["status"] == "passed" for check in checks.values())
+ assert checks["task_success"]["metrics"]["measured_displacement"] >= 0.07
+ assert checks["task_success"]["metrics"]["endpoint_error"] <= 0.01
+ assert list((shard / "dataset").rglob("*.parquet"))
+
+ # Rendering must retain every observation, including t=0 and the terminal
+ # state, without advancing the one-second physics rollout.
+ assert checks["motion_quality"]["metrics"]["duration_s"] == pytest.approx(1.0)
+ with np.load(shard / "terminal.npz") as terminal:
+ timestamps = terminal["timestamps"]
+ with av.open(str(output / "preview.mp4")) as video:
+ stream = video.streams.video[0]
+ assert stream.codec_context.name == "h264"
+ assert (stream.width, stream.height) == (640, 480)
+ assert stream.average_rate == evidence["fps"] == 20
+ frames = list(video.decode(video=0))
+ assert len(frames) == len(timestamps) == evidence["steps"] + 1
+ np.testing.assert_allclose(
+ [float(frame.pts * frame.time_base) for frame in frames],
+ timestamps - timestamps[0],
+ rtol=0,
+ atol=1e-6,
+ )
+ first = frames[0].to_ndarray(format="rgb24")
+ last = frames[-1].to_ndarray(format="rgb24")
+ assert first.std() > 1, "Camera preview must contain a rendered scene"
+ assert not np.array_equal(first, last), "Preview must show changing frames"
diff --git a/tests/lab/trajectory_generation/test_session_sink_integration.py b/tests/lab/trajectory_generation/test_session_sink_integration.py
new file mode 100644
index 000000000..dc1918c84
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_session_sink_integration.py
@@ -0,0 +1,111 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Session accounting driven by actual sealed LeRobot persistence receipts."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import pytest
+import torch
+
+from embodichain.lab.sim.motion.expansion import (
+ CandidateTrajectoryBatch,
+ ExpertEpisode,
+ GenerationSession,
+ SceneCase,
+ TrajectoryGenerationJobCfg,
+ ValidationCheck,
+ ValidationResult,
+)
+from embodichain.lab.trajectory_generation.sinks import LeRobotEpisodeSink
+
+
+def test_failed_real_commit_retry_preserves_rollout_count_and_confirms_coverage(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # Synthetic rollout evidence isolates receipt/accounting from physical quality.
+ cfg = TrajectoryGenerationJobCfg.from_mapping(
+ {"collection": {"target_committed_episodes": 1}}
+ )
+ session = GenerationSession(cfg)
+ case = SceneCase("case", "initial", "scene", "move", "robot")
+ session.register_case(
+ case, torch.tensor([[-1.0, 1.0], [-1.0, 1.0]]), joint_names=("a", "b")
+ )
+ identity, _ = session.propose(
+ "case",
+ "initial",
+ source_id="test_source",
+ source_revision="v1",
+ template_id="reference",
+ operator_id="replay",
+ )
+ positions = torch.tensor([[[0.0, 0.0], [0.25, 0.0], [0.5, 0.0]]])
+ dt = torch.tensor([[0.0, 0.05, 0.05]], dtype=torch.float64)
+ plan = CandidateTrajectoryBatch(
+ positions, dt, torch.tensor([3]), (identity,), ("a", "b")
+ )
+ session.add_planned(
+ plan, ValidationResult((ValidationCheck("path_collision", "passed"),))
+ )
+ assert session.take_ready("case", "initial", episode_byte_budget=4096) is not None
+ session.mark_rollout_started(identity)
+ episode_id, commit_id = session.episode_ids(identity)
+ episode = ExpertEpisode(
+ identity,
+ {"joint_positions": positions[0]},
+ positions[0, 1:],
+ dt[0].cumsum(0),
+ "qpos",
+ ValidationResult(
+ (
+ ValidationCheck("path_collision", "passed"),
+ ValidationCheck("task_success", "passed"),
+ )
+ ),
+ episode_id,
+ commit_id,
+ )
+ assert session.accept_episode(episode)
+ with LeRobotEpisodeSink(tmp_path / "episodes", fps=20) as sink:
+ write_manifest = sink._write_manifest
+
+ def fail_manifest(record):
+ raise OSError("manifest not written")
+
+ monkeypatch.setattr(sink, "_write_manifest", fail_manifest)
+ failed = sink.submit(episode)
+ assert not failed.confirmed
+ session.apply_receipt(failed)
+ assert session.snapshot()["counts"]["committed"] == 0
+ assert session.snapshot()["coverage"]["case"] == 0
+ retry_episode, submission = session.retry_write(commit_id)
+ monkeypatch.setattr(sink, "_write_manifest", write_manifest)
+ confirmed = sink.submit(retry_episode, submission_id=submission)
+ assert confirmed.confirmed, confirmed.error
+ session.apply_receipt(confirmed)
+ session.apply_receipt(confirmed)
+ report = session.snapshot()
+ assert report["counts"]["committed"] == 1
+ assert report["counts"]["rollout_attempted"] == 1
+ assert report["coverage"]["case"] == 1
+ assert report["pending_reserved_bytes"] == 0
+ manifest = json.loads((sink.root / "manifest.json").read_text())
+ assert len(manifest["episodes"]) == 1
+ assert manifest["episodes"][0]["commit_id"] == episode.commit_id
diff --git a/tests/lab/trajectory_generation/test_sim_initial_state.py b/tests/lab/trajectory_generation/test_sim_initial_state.py
new file mode 100644
index 000000000..a1e2b98d0
--- /dev/null
+++ b/tests/lab/trajectory_generation/test_sim_initial_state.py
@@ -0,0 +1,463 @@
+# ----------------------------------------------------------------------------
+# 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.
+# ----------------------------------------------------------------------------
+
+"""Physical initial-state contracts without creating a simulation world."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from embodichain.lab.trajectory_generation.integrations.sim import (
+ SimInitialStateAdapter,
+)
+
+_BATCH = 2
+_JOINTS = 3 # Include one mimic/gripper coordinate in addition to the arm.
+
+
+class _Robot:
+ """Mutable full-joint state with the production Robot getter/setter API."""
+
+ def __init__(self) -> None:
+ self.uid = "robot"
+ self.device = torch.device("cpu")
+ self.num_instances = _BATCH
+ self.dof = _JOINTS
+ self.joint_names = ["arm", "gripper", "mimic"]
+ self.control_parts = {"arm": ["arm"], "gripper": ["gripper", "mimic"]}
+ self.cfg = SimpleNamespace(fix_base=True, use_usd_properties=False)
+ self.pose = torch.eye(4).repeat(_BATCH, 1, 1)
+ self.pose[1, 0, 3] = 2.0
+ self.qpos = torch.tensor([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]])
+ self.qvel = self.qpos / 10
+ self.target_qpos = self.qpos + 0.01
+ self.target_qvel = self.qpos / 20
+ self.qf = self.qpos / 30
+ self.limits = torch.tensor([-1.0, 1.0]).repeat(_BATCH, _JOINTS, 1)
+ self.root_link_name = "base"
+ velocities = torch.zeros(_BATCH, 2, 6)
+ self.body_data = SimpleNamespace(
+ link_names=["tip", "base"], # Root selection must use identity.
+ entities=[SimpleNamespace(get_root_link_name=lambda: "base")],
+ body_link_vel=velocities,
+ root_lin_vel=velocities[:, 1, :3],
+ root_ang_vel=velocities[:, 1, 3:],
+ )
+ self.writes: list[str] = []
+ self.after_root_write = lambda: None
+
+ def get_local_pose(self, to_matrix: bool = False) -> torch.Tensor:
+ assert to_matrix
+ return self.pose
+
+ def get_qpos(self, target: bool = False) -> torch.Tensor:
+ return self.target_qpos if target else self.qpos
+
+ def get_qvel(self, target: bool = False) -> torch.Tensor:
+ return self.target_qvel if target else self.qvel
+
+ def get_qf(self) -> torch.Tensor:
+ return self.qf
+
+ def get_qpos_limits(self) -> torch.Tensor:
+ return self.limits
+
+ def set_local_pose(self, value: torch.Tensor) -> None:
+ self.writes.append("root_pose")
+ self.pose.copy_(value)
+ self.after_root_write()
+
+ def set_qpos(self, value: torch.Tensor, target: bool = True) -> None:
+ self.writes.append("target_qpos" if target else "qpos")
+ self.get_qpos(target=target).copy_(value)
+
+ def set_qvel(self, value: torch.Tensor, target: bool = True) -> None:
+ self.writes.append("target_qvel" if target else "qvel")
+ self.get_qvel(target=target).copy_(value)
+
+ def set_qf(self, value: torch.Tensor) -> None:
+ self.writes.append("qf")
+ self.qf.copy_(value)
+
+
+class _RigidObject:
+ """Minimal rigid body retaining force state independently of velocity."""
+
+ def __init__(self, uid: str, *, static: bool = False) -> None:
+ self.uid = uid
+ self.device = torch.device("cpu")
+ self.num_instances = _BATCH
+ self.is_static = self.is_non_dynamic = static
+ self.pose = torch.eye(4).repeat(_BATCH, 1, 1)
+ self.pose[:, 0, 3] = torch.tensor([0.2, 0.8])
+ self.body_state = torch.zeros(_BATCH, 13)
+ if not static:
+ self.body_state[:, 7:] = torch.arange(12).reshape(_BATCH, 6) / 100
+ self.pending_force = torch.zeros(_BATCH, 3)
+ self.writes: list[str] = []
+
+ def get_local_pose(self, to_matrix: bool = False) -> torch.Tensor:
+ assert to_matrix
+ return self.pose
+
+ def set_local_pose(self, value: torch.Tensor) -> None:
+ self.writes.append("pose")
+ self.pose.copy_(value)
+
+ def clear_dynamics(self) -> None:
+ self.writes.append("clear_dynamics")
+ self.body_state[:, 7:] = 0
+ self.pending_force.zero_()
+
+ def set_velocity(self, lin_vel: torch.Tensor, ang_vel: torch.Tensor) -> None:
+ self.writes.append("velocity")
+ self.body_state[:, 7:10] = lin_vel
+ self.body_state[:, 10:13] = ang_vel
+
+
+def _scene() -> tuple[SimpleNamespace, _Robot, SimInitialStateAdapter]:
+ robot = _Robot()
+ sim = SimpleNamespace(
+ num_envs=_BATCH,
+ _robots={robot.uid: robot},
+ _rigid_objects={
+ "cube": _RigidObject("cube"),
+ "table": _RigidObject("table", static=True),
+ },
+ _articulations={},
+ _rigid_object_groups={},
+ _soft_objects={},
+ _cloth_objects={},
+ _constraints={},
+ )
+ return sim, robot, SimInitialStateAdapter(sim, robot)
+
+
+def test_capture_owns_nested_tensor_state_and_complete_joint_order() -> None:
+ sim, robot, adapter = _scene()
+ state = adapter.capture()
+ robot.qpos.zero_()
+ sim._rigid_objects["cube"].body_state.zero_()
+ assert state.joint_names == ("arm", "gripper", "mimic")
+ assert state.robot["qpos"][1, 2] == pytest.approx(0.6)
+ assert state.rigid_objects["cube"]["angular_velocity"][1, 2] == pytest.approx(0.11)
+ with pytest.raises(TypeError):
+ state.robot["qpos"] = torch.zeros(_BATCH, _JOINTS)
+ with pytest.raises(TypeError):
+ state.rigid_objects["unknown"] = {}
+
+
+def test_restore_repeatedly_recovers_all_rows_targets_and_pending_forces() -> None:
+ sim, robot, adapter = _scene()
+ state = adapter.capture()
+ cube = sim._rigid_objects["cube"]
+
+ def advance_world_during_root_write() -> None:
+ # The real root setter advances the world before remaining restoration.
+ robot.qpos.add_(0.1)
+ cube.pose[:, 0, 3] += 0.3
+ cube.body_state[:, 7:] += 0.2
+
+ robot.after_root_write = advance_world_during_root_write
+ for _ in range(3):
+ robot.qpos.zero_()
+ robot.target_qpos.zero_()
+ robot.qvel.zero_()
+ robot.target_qvel.zero_()
+ robot.qf.zero_()
+ cube.pending_force.fill_(4)
+ adapter.restore(state)
+ assert adapter.verify(state).accepted
+ assert not cube.pending_force.any()
+ assert robot.writes[-6:] == [
+ "root_pose",
+ "qpos",
+ "qvel",
+ "target_qpos",
+ "target_qvel",
+ "qf",
+ ]
+ assert sim._rigid_objects["table"].writes[-1:] == ["pose"]
+
+
+@pytest.mark.parametrize("field", ["qpos", "qvel", "target_qpos", "target_qvel", "qf"])
+def test_verify_detects_joint_or_controller_drift(field: str) -> None:
+ _, robot, adapter = _scene()
+ state = adapter.capture()
+ getattr(robot, field)[1, 2] += 0.1
+ result = adapter.verify(state)
+ assert not result.accepted
+ assert f"robot.{field}" in result.checks[0].detail
+
+
+def test_verify_detects_root_velocity_and_rigid_velocity_drift() -> None:
+ sim, robot, adapter = _scene()
+ state = adapter.capture()
+ robot.body_data.root_lin_vel[0, 0] = 0.1
+ assert not adapter.verify(state).accepted
+ robot.body_data.root_lin_vel.zero_()
+ sim._rigid_objects["cube"].body_state[1, 10] += 0.1
+ assert not adapter.verify(state).accepted
+
+
+def test_verify_nonfinite_live_state_fails_without_physical_write() -> None:
+ _, robot, adapter = _scene()
+ state = adapter.capture()
+ robot.qvel[0, 0] = float("nan")
+ assert not adapter.verify(state).accepted
+ assert robot.writes == []
+
+
+@pytest.mark.parametrize(
+ "registry",
+ [
+ "_articulations",
+ "_rigid_object_groups",
+ "_soft_objects",
+ "_cloth_objects",
+ "_constraints",
+ ],
+)
+def test_unsupported_entity_or_constraint_rejected_before_restore(
+ registry: str,
+) -> None:
+ sim, robot, adapter = _scene()
+ state = adapter.capture()
+ getattr(sim, registry)["unsupported"] = object()
+ with pytest.raises(ValueError, match="does not support"):
+ adapter.restore(state)
+ assert robot.writes == []
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ "extra_robot",
+ "partial_batch",
+ "floating_base",
+ "usd_base",
+ "joint_order",
+ "object_uid",
+ "control_parts",
+ "body_mode",
+ ],
+)
+def test_topology_and_control_changes_fail_preflight(change: str) -> None:
+ sim, robot, adapter = _scene()
+ state = adapter.capture()
+ if change == "extra_robot":
+ sim._robots["other"] = _Robot()
+ elif change == "partial_batch":
+ robot.num_instances = 1
+ elif change == "floating_base":
+ robot.cfg.fix_base = False
+ elif change == "usd_base":
+ robot.cfg.use_usd_properties = True
+ elif change == "joint_order":
+ robot.joint_names.reverse()
+ elif change == "object_uid":
+ sim._rigid_objects["cube"].uid = "renamed"
+ elif change == "control_parts":
+ robot.control_parts["arm"] = ["gripper"]
+ else:
+ sim._rigid_objects["cube"].is_non_dynamic = True
+ with pytest.raises(ValueError):
+ adapter.restore(state)
+ assert robot.writes == []
+
+
+@pytest.mark.parametrize(
+ "change",
+ [
+ "missing_field",
+ "wrong_shape",
+ "nonfinite",
+ "nonrigid_pose",
+ "root_velocity",
+ "joint_limit",
+ "object_missing",
+ ],
+)
+def test_malformed_snapshot_rejected_before_any_write(change: str) -> None:
+ _, robot, adapter = _scene()
+ state = adapter.capture()
+ if change == "missing_field":
+ state = replace(
+ state,
+ robot={
+ key: value for key, value in state.robot.items() if key != "target_qvel"
+ },
+ )
+ elif change == "wrong_shape":
+ state = replace(state, robot={**state.robot, "qpos": torch.zeros(1, _JOINTS)})
+ elif change == "nonfinite":
+ state.robot["qpos"][0, 0] = float("nan")
+ elif change == "nonrigid_pose":
+ state.robot["root_pose"][0, 0, 0] = -1
+ elif change == "root_velocity":
+ state.robot["root_linear_velocity"][0, 0] = 0.1
+ elif change == "joint_limit":
+ state.robot["target_qpos"][0, 0] = 2
+ else:
+ state = replace(state, rigid_objects={})
+ with pytest.raises(ValueError):
+ adapter.restore(state)
+ assert robot.writes == []
+
+
+def test_signature_tracks_structure_and_ignores_episode_motion() -> None:
+ sim, robot, adapter = _scene()
+ signature = adapter.signature()
+ robot.qpos.add_(0.1)
+ sim._rigid_objects["cube"].pose[:, 0, 3] += 1
+ assert adapter.signature() == signature
+ sim._rigid_objects = dict(reversed(tuple(sim._rigid_objects.items())))
+ assert adapter.signature() == signature
+
+
+def test_verification_uses_explicit_absolute_tolerance() -> None:
+ sim, robot, _ = _scene()
+ tolerance = 0.01
+ adapter = SimInitialStateAdapter(sim, robot, atol=tolerance)
+ state = adapter.capture()
+ robot.qpos[0, 0] += tolerance / 2
+ assert adapter.verify(state).accepted
+ robot.qpos[0, 0] += tolerance
+ assert not adapter.verify(state).accepted
+
+
+@pytest.mark.parametrize("atol", [-1, float("nan"), float("inf"), True])
+def test_invalid_tolerance_rejected(atol: float) -> None:
+ sim, robot, _ = _scene()
+ with pytest.raises((ValueError, TypeError)):
+ SimInitialStateAdapter(sim, robot, atol=atol)
+
+
+@pytest.mark.requires_sim
+@pytest.mark.parametrize("physics_enabled", [False, True])
+def test_real_cpu_robot_and_rigid_object_initial_state(
+ tmp_path, physics_enabled
+) -> None:
+ """Exercise the actual CPU setters/getters without assets, IK, or cameras."""
+ from embodichain.lab.sim import SimulationManager, SimulationManagerCfg
+ from embodichain.lab.sim.cfg import RigidObjectCfg, RobotCfg
+ from embodichain.lab.sim.shapes import CubeCfg
+
+ urdf = tmp_path / "initial_state_robot.urdf"
+ urdf.write_text(
+ ''
+ ''
+ ''
+ ""
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ '',
+ encoding="utf-8",
+ )
+ sim = SimulationManager(
+ SimulationManagerCfg(headless=True, sim_device="cpu", num_envs=1)
+ )
+ try:
+ sim.enable_physics(physics_enabled)
+ robot = sim.add_robot(
+ RobotCfg(uid="initial_robot", fpath=str(urdf), fix_base=True)
+ )
+ cube = sim.add_rigid_object(
+ RigidObjectCfg(
+ uid="initial_cube",
+ shape=CubeCfg(size=[0.05, 0.05, 0.05]),
+ init_pos=[1, 0, 1],
+ )
+ )
+ # Distinct nonzero values catch confusion between current and target APIs.
+ robot.set_qpos(torch.tensor([[0.2]]), target=False)
+ robot.set_qpos(torch.tensor([[0.3]]), target=True)
+ robot.set_qvel(torch.tensor([[0.1]]), target=False)
+ robot.set_qvel(torch.tensor([[0.15]]), target=True)
+ robot.set_qf(torch.tensor([[0.05]]))
+ cube.set_velocity(
+ lin_vel=torch.tensor([[0.1, 0.2, 0.3]]),
+ ang_vel=torch.tensor([[0.3, 0.2, 0.1]]),
+ )
+ adapter = SimInitialStateAdapter(sim, robot)
+ state = adapter.capture()
+ robot.set_qpos(torch.tensor([[-0.2]]), target=False)
+ robot.set_qpos(torch.tensor([[-0.3]]), target=True)
+ robot.set_qvel(torch.zeros(1, 1), target=False)
+ robot.set_qvel(torch.zeros(1, 1), target=True)
+ robot.set_qf(torch.zeros(1, 1))
+ cube.clear_dynamics()
+ pose = cube.get_local_pose(to_matrix=True)
+ pose[:, 0, 3] += 0.2
+ cube.set_local_pose(pose)
+ adapter.restore(state)
+ result = adapter.verify(state)
+ assert result.accepted, result.checks
+ finally:
+ sim.destroy()
+ SimulationManager.flush_cleanup_queue()
+
+
+def test_host_planning_snapshots_own_state_and_keep_physical_case_rows() -> None:
+ from embodichain.lab.sim.motion.expansion import (
+ SceneCase,
+ ValidationCheck,
+ ValidationResult,
+ )
+ from embodichain.lab.trajectory_generation.initial_state import (
+ FixedSceneHost,
+ InitialStateProfile,
+ )
+
+ sim, robot, adapter = _scene()
+ cases = tuple(
+ SceneCase(f"case-{row}", f"initial-{row}", "scene", "move", "robot")
+ for row in range(_BATCH)
+ )
+ profile = InitialStateProfile(
+ profile_id="fixed_scene_initial_state",
+ prepare=lambda: None,
+ signature=lambda: "fixed-configuration",
+ verify=lambda cases: ValidationResult(
+ (ValidationCheck("task_initial", "passed"),)
+ ),
+ )
+ with FixedSceneHost(adapter, profile) as host:
+ binding = host.acquire_case(cases)
+ snapshots = host.snapshots(binding)
+ assert [value.scene_case for value in snapshots] == list(cases)
+ assert torch.equal(snapshots[1].joint_positions, robot.qpos[1])
+ assert torch.equal(snapshots[1].root_pose, robot.pose[1])
+ expected = snapshots[0].joint_positions.clone()
+ snapshots[0].joint_positions.zero_()
+ snapshots[0].entity_poses["cube"].zero_()
+ fresh = host.snapshots(binding)
+ assert torch.equal(fresh[0].joint_positions, expected)
+ assert torch.equal(
+ fresh[0].entity_poses["cube"], sim._rigid_objects["cube"].pose[0]
+ )
+ assert host.initial_observation(binding) is None
+ host.restore_initial()
+ with pytest.raises(RuntimeError, match="obsolete"):
+ host.snapshots(binding)
diff --git a/tests/sim/atomic_actions/test_actions.py b/tests/sim/atomic_actions/test_actions.py
index e96c35353..63dfe486b 100644
--- a/tests/sim/atomic_actions/test_actions.py
+++ b/tests/sim/atomic_actions/test_actions.py
@@ -2064,6 +2064,252 @@ def compute_batch_ik(
assert qpos.shape == (NUM_ENVS, 3, 2, ARM_DOF)
+@pytest.mark.parametrize("all_empty", (False, True))
+def test_pick_empty_grasp_rows_return_safe_pose_and_failure_mask(
+ all_empty: bool,
+) -> None:
+ generator = _motion_generator()
+ action = _bind_action(generator, PickUp())
+ empty = (torch.empty(0, 4, 4), torch.empty(0))
+ valid_pose = torch.eye(4).unsqueeze(0)
+ valid_pose[:, 0, 3] = 0.3
+ _GRASP_GENERATORS[id(action)].get_valid_grasp_poses = Mock(
+ return_value=[empty, empty if all_empty else (valid_pose, torch.zeros(1))]
+ )
+ action._select_feasible_grasp_variants = Mock(
+ side_effect=lambda poses, *_: (
+ poses,
+ torch.ones(poses.shape[:2], dtype=torch.bool),
+ )
+ )
+ safe_pose = torch.eye(4).repeat(NUM_ENVS, 1, 1)
+ safe_pose[:, 1, 3] = 0.4
+ generator.robot.compute_fk.side_effect = None
+ generator.robot.compute_fk.return_value = safe_pose
+
+ success, selected = action._resolve_grasp_pose(
+ ObjectSemantics(
+ affordance=AntipodalAffordance(), geometry={}, entity_id="target"
+ ),
+ torch.eye(4).repeat(NUM_ENVS, 1, 1),
+ torch.zeros(NUM_ENVS, ARM_DOF),
+ JointPositionTarget("arm", tuple(range(ARM_DOF))),
+ "hand",
+ PickUpOptions(),
+ torch.tensor((0.0, 0.0, -1.0)),
+ )
+
+ assert success.tolist() == [False, not all_empty]
+ torch.testing.assert_close(selected[0], safe_pose[0])
+ if all_empty:
+ action._select_feasible_grasp_variants.assert_not_called()
+ torch.testing.assert_close(selected, safe_pose)
+ else:
+ passed_poses = action._select_feasible_grasp_variants.call_args.args[0]
+ torch.testing.assert_close(passed_poses[0, 0], safe_pose[0])
+ torch.testing.assert_close(selected[1], valid_pose[0])
+
+
+def test_pick_padding_cannot_become_a_feasible_grasp() -> None:
+ generator = _motion_generator()
+ action = _bind_action(generator, PickUp())
+ _GRASP_GENERATORS[id(action)].get_valid_grasp_poses = Mock(
+ return_value=[
+ (torch.eye(4).repeat(1, 1, 1), torch.zeros(1)),
+ (torch.eye(4).repeat(2, 1, 1), torch.zeros(2)),
+ ]
+ )
+ action._select_feasible_grasp_variants = Mock(
+ side_effect=lambda poses, *_: (
+ poses,
+ torch.tensor([[False, True], [True, True]]),
+ )
+ )
+ success, _ = action._resolve_grasp_pose(
+ ObjectSemantics(
+ affordance=AntipodalAffordance(), geometry={}, entity_id="target"
+ ),
+ torch.eye(4).repeat(NUM_ENVS, 1, 1),
+ torch.zeros(NUM_ENVS, ARM_DOF),
+ JointPositionTarget("arm", tuple(range(ARM_DOF))),
+ "hand",
+ PickUpOptions(),
+ torch.tensor((0.0, 0.0, -1.0)),
+ )
+ assert success.tolist() == [False, True]
+
+
+@pytest.mark.parametrize(
+ "invalid_kind",
+ (
+ "nan_cost",
+ "infinite_cost",
+ "negative_infinite_cost",
+ "nan_pose",
+ "infinite_pose",
+ "reflection",
+ "nonorthogonal_rotation",
+ "invalid_bottom_row",
+ ),
+)
+def test_pick_invalid_grasp_never_reaches_ik_or_hides_valid_peer(
+ invalid_kind: str,
+) -> None:
+ generator = _motion_generator()
+ action = _bind_action(generator, PickUp())
+ invalid_pose = torch.eye(4)
+ invalid_pose[0, 3] = 0.1
+ invalid_cost = 0.0
+ if invalid_kind == "nan_cost":
+ invalid_cost = float("nan")
+ elif invalid_kind == "infinite_cost":
+ invalid_cost = float("inf")
+ elif invalid_kind == "negative_infinite_cost":
+ invalid_cost = -float("inf")
+ elif invalid_kind == "nan_pose":
+ invalid_pose[0, 3] = float("nan")
+ elif invalid_kind == "infinite_pose":
+ invalid_pose[0, 0] = float("inf")
+ elif invalid_kind == "reflection":
+ invalid_pose[0, 0] = -1.0
+ elif invalid_kind == "nonorthogonal_rotation":
+ invalid_pose[0, 0] = 2.0
+ else:
+ invalid_pose[3, 0] = 1.0
+ valid_pose = torch.eye(4)
+ valid_pose[0, 3] = 0.3
+ _GRASP_GENERATORS[id(action)].get_valid_grasp_poses = Mock(
+ return_value=[
+ (
+ torch.stack((invalid_pose, valid_pose)),
+ torch.tensor([invalid_cost, 1.0]),
+ ),
+ (invalid_pose.unsqueeze(0), torch.tensor([invalid_cost])),
+ ]
+ )
+ safe_pose = torch.eye(4).repeat(NUM_ENVS, 1, 1)
+ safe_pose[:, 1, 3] = 0.4
+ generator.robot.compute_fk.side_effect = None
+ generator.robot.compute_fk.return_value = safe_pose
+
+ def feasible_candidates(
+ poses: torch.Tensor, *_: object
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ assert torch.isfinite(poses).all()
+ torch.testing.assert_close(poses[:, 0], safe_pose)
+ return poses, torch.ones(poses.shape[:2], dtype=torch.bool)
+
+ action._select_feasible_grasp_variants = Mock(side_effect=feasible_candidates)
+ success, selected = action._resolve_grasp_pose(
+ ObjectSemantics(
+ affordance=AntipodalAffordance(), geometry={}, entity_id="target"
+ ),
+ torch.eye(4).repeat(NUM_ENVS, 1, 1),
+ torch.zeros(NUM_ENVS, ARM_DOF),
+ JointPositionTarget("arm", tuple(range(ARM_DOF))),
+ "hand",
+ PickUpOptions(),
+ torch.tensor((0.0, 0.0, -1.0)),
+ )
+
+ assert success.tolist() == [True, False]
+ torch.testing.assert_close(selected[0], valid_pose)
+ torch.testing.assert_close(selected[1], safe_pose[1])
+
+
+@pytest.mark.parametrize("cost_offset", (0.0, 10000.0))
+def test_pick_finite_costs_choose_stable_minimum_without_magic_threshold(
+ cost_offset: float,
+) -> None:
+ generator = _motion_generator()
+ action = _bind_action(generator, PickUp())
+ poses = torch.eye(4).repeat(3, 1, 1)
+ poses[:, 0, 3] = torch.tensor([0.1, 0.2, 0.3])
+ _GRASP_GENERATORS[id(action)].get_valid_grasp_poses = Mock(
+ return_value=[
+ (poses, torch.tensor([2.0, 1.0, 1.0]) + cost_offset),
+ (poses, torch.tensor([0.5, 2.0, 1.0]) + cost_offset),
+ ]
+ )
+ action._select_feasible_grasp_variants = Mock(
+ side_effect=lambda poses, *_: (
+ poses,
+ torch.ones(poses.shape[:2], dtype=torch.bool),
+ )
+ )
+ success, selected = action._resolve_grasp_pose(
+ ObjectSemantics(
+ affordance=AntipodalAffordance(), geometry={}, entity_id="target"
+ ),
+ torch.eye(4).repeat(NUM_ENVS, 1, 1),
+ torch.zeros(NUM_ENVS, ARM_DOF),
+ JointPositionTarget("arm", tuple(range(ARM_DOF))),
+ "hand",
+ PickUpOptions(),
+ torch.tensor((0.0, 0.0, -1.0)),
+ )
+ assert success.all()
+ torch.testing.assert_close(selected, poses[torch.tensor([1, 0])])
+
+
+@pytest.mark.parametrize("bad_qpos", (float("nan"), float("inf"), 99.0))
+def test_pick_failed_ik_preserves_last_valid_seed(bad_qpos: float) -> None:
+ generator = _motion_generator()
+ action = _bind_action(generator, PickUp())
+ poses = torch.eye(4).repeat(NUM_ENVS, 1, 2, 1, 1)
+ seed = torch.full((NUM_ENVS, ARM_DOF), 0.25)
+
+ def compute_batch_ik(
+ *, pose: torch.Tensor, name: str, joint_seed: torch.Tensor
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ success = torch.tensor([[False, True], [True, True]])
+ qpos = joint_seed + 0.1
+ qpos[0, 0] = bad_qpos
+ # A backend incorrectly flagging a NaN result as success must also fail.
+ qpos[1, 0] = float("nan")
+ return success, qpos
+
+ generator.robot.compute_batch_ik.side_effect = compute_batch_ik
+ success, qpos = action._compute_batch_candidate_ik(
+ poses, seed, JointPositionTarget("arm", tuple(range(ARM_DOF)))
+ )
+ assert success.tolist() == [[[False, True]], [[False, True]]]
+ torch.testing.assert_close(qpos[:, 0, 0], seed)
+ torch.testing.assert_close(qpos[:, 0, 1], seed + 0.1)
+ assert torch.isfinite(qpos).all()
+
+
+def test_pick_failed_pregrasp_ik_never_passes_nan_seed_to_later_stages() -> None:
+ generator = _motion_generator()
+ action = _bind_action(generator, PickUp())
+ seeds: list[torch.Tensor] = []
+
+ def compute_batch_ik(
+ *, pose: torch.Tensor, name: str, joint_seed: torch.Tensor
+ ) -> tuple[torch.Tensor, torch.Tensor]:
+ assert torch.isfinite(joint_seed).all()
+ seeds.append(joint_seed.clone())
+ success = torch.ones(pose.shape[:2], dtype=torch.bool)
+ qpos = joint_seed + 0.1
+ if len(seeds) == 1:
+ success[0] = False
+ qpos[0] = float("nan")
+ return success, qpos
+
+ generator.robot.compute_batch_ik.side_effect = compute_batch_ik
+ _, success = action._select_feasible_grasp_variants(
+ torch.eye(4).repeat(NUM_ENVS, 1, 1, 1),
+ torch.zeros(NUM_ENVS, ARM_DOF),
+ torch.eye(4).repeat(NUM_ENVS, 1, 1),
+ JointPositionTarget("arm", tuple(range(ARM_DOF))),
+ PickUpOptions(downstream_object_target_poses=(torch.eye(4),)),
+ torch.tensor((0.0, 0.0, -1.0)),
+ )
+ assert success.tolist() == [[False], [True]]
+ assert len(seeds) == 4 # Pregrasp, grasp, lift, and downstream target.
+ torch.testing.assert_close(seeds[1][0], torch.zeros_like(seeds[1][0]))
+
+
def test_pick_maps_canonical_grasp_frames_to_the_robot_eef() -> None:
"""Endpoint calibration is applied after canonical grasp generation."""
generator = _motion_generator()
diff --git a/tests/sim/atomic_actions/test_engine_per_env.py b/tests/sim/atomic_actions/test_engine_per_env.py
index 238a9fbb2..7f5cb6041 100644
--- a/tests/sim/atomic_actions/test_engine_per_env.py
+++ b/tests/sim/atomic_actions/test_engine_per_env.py
@@ -932,6 +932,237 @@ def test_session_completes_incremental_command_sequence() -> None:
assert final.eligible_mask.tolist() == [True]
+def _candidate_plan(
+ action: DynamicAction,
+ request: ResolvedActionRequest,
+ context: PlanningContext,
+) -> ActionPlan:
+ """Materialize a selected candidate without invoking the skill planner."""
+ target = torch.full_like(context.robot.qpos, 0.7)
+ return action.build_plan(
+ request,
+ context,
+ success=True,
+ trajectory=TimedTrajectory.from_uniform_step(
+ torch.stack((context.robot.qpos, target), dim=1),
+ env_ids=context.env_ids,
+ step_dt=0.1,
+ ),
+ )
+
+
+def test_initial_plan_provider_consumes_candidate_then_plans_later_invocation() -> None:
+ engine, action = _engine()
+ initial = _context(0.0, 0.0, 0.2, 0)
+ invocation = _invocation(engine)
+ provider = Mock(
+ side_effect=lambda request, context: _candidate_plan(action, request, context)
+ )
+
+ session = engine.start(
+ (invocation, replace(invocation, invocation_id="next-call")),
+ initial,
+ initial_plan_provider=provider,
+ )
+ assert action.plan_count == 0
+ request, measured = provider.call_args.args
+ assert isinstance(request, ResolvedActionRequest)
+ assert measured is initial
+ session.tick(initial)
+ selected = session.tick(_context(0.1, 0.0, 0.2, 0))
+ torch.testing.assert_close(
+ _joint_positions(selected.command), torch.full((1, 2), 0.7)
+ )
+ session.tick(_context(0.2, 0.7, 0.2, 0))
+ restarted = session.tick(_context(0.3, 0.7, 0.2, 0))
+ torch.testing.assert_close(
+ _joint_positions(restarted.command), torch.full((1, 2), 0.7)
+ )
+ following = session.tick(_context(0.4, 0.7, 0.2, 0))
+ torch.testing.assert_close(
+ _joint_positions(following.command), torch.full((1, 2), 0.2)
+ )
+ completed = session.tick(_context(0.5, 0.2, 0.2, 0))
+ assert completed.status is ExecutionStatus.COMPLETED
+ assert action.plan_count == 1
+ assert action.requests[0].invocation_id == "next-call"
+ provider.assert_called_once()
+
+
+def test_initial_plan_provider_does_not_replace_recovery_planner() -> None:
+ engine, action = _engine()
+ initial = _context(0.0, 0.0, 0.2, 0)
+ provider = Mock(
+ side_effect=lambda request, context: _candidate_plan(action, request, context)
+ )
+ session = engine.start(
+ (_invocation(engine),), initial, initial_plan_provider=provider
+ )
+ session.tick(initial)
+
+ recovered = session.tick(_context(0.1, 0.0, 0.4, 1))
+ assert ExecutionEventKind.REPLANNED in {event.kind for event in recovered.events}
+ assert action.plan_count == 1
+ assert session.plan_attempts[-1].plan.planned_scene_version == 1
+ provider.assert_called_once()
+
+
+@pytest.mark.parametrize(
+ ("field", "value", "message"),
+ (
+ ("skill_id", "another-skill", "skill_id must match"),
+ ("invocation_id", "old-call", "correlation id"),
+ ("invocation_revision", 1, "request revision"),
+ ("planned_scene_version", 1, "planning scene version"),
+ ("planned_collision_world_revision", (1,), "collision-world revision"),
+ ),
+)
+def test_initial_plan_provider_rejects_mismatched_plan(
+ field: str, value: object, message: str
+) -> None:
+ engine, action = _engine()
+
+ def provider(
+ request: ResolvedActionRequest, context: PlanningContext
+ ) -> ActionPlan:
+ return replace(_candidate_plan(action, request, context), **{field: value})
+
+ with pytest.raises(ValueError, match=message):
+ engine.start(
+ (_invocation(engine),),
+ _context(0.0, 0.0, 0.2, 0),
+ initial_plan_provider=provider,
+ )
+ assert action.plan_count == 0
+
+
+def test_initial_plan_provider_rejects_wrong_result_type() -> None:
+ engine, _ = _engine()
+ with pytest.raises(TypeError, match="must return an ActionPlan"):
+ engine.start(
+ (_invocation(engine),),
+ _context(0.0, 0.0, 0.2, 0),
+ initial_plan_provider=Mock(return_value=None),
+ )
+
+
+def test_initial_plan_provider_cannot_command_an_unbound_target() -> None:
+ engine, action = _engine()
+
+ def provider(
+ request: ResolvedActionRequest, context: PlanningContext
+ ) -> ActionPlan:
+ plan = _candidate_plan(action, request, context)
+ frames = tuple(
+ replace(
+ frame,
+ commands=tuple(
+ replace(command, target=JointPositionTarget("unbound", (0, 1)))
+ for command in frame.commands
+ ),
+ )
+ for frame in plan.commands.frames
+ )
+ return replace(plan, commands=replace(plan.commands, frames=frames))
+
+ with pytest.raises(ValueError, match="not authorized"):
+ engine.start(
+ (_invocation(engine),),
+ _context(0.0, 0.0, 0.2, 0),
+ initial_plan_provider=provider,
+ )
+
+
+def test_initial_plan_provider_receives_new_request_after_reset() -> None:
+ engine, action = _engine()
+ invocation = _invocation(engine)
+ provider = Mock(
+ side_effect=lambda request, context: _candidate_plan(action, request, context)
+ )
+ before = _context(1.0, 0.3, 0.2, 5)
+ after = _context(0.0, 0.0, 0.2, 0)
+
+ first = engine.start((invocation,), before, initial_plan_provider=provider)
+ second = engine.start((invocation,), after, initial_plan_provider=provider)
+
+ first_request, first_context = provider.call_args_list[0].args
+ second_request, second_context = provider.call_args_list[1].args
+ assert first_request is not second_request
+ assert first_context is before
+ assert second_context is after
+ torch.testing.assert_close(
+ _joint_positions(first.tick(before).command), before.robot.qpos
+ )
+ torch.testing.assert_close(
+ _joint_positions(second.tick(after).command), after.robot.qpos
+ )
+ assert action.plan_count == 0
+
+
+def test_initial_plan_provider_preserves_required_collision_binding() -> None:
+ engine, action = _engine()
+ provider = Mock(
+ side_effect=lambda request, context: _candidate_plan(action, request, context)
+ )
+ with pytest.raises(ValueError, match="dynamic_collision_mode='required'"):
+ engine.start(
+ (
+ _invocation(
+ engine, dynamic_collision_mode=DynamicCollisionMode.REQUIRED
+ ),
+ ),
+ _context(0.0, 0.0, 0.2, 0),
+ initial_plan_provider=provider,
+ )
+ provider.assert_not_called()
+
+
+def test_initial_plan_provider_cannot_bypass_phase_gate_validation() -> None:
+ engine, _ = _engine()
+ action = PhaseGateAction()
+ engine.register(action)
+ provider = Mock(
+ side_effect=lambda request, context: _candidate_plan(action, request, context)
+ )
+ with pytest.raises(ValueError, match="missing segment 'commit'"):
+ engine.start(
+ (_phase_gate_invocation(engine),),
+ _context(0.0, 0.0, 0.2, 0),
+ initial_plan_provider=provider,
+ )
+ assert action.plan_count == 0
+
+
+def test_initial_plan_provider_keeps_physical_effect_verification() -> None:
+ engine, _ = _engine()
+ action = EffectAction()
+ engine.register(action)
+ initial = _context(0.0, 0.0, 0.2, 0)
+ provider = Mock(side_effect=action._plan)
+ session = engine.start(
+ (_invocation(engine, skill_id=action.skill_id),),
+ initial,
+ initial_plan_provider=provider,
+ )
+ session.tick(initial)
+ session.tick(_context(0.1, 0.0, 0.2, 0))
+ waiting = session.tick(_context(0.2, 0.2, 0.2, 0))
+ assert waiting.pending_effect is not None
+ assert waiting.status is ExecutionStatus.RUNNING
+ assert waiting.task_state.get_held_object("arm") is None
+ completed = session.tick(
+ _context(0.3, 0.2, 0.2, 0),
+ effect_result=_effect_result(
+ waiting.pending_effect.verification_id,
+ torch.tensor([True]),
+ torch.tensor([False]),
+ ),
+ )
+ assert completed.status is ExecutionStatus.COMPLETED
+ assert completed.task_state.get_held_object("arm") is not None
+ provider.assert_called_once()
+
+
@pytest.mark.parametrize(
("segment_name", "message"),
(("missing", "missing segment"), ("prepare", "first trajectory segment")),
diff --git a/tests/sim/motion/planners/test_curobo_planner.py b/tests/sim/motion/planners/test_curobo_planner.py
index 4609677f1..e0d6bc7f7 100644
--- a/tests/sim/motion/planners/test_curobo_planner.py
+++ b/tests/sim/motion/planners/test_curobo_planner.py
@@ -701,7 +701,10 @@ def test_dynamic_update_uses_registry_id_in_curobo_backend():
assert [(name, env_idx) for name, _, env_idx in updates] == [("registry_cube", 0)]
-def test_validate_joint_trajectory_checks_every_exact_sample_in_curobo_order():
+@pytest.mark.parametrize("failing_cost", ["bound", "self", "scene"])
+def test_validate_joint_trajectory_checks_every_exact_sample_in_curobo_order(
+ failing_cost,
+):
"""The collision gate preserves samples and maps simulator joint order."""
planner = object.__new__(CuroboPlanner)
planner.cfg = CuroboPlannerCfg(
@@ -716,12 +719,18 @@ def from_position(position, *, joint_names):
joint_states.append((position.clone(), tuple(joint_names)))
return SimpleNamespace(position=position)
- def validate(sample, *, env_query_idx):
- collision_queries.append((sample.clone(), env_query_idx.clone()))
- validity = torch.ones(sample.shape[:2], dtype=torch.bool)
- if len(collision_queries) == 2:
- validity[1, 0] = False
- return validity
+ def kinematics(sample):
+ return SimpleNamespace(position=sample, robot_spheres=sample.unsqueeze(-2))
+
+ def cost(kind, sample):
+ value = torch.zeros(sample.shape[:2])
+ if kind == failing_cost and sample[1, 0, 0] == pytest.approx(3.1):
+ value[1, 0] = 1.0
+ return value
+
+ def scene_cost(state, *, idxs_env_query):
+ collision_queries.append((state.position.clone(), idxs_env_query.clone()))
+ return cost("scene", state.position)
planner._bindings = SimpleNamespace(
JointState=SimpleNamespace(from_position=from_position),
@@ -729,7 +738,17 @@ def validate(sample, *, env_query_idx):
backend = SimpleNamespace(
sim_joint_names=["sim_left", "sim_right"],
sim_to_curobo_col_idx=None,
- collision_checker=SimpleNamespace(validate=validate),
+ collision_checker=SimpleNamespace(
+ get_kinematics=kinematics,
+ setup_batch_tensors=lambda batch, horizon: None,
+ self_collision_cost=object(),
+ get_bound=lambda sample: cost("bound", sample),
+ get_self_collision=lambda spheres: cost("self", spheres.squeeze(-2)),
+ collision_constraint=SimpleNamespace(
+ update_num_spheres=lambda count, batch, horizon: None,
+ forward=scene_cost,
+ ),
+ ),
profile=SimpleNamespace(
sim_to_curobo_joint_names={
"sim_left": "curobo_left",
@@ -1097,3 +1116,105 @@ def test_curobo_uses_accelerator_with_cpu_physics():
finally:
sim.destroy()
SimulationManager.flush_cleanup_queue()
+
+
+@pytest.mark.parametrize("use_current_fallback", [False, True])
+def test_robot_yaml_cache_invalidates_when_locked_joint_initial_state_changes(
+ tmp_path, monkeypatch, use_current_fallback
+):
+ """A changed gripper pose must rebuild its static collision geometry."""
+ import embodichain.lab.sim.motion.planners.curobo.curobo_yaml as yaml_module
+
+ urdf = tmp_path / "robot.urdf"
+ urdf.write_text("")
+ planner = object.__new__(CuroboPlanner)
+ planner.cfg = CuroboPlannerCfg(robot_uid="cache-test")
+ planner.cfg.auto_gen.cache_dir = str(tmp_path)
+ planner._curobo_device = torch.device("cpu")
+ current = torch.tensor([[0.0, 0.02]])
+ planner.robot = SimpleNamespace(
+ joint_names=("arm_joint", "finger_joint"),
+ control_parts={"arm": ["arm_joint"]},
+ cfg=SimpleNamespace(
+ fpath=str(urdf), init_qpos=None if use_current_fallback else [0.0, 0.02]
+ ),
+ get_qpos=lambda: current.clone(),
+ )
+ generated = []
+
+ def generate(robot, control_part, output_path, **kwargs):
+ from pathlib import Path
+
+ generated.append(output_path)
+ Path(output_path).write_text("robot_cfg: {}")
+ return output_path
+
+ monkeypatch.setattr(yaml_module, "generate_curobo_robot_yaml", generate)
+ original = planner._auto_generate_robot_yaml("arm", "tool")
+ assert planner._auto_generate_robot_yaml("arm", "tool") == original
+ assert len(generated) == 1
+ if use_current_fallback:
+ current[0, 1] = 0.04
+ else:
+ planner.robot.cfg.init_qpos[1] = 0.04
+ changed = planner._auto_generate_robot_yaml("arm", "tool")
+ assert changed != original
+ assert len(generated) == 2
+ if use_current_fallback:
+ current[0, 0] = 0.5
+ else:
+ planner.robot.cfg.init_qpos[0] = 0.5
+ assert planner._auto_generate_robot_yaml("arm", "tool") == changed
+
+
+@pytest.mark.parametrize("use_current_fallback", [False, True])
+def test_runtime_backend_rejects_changed_locked_joints_until_explicit_close(
+ use_current_fallback,
+):
+ """Runtime cache hits must obey the same lock signature as generated YAMLs."""
+ planner = object.__new__(CuroboPlanner)
+ planner.cfg = CuroboPlannerCfg(robot_uid="runtime-lock", use_cuda_graph=False)
+ planner._curobo_device = torch.device("cpu")
+ current = torch.tensor([[0.0, 0.02]])
+ planner.robot = SimpleNamespace(
+ joint_names=("arm_joint", "finger_joint"),
+ control_parts={"arm": ["arm_joint"]},
+ cfg=SimpleNamespace(init_qpos=None if use_current_fallback else [0.0, 0.02]),
+ get_qpos=lambda: current.clone(),
+ )
+ planner._backend_cache = {}
+ built, closed = [], []
+
+ def build(**kwargs):
+ backend = SimpleNamespace(planner=object(), use_cuda_graph=False)
+ built.append(backend)
+ return backend
+
+ planner._materialize_profile = lambda control_part: object()
+ planner._resolve_sim_joint_names = lambda control_part: ["arm_joint"]
+ planner._build_backend = build
+ planner._warmup_backend = lambda backend: None
+ planner._close_planner = closed.append
+
+ original = planner._get_backend("arm", 1)
+ assert planner._get_backend("arm", 1) is original
+ if use_current_fallback:
+ current[0, 0] = 0.5
+ else:
+ planner.robot.cfg.init_qpos[0] = 0.5
+ assert planner._get_backend("arm", 1) is original
+ if use_current_fallback:
+ current[0, 1] = 0.04
+ else:
+ planner.robot.cfg.init_qpos[1] = 0.04
+ with pytest.raises(RuntimeError, match="locked-joint configuration changed"):
+ planner._get_backend("arm", 1)
+ assert len(built) == 1
+ assert not closed
+
+ planner.close()
+ assert closed == [original.planner]
+ replacement = planner._get_backend("arm", 1)
+ assert replacement is not original
+ assert replacement.robot_lock_signature != original.robot_lock_signature
+ assert len(built) == 2
diff --git a/tests/sim/objects/test_articulation.py b/tests/sim/objects/test_articulation.py
index 0471f8119..7a16984b4 100644
--- a/tests/sim/objects/test_articulation.py
+++ b/tests/sim/objects/test_articulation.py
@@ -44,6 +44,17 @@
NUM_ARENAS = 10
+@pytest.mark.no_sim
+def test_root_link_name_reads_registered_native_entity() -> None:
+ """Initial-state capture resolves the actual root through native entities."""
+ articulation = object.__new__(Articulation)
+ articulation._entities = [
+ SimpleNamespace(get_root_link_name=lambda: "fixed_robot_base")
+ ]
+
+ assert articulation.root_link_name == "fixed_robot_base"
+
+
class _GravityEntity:
"""Record native gravity calls for an articulation test double."""