From ebceba78f26cc70734658f55265c8eedd6c647f8 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Tue, 6 Oct 2026 14:52:22 +0200 Subject: [PATCH 01/45] Add custom triangular mesh spawning --- .../maximiliank-custom-mesh.added.rst | 2 + .../changelog.d/maximiliank-custom-mesh.minor | 0 source/isaaclab/isaaclab/sim/__init__.pyi | 4 ++ .../isaaclab/sim/spawners/__init__.pyi | 4 ++ .../isaaclab/sim/spawners/meshes/__init__.pyi | 4 ++ .../isaaclab/sim/spawners/meshes/meshes.py | 69 ++++++++++++++++--- .../sim/spawners/meshes/meshes_cfg.py | 22 ++++++ source/isaaclab/test/sim/test_spawn_meshes.py | 19 +++++ 8 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 source/isaaclab/changelog.d/maximiliank-custom-mesh.added.rst create mode 100644 source/isaaclab/changelog.d/maximiliank-custom-mesh.minor diff --git a/source/isaaclab/changelog.d/maximiliank-custom-mesh.added.rst b/source/isaaclab/changelog.d/maximiliank-custom-mesh.added.rst new file mode 100644 index 000000000000..4f2d5bb1aa63 --- /dev/null +++ b/source/isaaclab/changelog.d/maximiliank-custom-mesh.added.rst @@ -0,0 +1,2 @@ +* Added :class:`~isaaclab.sim.MeshCustomCfg` for spawning meshes from authored + vertices and triangular faces. diff --git a/source/isaaclab/changelog.d/maximiliank-custom-mesh.minor b/source/isaaclab/changelog.d/maximiliank-custom-mesh.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 49d378654f90..e0b024d82697 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -132,6 +132,7 @@ __all__ = [ "VisualMaterialCfg", "spawn_mesh_capsule", "spawn_mesh_cone", + "spawn_mesh_custom", "spawn_mesh_cuboid", "spawn_mesh_cylinder", "spawn_mesh_rectangle", @@ -139,6 +140,7 @@ __all__ = [ "MeshCapsuleCfg", "MeshCfg", "MeshConeCfg", + "MeshCustomCfg", "MeshCuboidCfg", "MeshCylinderCfg", "MeshRectangleCfg", @@ -351,6 +353,7 @@ from .spawners import ( MeshCapsuleCfg, MeshCfg, MeshConeCfg, + MeshCustomCfg, MeshCuboidCfg, MeshCylinderCfg, MeshRectangleCfg, @@ -395,6 +398,7 @@ from .spawners import ( spawn_light, spawn_mesh_capsule, spawn_mesh_cone, + spawn_mesh_custom, spawn_mesh_cuboid, spawn_mesh_cylinder, spawn_mesh_rectangle, diff --git a/source/isaaclab/isaaclab/sim/spawners/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/__init__.pyi index 339d1ef667ba..be3d9e26ced4 100644 --- a/source/isaaclab/isaaclab/sim/spawners/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/__init__.pyi @@ -44,6 +44,7 @@ __all__ = [ "VisualMaterialCfg", "spawn_mesh_capsule", "spawn_mesh_cone", + "spawn_mesh_custom", "spawn_mesh_cuboid", "spawn_mesh_cylinder", "spawn_mesh_rectangle", @@ -51,6 +52,7 @@ __all__ = [ "MeshCapsuleCfg", "MeshCfg", "MeshConeCfg", + "MeshCustomCfg", "MeshCuboidCfg", "MeshCylinderCfg", "MeshRectangleCfg", @@ -127,6 +129,7 @@ from .materials import ( from .meshes import ( spawn_mesh_capsule, spawn_mesh_cone, + spawn_mesh_custom, spawn_mesh_cuboid, spawn_mesh_cylinder, spawn_mesh_rectangle, @@ -134,6 +137,7 @@ from .meshes import ( MeshCapsuleCfg, MeshCfg, MeshConeCfg, + MeshCustomCfg, MeshCuboidCfg, MeshCylinderCfg, MeshRectangleCfg, diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi index c853dca4d1d0..fd1e4ac5fdf5 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi @@ -6,6 +6,7 @@ __all__ = [ "spawn_mesh_capsule", "spawn_mesh_cone", + "spawn_mesh_custom", "spawn_mesh_cuboid", "spawn_mesh_cylinder", "spawn_mesh_rectangle", @@ -13,6 +14,7 @@ __all__ = [ "MeshCapsuleCfg", "MeshCfg", "MeshConeCfg", + "MeshCustomCfg", "MeshCuboidCfg", "MeshCylinderCfg", "MeshRectangleCfg", @@ -22,6 +24,7 @@ __all__ = [ from .meshes import ( spawn_mesh_capsule, spawn_mesh_cone, + spawn_mesh_custom, spawn_mesh_cuboid, spawn_mesh_cylinder, spawn_mesh_rectangle, @@ -31,6 +34,7 @@ from .meshes_cfg import ( MeshCapsuleCfg, MeshCfg, MeshConeCfg, + MeshCustomCfg, MeshCuboidCfg, MeshCylinderCfg, MeshRectangleCfg, diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index 5d41e03bf837..eb55e8d77a78 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -28,6 +28,56 @@ from . import meshes_cfg +@clone +def spawn_mesh_custom( + prim_path: str, + cfg: meshes_cfg.MeshCustomCfg, + translation: tuple[float, float, float] | None = None, + orientation: tuple[float, float, float, float] | None = None, + **kwargs, +) -> Usd.Prim: + """Create a USD mesh from explicitly authored vertices and triangular faces. + + This spawner is intended for custom static geometry such as terrain patches, + tracks, and fixtures that cannot be represented by the generated mesh + primitives. Face winding is preserved, so counter-clockwise faces define the + colliding side when exact triangle-mesh collision is selected. + + Args: + prim_path: Prim path or pattern at which to spawn the asset. + cfg: Custom mesh configuration. + translation: Translation relative to the parent prim [m]. + orientation: Quaternion orientation in ``(x, y, z, w)`` order. + **kwargs: Additional cloning keyword arguments. + + Returns: + The created root prim. + + Raises: + ValueError: If the vertex or face arrays are malformed, a face index is + out of range, or the collision approximation is unknown. + """ + del kwargs + vertices = np.asarray(cfg.vertices, dtype=np.float32) + faces = np.asarray(cfg.faces, dtype=np.int64) + if vertices.ndim != 2 or vertices.shape[1:] != (3,) or len(vertices) < 3: + raise ValueError(f"Custom mesh vertices must have shape (N, 3) with N >= 3, got {vertices.shape}.") + if faces.ndim != 2 or faces.shape[1:] != (3,) or len(faces) < 1: + raise ValueError(f"Custom mesh faces must have shape (M, 3) with M >= 1, got {faces.shape}.") + if np.any(faces < 0) or np.any(faces >= len(vertices)): + raise ValueError(f"Custom mesh face indices must be in [0, {len(vertices) - 1}].") + if cfg.collision_approximation not in schemas.MESH_APPROXIMATION_TOKENS: + raise ValueError( + f"Unknown mesh collision approximation {cfg.collision_approximation!r}. " + f"Valid options are: {list(schemas.MESH_APPROXIMATION_TOKENS)}" + ) + + mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) + stage = get_current_stage() + _spawn_mesh_geom_from_mesh(prim_path, cfg, mesh, translation, orientation, stage=stage) + return stage.GetPrimAtPath(prim_path) + + @clone def spawn_mesh_sphere( prim_path: str, @@ -392,7 +442,7 @@ def _spawn_mesh_geom_from_mesh( "points": mesh.vertices, "faceVertexIndices": mesh.faces.flatten(), "faceVertexCounts": np.asarray([3] * len(mesh.faces)), - "subdivisionScheme": "bilinear", + "subdivisionScheme": getattr(cfg, "subdivision_scheme", "bilinear"), }, stage=stage, ) @@ -424,13 +474,16 @@ def _spawn_mesh_geom_from_mesh( ) elif cfg.collision_props is not None: # decide on type of collision approximation based on the mesh - if cfg.__class__.__name__ == "MeshSphereCfg": - collision_approximation = "boundingSphere" - elif cfg.__class__.__name__ == "MeshCuboidCfg": - collision_approximation = "boundingCube" - else: - # for: MeshCylinderCfg, MeshCapsuleCfg, MeshConeCfg - collision_approximation = "convexHull" + collision_approximation = getattr(cfg, "collision_approximation", None) + if collision_approximation is None: + if cfg.__class__.__name__ == "MeshSphereCfg": + collision_approximation = "boundingSphere" + elif cfg.__class__.__name__ == "MeshCuboidCfg": + collision_approximation = "boundingCube" + else: + # for: MeshCylinderCfg, MeshCapsuleCfg, MeshConeCfg + collision_approximation = "convexHull" + # apply collision approximation to mesh # note: for primitives, we use the convex hull approximation -- this should be sufficient for most cases. UsdPhysics.MeshCollisionAPI.Apply(mesh_prim).GetApproximationAttr().Set(collision_approximation) # collision properties anchor at the geometry prim diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py index 2580165740e0..17d9f40945d3 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py @@ -88,6 +88,28 @@ class MeshCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): """ +@configclass +class MeshCustomCfg(MeshCfg): + """Configuration parameters for a mesh authored from vertices and triangular faces. + + See :meth:`spawn_mesh_custom` for more information. + """ + + func: Callable | str = "{DIR}.meshes:spawn_mesh_custom" + + vertices: tuple[tuple[float, float, float], ...] = MISSING + """Vertex positions [m].""" + + faces: tuple[tuple[int, int, int], ...] = MISSING + """Triangle vertex indices with counter-clockwise front-face winding.""" + + collision_approximation: str = "none" + """Mesh collision approximation name. Defaults to ``"none"`` for exact triangle-mesh collision.""" + + subdivision_scheme: Literal["none", "catmullClark", "loop", "bilinear"] = "none" + """USD subdivision scheme. Defaults to ``"none"`` to preserve the authored surface.""" + + @configclass class MeshSphereCfg(MeshCfg): """Configuration parameters for a sphere mesh prim with deformable properties. diff --git a/source/isaaclab/test/sim/test_spawn_meshes.py b/source/isaaclab/test/sim/test_spawn_meshes.py index 282c9d48f0c6..786dcaedd2bd 100644 --- a/source/isaaclab/test/sim/test_spawn_meshes.py +++ b/source/isaaclab/test/sim/test_spawn_meshes.py @@ -157,6 +157,25 @@ def test_invalid_edge_refinement(sim): cfg.func("/World/Invalid", cfg) +def test_spawn_custom_mesh(sim): + """Test spawning an exact collision mesh from authored triangle data.""" + cfg = sim_utils.MeshCustomCfg( + vertices=((-0.5, -0.5, 0.0), (0.5, -0.5, 0.0), (0.5, 0.5, 0.0), (-0.5, 0.5, 0.0)), + faces=((0, 1, 2), (0, 2, 3)), + collision_props=sim_utils.CollisionBaseCfg(), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.4, 0.8)), + ) + prim = cfg.func("/World/CustomMesh", cfg) + + assert prim.IsValid() + mesh_prim = sim.stage.GetPrimAtPath("/World/CustomMesh/geometry/mesh") + assert mesh_prim.GetPrimTypeInfo().GetTypeName() == "Mesh" + assert mesh_prim.GetAttribute("faceVertexIndices").Get() == [0, 1, 2, 0, 2, 3] + assert mesh_prim.GetAttribute("subdivisionScheme").Get() == "none" + assert mesh_prim.GetAttribute("physics:approximation").Get() == "none" + assert not mesh_prim.GetAttribute("primvars:displayColor").HasAuthoredValue() + + """ Physics properties. """ From e7d3aa3bf2b3670a83b764083c13f02f7ea16508 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Tue, 6 Oct 2026 14:53:40 +0200 Subject: [PATCH 02/45] Stage conveyor adapters in Contrib --- .../maximiliank-conveyor-support.added.rst | 1 + .../maximiliank-conveyor-support.minor | 0 .../isaaclab_contrib/conveyors/README.md | 21 + .../isaaclab_contrib/conveyors/__init__.py | 10 + .../isaaclab_contrib/conveyors/newton.py | 804 ++++++++++++++++++ .../isaaclab_contrib/conveyors/physx.py | 532 ++++++++++++ .../conveyors/surface_velocity.py | 179 ++++ .../conveyors/test_surface_velocity_newton.py | 166 ++++ .../conveyors/test_surface_velocity_physx.py | 147 ++++ .../conveyors/test_surface_velocity_spec.py | 45 + .../maximiliank-conveyor-lifecycle.added.rst | 2 + .../maximiliank-conveyor-lifecycle.minor | 0 .../physics/newton_collision_cfg.py | 7 + .../isaaclab_newton/physics/newton_manager.py | 111 +++ 14 files changed, 2025 insertions(+) create mode 100644 source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst create mode 100644 source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.minor create mode 100644 source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md create mode 100644 source/isaaclab_contrib/isaaclab_contrib/conveyors/__init__.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py create mode 100644 source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py create mode 100644 source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py create mode 100644 source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py create mode 100644 source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py create mode 100644 source/isaaclab_newton/changelog.d/maximiliank-conveyor-lifecycle.added.rst create mode 100644 source/isaaclab_newton/changelog.d/maximiliank-conveyor-lifecycle.minor diff --git a/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst b/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst new file mode 100644 index 000000000000..3dc15f179a03 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst @@ -0,0 +1 @@ +* Added experimental straight and curved conveyor controls with Newton force-model and native CPU PhysX adapters. diff --git a/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.minor b/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md b/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md new file mode 100644 index 000000000000..751755a2487e --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md @@ -0,0 +1,21 @@ +# Experimental conveyor support + +These adapters stage conveyor integration in `isaaclab_contrib`; their API is +experimental and is not exported from Isaac Lab core or the backend packages. + +- `surface_velocity.SurfaceVelocitySpec` describes a straight or curved surface + in the collision prim's local frame. `SurfaceVelocityView` describes the shared + control operations. +- `newton.SurfaceVelocity` uses Newton's upstream `ConveyorForceModel`. The + adapter owns replicated surface/body bindings and solver lifecycle integration, + including hard resets and registration before CUDA graph capture. It requires + the Newton backend. +- `physx.SurfaceVelocity` uses native `PhysxSurfaceVelocityAPI` on CPU. + Author the API before simulation parsing with `apply_surface_velocity_api`. + This path requires the PhysX backend and Kit; GPU dynamics are unsupported. + +Both adapters retain commanded speeds while disabled and clear selected worlds' +runtime state on reset. The Newton adapter supports live traction controls; +PhysX uses authored contact materials and rejects unsupported traction setters. + +No meshes, assets, task registrations, or policy checkpoints are bundled here. diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/__init__.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/__init__.py new file mode 100644 index 000000000000..6dddd7297fff --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Experimental conveyor support for contributed tasks. + +Backend adapters are imported explicitly from :mod:`.newton` or :mod:`.physx` so +importing the surface descriptions does not load either optional backend. +""" diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py new file mode 100644 index 000000000000..52e4c5b8fcfa --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py @@ -0,0 +1,804 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Batched surface controls and lifecycle integration for Newton's conveyor force model. + +Newton owns contact classification and traction computation. This adapter resolves replicated +USD surfaces, selects transported bodies, and preserves controls across solver rebuilds. +""" + +from __future__ import annotations + +import re +from collections.abc import Sequence +from typing import Any + +import numpy as np +import warp as wp +from isaaclab_newton.physics import NewtonManager +from newton.examples.basic.example_basic_conveyor_forces import ConveyorForceModel +from newton.examples.basic.example_basic_conveyor_forces import ( + Vec3Pair as Vec3Pair, +) +from newton.examples.basic.example_basic_conveyor_forces import ( + compute_basis_vectors as compute_basis_vectors, +) +from newton.examples.basic.example_basic_conveyor_forces import ( + compute_point_force as compute_point_force, +) +from newton.examples.basic.example_basic_conveyor_forces import ( + compute_point_impulse as compute_point_impulse, +) + +from isaaclab.physics import PhysicsEvent + +from .surface_velocity import SurfaceVelocitySpec + + +@wp.kernel +def _update_belt_velocities( + direction: wp.array[wp.vec3], + radius: wp.array[wp.float32], + effective_velocity: wp.array[wp.float32], + linear_velocity: wp.array[wp.vec3], + angular_velocity: wp.array[wp.vec3], +): + conveyor_id = wp.tid() + linear_velocity[conveyor_id] = direction[conveyor_id] * effective_velocity[conveyor_id] + angular_velocity[conveyor_id] = direction[conveyor_id] * (effective_velocity[conveyor_id] / radius[conveyor_id]) + + +@wp.kernel +def _filter_body_forces( + body_is_tracked: wp.array[wp.int32], + body_force: wp.array[wp.spatial_vector], +): + body_id = wp.tid() + if body_is_tracked[body_id] == 0: + body_force[body_id] = wp.spatial_vector() + + +@wp.kernel +def _advance_startup_scale( + dt: wp.float32, duration: wp.float32, elapsed: wp.array[wp.float32], scale: wp.array[wp.float32] +): + elapsed[0] += dt + scale[0] = wp.min(1.0, elapsed[0] / duration) + + +@wp.kernel +def _integrate_encoders( + dt: wp.float32, + effective_velocity: wp.array[wp.float32], + position: wp.array[wp.float32], +): + conveyor_id = wp.tid() + position[conveyor_id] += dt * effective_velocity[conveyor_id] + + +@wp.kernel +def _update_effective_velocities( + commanded_velocity: wp.array[wp.float32], + enabled: wp.array[wp.int32], + effective_velocity: wp.array[wp.float32], +): + conveyor_id = wp.tid() + effective_velocity[conveyor_id] = commanded_velocity[conveyor_id] * wp.float32(enabled[conveyor_id]) + + +@wp.kernel +def _gather_float_values( + source: wp.array[wp.float32], + indices: wp.array[wp.int32], + values: wp.array[wp.float32], +): + output_id = wp.tid() + values[output_id] = source[indices[output_id]] + + +@wp.kernel +def _gather_int_values( + source: wp.array[wp.int32], + indices: wp.array[wp.int32], + values: wp.array[wp.int32], +): + output_id = wp.tid() + values[output_id] = source[indices[output_id]] + + +@wp.kernel +def _clear_selected_body_forces( + body_world: wp.array[wp.int32], + world_mask: wp.array[wp.bool], + body_force: wp.array[wp.spatial_vector], +): + body_id = wp.tid() + world_id = body_world[body_id] + if world_id >= 0 and world_mask[world_id]: + body_force[body_id] = wp.spatial_vector() + + +@wp.kernel +def _clear_selected_encoders( + conveyor_world: wp.array[wp.int32], + world_mask: wp.array[wp.bool], + encoder_position: wp.array[wp.float32], +): + conveyor_id = wp.tid() + world_id = conveyor_world[conveyor_id] + if world_mask[world_id]: + encoder_position[conveyor_id] = 0.0 + + +def _require_buffer_length(name: str, buffer: Any, expected: int) -> None: + """Reject missing or mis-sized buffers before a Warp launch can access them.""" + actual = len(buffer) if buffer is not None else 0 + if actual != expected: + raise RuntimeError(f"Conveyor force buffer {name!r} has length {actual}, expected {expected}.") + + +def _as_numpy(values: Any) -> np.ndarray: + """Convert supported tensor-like values to a host NumPy array.""" + if hasattr(values, "detach"): + return values.detach().cpu().numpy() + if hasattr(values, "numpy") and not isinstance(values, np.ndarray): + return values.numpy() + return np.asarray(values) + + +def _world_vector(transform_values: np.ndarray, local_vector: tuple[float, float, float]) -> wp.vec3: + """Rotate a local vector into world space and normalize it.""" + transform = wp.transform( + wp.vec3(*(float(value) for value in transform_values[:3])), + wp.quat(*(float(value) for value in transform_values[3:])), + ) + rotated = wp.transform_vector(transform, wp.vec3(*local_vector)) + values = np.asarray([float(rotated[index]) for index in range(3)], dtype=np.float32) + norm = float(np.linalg.norm(values)) + if norm <= 1.0e-8: + raise ValueError(f"Conveyor direction or surface normal must be non-zero, got {local_vector}.") + values /= norm + return wp.vec3(*(float(value) for value in values)) + + +def _world_point(transform_values: np.ndarray, local_point: tuple[float, float, float]) -> wp.vec3: + """Transform a local point into world space.""" + transform = wp.transform( + wp.vec3(*(float(value) for value in transform_values[:3])), + wp.quat(*(float(value) for value in transform_values[3:])), + ) + point = wp.transform_point(transform, wp.vec3(*local_point)) + return wp.vec3(*(float(point[index]) for index in range(3))) + + +def _resolve_belt_prim_path(prim_path: str, env_path_format: str, world_id: int) -> str: + """Resolve one replicated conveyor template to an exact Newton shape label.""" + if prim_path.startswith("{ENV_REGEX_NS}/"): + return prim_path.format(ENV_REGEX_NS=env_path_format.format(world_id)) + return prim_path + + +def _shape_belongs_to_prim(shape_label: str, prim_path: str) -> bool: + """Return whether a Newton collision-shape label is the prim or one of its descendants.""" + return shape_label == prim_path or shape_label.startswith(f"{prim_path.rstrip('/')}/") + + +def _validate_env_path_format(env_path_format: str) -> None: + """Validate the concrete per-world path format derived from the scene cloner.""" + if not isinstance(env_path_format, str) or env_path_format.count("{}") != 1: + raise ValueError(f"Conveyor env_path_format must contain exactly one '{{}}', got {env_path_format!r}.") + if not env_path_format.startswith("/"): + raise ValueError(f"Conveyor env_path_format must be absolute, got {env_path_format!r}.") + + +def _validate_newton_surface_specs(surface_specs: Sequence[SurfaceVelocitySpec]) -> None: + """Validate Newton-specific requirements before registering lifecycle callbacks.""" + if not surface_specs: + raise ValueError("At least one surface-velocity specification is required.") + prim_paths = [spec.prim_path for spec in surface_specs] + if len(set(prim_paths)) != len(prim_paths): + raise ValueError(f"Conveyor prim paths must be unique, got {prim_paths}.") + for index, path in enumerate(prim_paths): + for other in prim_paths[index + 1 :]: + if _shape_belongs_to_prim(path, other) or _shape_belongs_to_prim(other, path): + raise ValueError(f"Conveyor prim paths must not be ancestors of one another: {path!r}, {other!r}.") + for spec in surface_specs: + if spec.curved and spec.radius is None: + raise ValueError(f"Newton requires an explicit positive radius for curved belt {spec.prim_path!r}.") + + +class SurfaceVelocity: + """Adapt Newton's MuJoCo conveyor force model to the Isaac Lab lifecycle. + + The driver is created after the simulation context but before its first + reset. It requests solved contact forces before model finalization, then + binds model-specific buffers after solver initialization and before CUDA + graph capture. A hard simulation reset transparently replaces that binding + with buffers for the re-finalized model. Resolved belt indices use deterministic + environment-major ordering across those rebuilds. + """ + + def __init__( + self, + num_envs: int, + surface_specs: Sequence[SurfaceVelocitySpec], + *, + body_pattern: str, + body_count_per_env: int | None = None, + startup_duration_s: float = 1.0, + env_path_format: str = "/World/envs/env_{}", + ) -> None: + """Register the force pipeline for the next Newton model initialization. + + Args: + num_envs: Number of replicated simulation environments. + surface_specs: Authored surface descriptions in stable within-environment order. + body_pattern: Regular expression selecting bodies that receive surface traction. + body_count_per_env: Expected selected body count per environment, or ``None``. + startup_duration_s: Duration of the initial traction ramp [s]. + env_path_format: Format string resolving one exact environment root from its integer world index. + """ + surface_specs = tuple(surface_specs) + if not all(isinstance(spec, SurfaceVelocitySpec) for spec in surface_specs): + raise TypeError("Every surface specification must be a SurfaceVelocitySpec.") + _validate_newton_surface_specs(surface_specs) + if not isinstance(num_envs, int) or isinstance(num_envs, bool) or num_envs <= 0: + raise ValueError(f"num_envs must be a positive integer, got {num_envs!r}.") + if num_envs > 1 and any(not spec.prim_path.startswith("{ENV_REGEX_NS}/") for spec in surface_specs): + raise ValueError( + "Replicated conveyor environments require every belt prim_path to start with '{ENV_REGEX_NS}/'." + ) + if not np.isfinite(startup_duration_s) or startup_duration_s <= 0.0: + raise ValueError(f"Conveyor startup duration must be positive, got {startup_duration_s}.") + _validate_env_path_format(env_path_format) + if not isinstance(body_pattern, str): + raise ValueError(f"body_pattern must be a regular-expression string, got {body_pattern!r}.") + try: + re.compile(body_pattern) + except re.error as exc: + raise ValueError(f"Invalid body pattern: {body_pattern!r}.") from exc + if body_count_per_env is not None and ( + not isinstance(body_count_per_env, int) or isinstance(body_count_per_env, bool) or body_count_per_env < 0 + ): + raise ValueError("body_count_per_env must be a non-negative integer or None.") + self._binding: _SurfaceVelocityBinding | None = None + self._closed = False + self._num_envs = num_envs + self._surface_specs = surface_specs + self._binding_kwargs = { + "num_envs": num_envs, + "surface_specs": self._surface_specs, + "startup_duration_s": startup_duration_s, + "env_path_format": env_path_format, + "body_pattern": body_pattern, + "body_count_per_env": body_count_per_env, + } + self._model_init_handle = NewtonManager.register_callback( + self._request_contact_forces, + PhysicsEvent.MODEL_INIT, + name="surface_velocity_contact_attribute", + ) + try: + NewtonManager.register_solver_init_callback(self._bind_solver) + except Exception: + self._model_init_handle.deregister() + raise + + def _require_binding(self) -> _SurfaceVelocityBinding: + """Return the current binding or fail before solver initialization.""" + binding = self._binding + if binding is None: + raise RuntimeError("Surface velocity is not bound to an initialized Newton solver.") + return binding + + @property + def specs(self) -> tuple[SurfaceVelocitySpec, ...]: + """Authored surface descriptions in stable within-environment order.""" + return self._surface_specs + + @property + def surfaces_per_env(self) -> int: + """Number of authored surfaces in each replicated environment.""" + return len(self._surface_specs) + + @property + def num_surfaces(self) -> int: + """Total number of resolved surfaces across all environments.""" + return self._num_envs * self.surfaces_per_env + + @property + def count(self) -> int: + """Alias for :attr:`num_surfaces`, matching tensor-view naming.""" + return self.num_surfaces + + @property + def initialized(self) -> bool: + """Whether the driver is bound to the active Newton solver.""" + return self._binding is not None + + @property + def prim_paths(self) -> tuple[str, ...]: + """Resolved Newton shape labels in environment-major belt order.""" + return self._require_binding().surface_paths + + @property + def surface_paths(self) -> tuple[str, ...]: + """Alias for :attr:`prim_paths`.""" + return self.prim_paths + + def set_velocities(self, velocities: Any, indices: Any = None) -> None: + """Set signed surface speeds, preserving commands while surfaces are disabled.""" + self._require_binding().set_velocities(velocities, indices) + + def get_velocities(self, indices: Any = None, clone: bool = True) -> wp.array: + """Return effective surface speeds, with disabled surfaces reported as zero.""" + return self._require_binding().get_velocities(indices, clone) + + def get_commanded_velocities(self, indices: Any = None, clone: bool = True) -> wp.array: + """Return staged surface speeds without applying the enabled mask.""" + return self._require_binding().get_commanded_velocities(indices, clone) + + def set_enabled(self, flags: Any, indices: Any = None) -> None: + """Enable or disable selected surfaces without discarding their speed commands.""" + self._require_binding().set_enabled(flags, indices) + + def get_enabled(self, indices: Any = None, clone: bool = True) -> wp.array: + """Return integer enabled flags for selected surfaces.""" + return self._require_binding().get_enabled(indices, clone) + + def get_encoder_positions(self, indices: Any = None, clone: bool = True) -> wp.array: + """Return physics-rate integrated surface travel distances [m].""" + return self._require_binding().get_encoder_positions(indices, clone) + + def reset(self, env_ids: Any = None) -> None: + """Clear stale force and encoder state for selected environments.""" + self._require_binding().reset(env_ids) + + def _request_contact_forces(self, _event: Any) -> None: + """Request solved per-contact forces before the Newton model is finalized.""" + NewtonManager.request_extended_contact_attribute("force") + + def _bind_solver(self, model: Any, contacts: Any) -> None: + """Bind graph callbacks and buffers to the newly initialized solver.""" + previous = self._binding + settings = None + if previous is not None: + settings = ( + previous._command_velocity_host.copy(), + previous._enabled_host.copy(), + ) + previous.close() + self._binding = None + + binding = _SurfaceVelocityBinding(model=model, contacts=contacts, **self._binding_kwargs) + if settings is not None: + velocities, enabled = settings + binding.set_velocities(velocities) + binding.set_enabled(enabled) + self._binding = binding + + def close(self) -> None: + """Deregister lifecycle and graph callbacks; repeated calls are safe.""" + if self._closed: + return + NewtonManager.unregister_solver_init_callback(self._bind_solver) + self._model_init_handle.deregister() + if self._binding is not None: + self._binding.close() + self._binding = None + self._closed = True + + +class _SurfaceVelocityBinding: + """Bind replicated surfaces and controls to Newton's conveyor force model.""" + + def __init__( + self, + model: Any, + contacts: Any, + num_envs: int, + surface_specs: Sequence[SurfaceVelocitySpec], + *, + body_pattern: str, + body_count_per_env: int | None = None, + startup_duration_s: float = 1.0, + env_path_format: str = "/World/envs/env_{}", + ) -> None: + """Initialize the binding before Newton CUDA graph capture. + + Args: + model: Finalized Newton model owned by the active solver. + contacts: Contact buffer owned by the active solver. + num_envs: Number of replicated simulation environments. + surface_specs: Authored surface descriptions in stable within-environment order. + body_pattern: Regular expression selecting bodies that receive surface traction. + body_count_per_env: Expected selected body count per environment, or ``None``. + startup_duration_s: Duration of the initial traction ramp [s]. + env_path_format: Format string resolving one exact environment root from its integer world index. + """ + if not isinstance(num_envs, int) or isinstance(num_envs, bool) or num_envs <= 0: + raise ValueError(f"num_envs must be a positive integer, got {num_envs!r}.") + if not np.isfinite(startup_duration_s) or startup_duration_s <= 0.0: + raise ValueError(f"Conveyor startup duration must be positive, got {startup_duration_s}.") + _validate_env_path_format(env_path_format) + + self._surface_specs = tuple(surface_specs) + _validate_newton_surface_specs(self._surface_specs) + try: + compiled_body_pattern = re.compile(body_pattern) + except re.error as exc: + raise ValueError(f"Invalid body pattern: {body_pattern!r}.") from exc + + if model is None or contacts is None: + raise RuntimeError("The conveyor driver requires an initialized Newton model and contact buffer.") + if contacts.force is None: + raise RuntimeError( + "Newton did not allocate per-contact force reporting. The conveyor driver must request the " + "'force' contact attribute before model finalization." + ) + if model.world_count != num_envs: + raise RuntimeError(f"Newton model has {model.world_count} worlds, expected {num_envs}.") + + self._model = model + self._contacts = contacts + self._device = model.device + self._num_envs = num_envs + self._startup_duration_s = startup_duration_s + self._closed = False + self._validate_backend_buffers() + + surfaces_per_env = len(self._surface_specs) + conveyor_count = num_envs * surfaces_per_env + conveyor_shapes = [-1] * conveyor_count + direction = [wp.vec3() for _ in range(conveyor_count)] + pivot_point = [wp.vec3() for _ in range(conveyor_count)] + radius = [1.0] * conveyor_count + surface_normal = [wp.vec3() for _ in range(conveyor_count)] + conveyor_world = [conveyor_id // surfaces_per_env for conveyor_id in range(conveyor_count)] + surface_paths = [""] * conveyor_count + + shape_body = model.shape_body.numpy() + shape_world = model.shape_world.numpy() + shape_transform = model.shape_transform.numpy() + seen_sections: set[tuple[int, int]] = set() + for shape_id, label in enumerate(model.shape_label): + world_id = int(shape_world[shape_id]) + if not 0 <= world_id < num_envs: + continue + matching_specs = [ + index + for index, spec in enumerate(self._surface_specs) + if _shape_belongs_to_prim(label, _resolve_belt_prim_path(spec.prim_path, env_path_format, world_id)) + ] + if not matching_specs: + continue + if len(matching_specs) > 1: + raise RuntimeError(f"Conveyor shape {label!r} matches more than one section specification.") + if int(shape_body[shape_id]) >= 0: + raise ValueError(f"Conveyor shape must be static: {label}") + + spec_id = matching_specs[0] + section_key = (world_id, spec_id) + if section_key in seen_sections: + raise RuntimeError( + f"World {world_id} contains multiple shapes matching conveyor section " + f"{self._surface_specs[spec_id].prim_path!r}." + ) + seen_sections.add(section_key) + + spec = self._surface_specs[spec_id] + conveyor_id = world_id * surfaces_per_env + spec_id + conveyor_shapes[conveyor_id] = shape_id + direction[conveyor_id] = _world_vector(shape_transform[shape_id], spec.direction) + pivot_point[conveyor_id] = _world_point(shape_transform[shape_id], spec.pivot_point) + radius[conveyor_id] = 1.0 if spec.radius is None else spec.radius + surface_normal[conveyor_id] = _world_vector(shape_transform[shape_id], spec.surface_normal) + surface_paths[conveyor_id] = label + + expected_sections = { + (world_id, spec_id) for world_id in range(num_envs) for spec_id in range(len(self._surface_specs)) + } + missing_sections = sorted(expected_sections - seen_sections) + if missing_sections: + details = ", ".join( + f"world {world_id}: {self._surface_specs[spec_id].prim_path}" + for world_id, spec_id in missing_sections[:8] + ) + raise RuntimeError(f"Missing {len(missing_sections)} conveyor collision sections ({details}).") + + body_is_tracked = np.zeros(model.body_count, dtype=np.int32) + tracked_counts = np.zeros(num_envs, dtype=np.int32) + body_world = model.body_world.numpy() + for body_id, label in enumerate(model.body_label): + if compiled_body_pattern.search(label) is None: + continue + world_id = int(body_world[body_id]) + if not 0 <= world_id < num_envs: + raise RuntimeError(f"Transported body {label!r} belongs to invalid world {world_id}.") + body_is_tracked[body_id] = 1 + tracked_counts[world_id] += 1 + + if body_count_per_env is not None: + bad_worlds = np.flatnonzero(tracked_counts != body_count_per_env) + if bad_worlds.size: + details = ", ".join(f"world {world_id}: {tracked_counts[world_id]}" for world_id in bad_worlds[:8]) + raise RuntimeError( + f"Body pattern {body_pattern!r} expected {body_count_per_env} bodies per world ({details})." + ) + if not np.any(body_is_tracked): + raise RuntimeError(f"Body pattern {body_pattern!r} matched no Newton bodies.") + + self._conveyor = ConveyorForceModel(model, solver_type="mujoco") + for conveyor_id, shape_id in enumerate(conveyor_shapes): + spec = self._surface_specs[conveyor_id % surfaces_per_env] + parameters = { + "surface_normal": surface_normal[conveyor_id], + "friction": spec.friction_coefficient, + "threshold": spec.contact_threshold, + } + if spec.curved: + self._conveyor.add_pivot_belt(shape_id, pivot_point[conveyor_id], wp.vec3(), **parameters) + else: + self._conveyor.add_constant_belt(shape_id, wp.vec3(), **parameters) + self._conveyor.finalize(contacts) + + self._surface_paths = tuple(surface_paths) + self._body_is_tracked = wp.array(body_is_tracked, dtype=wp.int32, device=self._device) + self._direction = wp.array(direction, dtype=wp.vec3, device=self._device) + self._radius = wp.array(radius, dtype=wp.float32, device=self._device) + self._conveyor_world = wp.array(conveyor_world, dtype=wp.int32, device=self._device) + + authored_velocity = np.asarray([spec.velocity for spec in self._surface_specs], dtype=np.float32) + authored_enabled = np.asarray([spec.enabled for spec in self._surface_specs], dtype=np.int32) + self._command_velocity_host = np.tile(authored_velocity, num_envs) + self._enabled_host = np.tile(authored_enabled, num_envs) + self._command_velocity = wp.array(self._command_velocity_host, dtype=wp.float32, device=self._device) + self._enabled = wp.array(self._enabled_host, dtype=wp.int32, device=self._device) + self._effective_velocity = wp.zeros(conveyor_count, dtype=wp.float32, device=self._device) + self._encoder_position = wp.zeros(conveyor_count, dtype=wp.float32, device=self._device) + self._elapsed_time = wp.zeros(1, dtype=wp.float32, device=self._device) + self._conveyor.set_speed_scale(0.0) + + self._world_mask_host = np.zeros(num_envs, dtype=np.bool_) + self._world_mask = wp.zeros(num_envs, dtype=wp.bool, device=self._device) + self._refresh_effective_velocities() + + NewtonManager.register_state_force_callback(self.apply) + NewtonManager.register_post_solver_substep_callback(self.update) + + @property + def surface_paths(self) -> tuple[str, ...]: + """Resolved Newton shape labels in conveyor-index order.""" + return self._surface_paths + + def set_velocities(self, velocities: Any, indices: Any = None) -> None: + """Set signed surface speeds, preserving commands while surfaces are disabled.""" + selected = self._resolve_indices(indices) + self._command_velocity_host[selected] = self._broadcast_1d(velocities, len(selected), "velocities") + self._command_velocity.assign(self._command_velocity_host) + self._refresh_effective_velocities() + + def get_velocities(self, indices: Any = None, clone: bool = True) -> wp.array: + """Return effective surface speeds, with disabled surfaces reported as zero.""" + return self._get_device_values(self._effective_velocity, indices, clone) + + def get_commanded_velocities(self, indices: Any = None, clone: bool = True) -> wp.array: + """Return staged surface speeds without applying the enabled mask.""" + return self._get_device_values(self._command_velocity, indices, clone) + + def set_enabled(self, flags: Any, indices: Any = None) -> None: + """Enable or disable selected surfaces without discarding their speed commands.""" + selected = self._resolve_indices(indices) + values = self._broadcast_1d(flags, len(selected), "enabled flags") + if not np.all(np.isin(values, (0.0, 1.0))): + raise ValueError(f"Surface enabled flags must contain {len(selected)} boolean values.") + self._enabled_host[selected] = values.astype(np.int32) + self._enabled.assign(self._enabled_host) + self._refresh_effective_velocities() + + def get_enabled(self, indices: Any = None, clone: bool = True) -> wp.array: + """Return integer enabled flags for selected surfaces.""" + return self._get_device_int_values(self._enabled, indices, clone) + + def get_encoder_positions(self, indices: Any = None, clone: bool = True) -> wp.array: + """Return physics-rate integrated surface travel distances [m].""" + return self._get_device_values(self._encoder_position, indices, clone) + + def reset(self, env_ids: Any = None) -> None: + """Clear stale force and encoder state for selected environments. + + A full reset also restarts the global startup ramp. Partial vectorized + resets leave other environments' conveyor forces and startup state intact. + + Args: + env_ids: Environment indices to reset, or ``None`` for every environment. + """ + if env_ids is None: + self._conveyor.conveyor_body_f.zero_() + self._encoder_position.zero_() + self._elapsed_time.zero_() + self._conveyor.global_velocity_scale.zero_() + return + + ids = _as_numpy(env_ids) + if not np.issubdtype(ids.dtype, np.integer): + raise IndexError(f"Surface reset environment indices must be integers, got {env_ids!r}.") + ids = ids.astype(np.int64, copy=False).reshape(-1) + if np.any((ids < 0) | (ids >= self._num_envs)): + raise IndexError(f"Conveyor reset environment indices are out of range: {ids.tolist()}.") + self._world_mask_host.fill(False) + self._world_mask_host[ids] = True + if np.all(self._world_mask_host): + self.reset() + return + self._world_mask.assign(self._world_mask_host) + wp.launch( + _clear_selected_body_forces, + dim=self._model.body_count, + inputs=[self._model.body_world, self._world_mask], + outputs=[self._conveyor.conveyor_body_f], + device=self._device, + ) + wp.launch( + _clear_selected_encoders, + dim=len(self._surface_paths), + inputs=[self._conveyor_world, self._world_mask], + outputs=[self._encoder_position], + device=self._device, + ) + + def close(self) -> None: + """Deregister Newton callbacks and release references held by the driver.""" + if self._closed: + return + NewtonManager.unregister_state_force_callback(self.apply) + NewtonManager.unregister_post_solver_substep_callback(self.update) + self._closed = True + + def apply(self, state) -> None: + """Apply the wrench computed from the preceding physics solve.""" + self._conveyor.apply(state) + + def update(self, solver, contacts, state, dt: float) -> None: + """Update surface travel and let Newton compute the next conveyor wrench.""" + wp.launch( + _advance_startup_scale, + dim=1, + inputs=[dt, self._startup_duration_s], + outputs=[self._elapsed_time, self._conveyor.global_velocity_scale], + device=self._device, + ) + wp.launch( + _integrate_encoders, + dim=len(self._surface_paths), + inputs=[dt, self._effective_velocity], + outputs=[self._encoder_position], + device=self._device, + ) + self._conveyor.update(solver, contacts, state, dt) + # Exclude robot links and other bodies outside the transported-body selection. + wp.launch( + _filter_body_forces, + dim=self._model.body_count, + inputs=[self._body_is_tracked], + outputs=[self._conveyor.conveyor_body_f], + device=self._device, + ) + + def _validate_backend_buffers(self) -> None: + """Validate every fixed-size Newton buffer consumed by conveyor kernels.""" + model = self._model + contacts = self._contacts + _require_buffer_length("model.shape_body", model.shape_body, model.shape_count) + _require_buffer_length("model.shape_world", model.shape_world, model.shape_count) + _require_buffer_length("model.shape_transform", model.shape_transform, model.shape_count) + _require_buffer_length("model.body_world", model.body_world, model.body_count) + _require_buffer_length("model.body_com", model.body_com, model.body_count) + _require_buffer_length("model.body_inv_mass", model.body_inv_mass, model.body_count) + _require_buffer_length("model.body_inv_inertia", model.body_inv_inertia, model.body_count) + _require_buffer_length("contacts.force", contacts.force, contacts.rigid_contact_max) + _require_buffer_length( + "contacts.rigid_contact_shape0", contacts.rigid_contact_shape0, contacts.rigid_contact_max + ) + _require_buffer_length( + "contacts.rigid_contact_shape1", contacts.rigid_contact_shape1, contacts.rigid_contact_max + ) + _require_buffer_length( + "contacts.rigid_contact_normal", contacts.rigid_contact_normal, contacts.rigid_contact_max + ) + _require_buffer_length( + "contacts.rigid_contact_point0", contacts.rigid_contact_point0, contacts.rigid_contact_max + ) + _require_buffer_length( + "contacts.rigid_contact_point1", contacts.rigid_contact_point1, contacts.rigid_contact_max + ) + _require_buffer_length( + "contacts.rigid_contact_offset0", contacts.rigid_contact_offset0, contacts.rigid_contact_max + ) + _require_buffer_length( + "contacts.rigid_contact_offset1", contacts.rigid_contact_offset1, contacts.rigid_contact_max + ) + _require_buffer_length("contacts.rigid_contact_count", contacts.rigid_contact_count, 1) + + def _resolve_indices(self, indices: Any) -> np.ndarray: + """Normalize and validate a surface index selection.""" + if indices is None: + return np.arange(len(self._surface_paths), dtype=np.int64) + if isinstance(indices, slice): + return np.arange(len(self._surface_paths), dtype=np.int64)[indices] + selected = _as_numpy(indices) + if selected.dtype == np.bool_: + if selected.ndim != 1 or selected.size != len(self._surface_paths): + raise IndexError(f"Boolean surface indices must have length {len(self._surface_paths)}.") + return np.flatnonzero(selected).astype(np.int64) + if not np.issubdtype(selected.dtype, np.integer): + raise IndexError(f"Surface indices must be integers, got {indices!r}.") + selected = selected.astype(np.int64, copy=False).reshape(-1) + if np.any((selected < 0) | (selected >= len(self._surface_paths))): + raise IndexError(f"Conveyor surface indices are out of range: {selected.tolist()}.") + return selected + + def _refresh_effective_velocities(self) -> None: + """Apply the enabled mask at the one device-side command seam.""" + wp.launch( + _update_effective_velocities, + dim=len(self._surface_paths), + inputs=[self._command_velocity, self._enabled], + outputs=[self._effective_velocity], + device=self._device, + ) + wp.launch( + _update_belt_velocities, + dim=len(self._surface_paths), + inputs=[self._direction, self._radius, self._effective_velocity], + outputs=[self._conveyor.conv_const_vel, self._conveyor.conv_pivot_angvel], + device=self._device, + ) + + def _get_device_values(self, source: wp.array, indices: Any, clone: bool) -> wp.array: + """Clone a complete device buffer or gather a selected subset.""" + if indices is None: + return wp.clone(source) if clone else source + selected = self._resolve_indices(indices) + selected_device = wp.array(selected, dtype=wp.int32, device=self._device) + values = wp.empty(len(selected), dtype=wp.float32, device=self._device) + if len(selected) > 0: + wp.launch( + _gather_float_values, + dim=len(selected), + inputs=[source, selected_device], + outputs=[values], + device=self._device, + ) + return values + + def _get_device_int_values(self, source: wp.array, indices: Any, clone: bool) -> wp.array: + """Clone an integer device buffer or gather a selected subset.""" + if indices is None: + return wp.clone(source) if clone else source + selected = self._resolve_indices(indices) + selected_device = wp.array(selected, dtype=wp.int32, device=self._device) + values = wp.empty(len(selected), dtype=wp.int32, device=self._device) + if len(selected) > 0: + wp.launch( + _gather_int_values, + dim=len(selected), + inputs=[source, selected_device], + outputs=[values], + device=self._device, + ) + return values + + @staticmethod + def _broadcast_1d(values: Any, count: int, name: str) -> np.ndarray: + """Broadcast one scalar or validate one value per selected surface.""" + array = np.asarray(_as_numpy(values), dtype=np.float32) + if not np.all(np.isfinite(array)): + raise ValueError(f"Conveyor {name} must contain only finite values.") + if array.ndim == 0 or array.size == 1: + return np.full(count, float(array.reshape(-1)[0]), dtype=np.float32) + if array.ndim != 1 or array.size != count: + raise ValueError(f"Conveyor {name} need one value or {count} values, got shape {array.shape}.") + return array diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py new file mode 100644 index 000000000000..93622a53f039 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py @@ -0,0 +1,532 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""CPU-only native PhysX surface-velocity control. + +The module deliberately keeps PhysX schema imports behind authoring and binding calls. Its +geometry conversion and host-side control state therefore remain usable in import-light tests +and tooling that do not launch Kit. + +The task configuration rejects GPU dynamics: the supported native ``PhysxSurfaceVelocityAPI`` +path can lose conveyor contacts there. The force-driven Newton adapter is the scalable GPU path. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Any, Protocol + +import numpy as np + +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec + +_ENV_REGEX_NS = "{ENV_REGEX_NS}" + + +@dataclass(frozen=True, slots=True) +class PhysxSurfaceVelocityTwist: + """Local PhysX surface twist. + + Attributes: + linear_velocity: Local linear surface velocity [m/s]. + angular_velocity_deg: Local angular surface velocity [deg/s]. + """ + + linear_velocity: tuple[float, float, float] + angular_velocity_deg: tuple[float, float, float] + + +def compute_surface_velocity_twist( + spec: SurfaceVelocitySpec, velocity: float | None = None +) -> PhysxSurfaceVelocityTwist: + """Convert one backend-neutral belt command to a local PhysX surface twist. + + Straight belts map their signed speed to a normalized linear direction. Curved belts map + speed over radius to PhysX's degree-per-second angular convention. PhysX rotates the surface + field about the rigid-body origin, so ``-omega x pivot`` is included in the linear component + to move the instantaneous center to :attr:`SurfaceVelocitySpec.pivot_point`. + + Args: + spec: Authored conveyor intent in the collision prim's local frame. + velocity: Optional signed speed override [m/s]. Defaults to ``spec.velocity``. + + Returns: + Local linear and angular PhysX surface velocities. + + Raises: + ValueError: If the speed is not finite, the direction is zero, or a curved belt has no + positive radius. + """ + speed = spec.velocity if velocity is None else _finite_float("velocity", velocity) + direction = np.asarray(spec.direction, dtype=np.float64) + magnitude = float(np.linalg.norm(direction)) + if not math.isfinite(magnitude) or magnitude <= 1.0e-8: + raise ValueError(f"Conveyor direction must be finite and non-zero, got {spec.direction!r}.") + unit_direction = direction / magnitude + + if not spec.curved: + linear = unit_direction * speed + return PhysxSurfaceVelocityTwist(tuple(float(value) for value in linear), (0.0, 0.0, 0.0)) + + if spec.radius is None or not math.isfinite(spec.radius) or spec.radius <= 0.0: + raise ValueError("A curved PhysX conveyor requires a finite positive radius.") + angular_rad = unit_direction * (speed / spec.radius) + pivot = np.asarray(spec.pivot_point, dtype=np.float64) + linear = -np.cross(angular_rad, pivot) + angular_deg = np.degrees(angular_rad) + return PhysxSurfaceVelocityTwist( + tuple(float(value) for value in linear), tuple(float(value) for value in angular_deg) + ) + + +def apply_surface_velocity_api( + prim_or_path: Any, + spec: SurfaceVelocitySpec, + *, + velocity_scale: float = 0.0, + stage: Any | None = None, +) -> None: + """Apply and author a kinematic PhysX surface-velocity API on one belt prim. + + This function is intended for a task spawner, before PhysX parses the stage. It lazily imports + ``pxr.PhysxSchema`` and applies both the rigid-body and surface-velocity schemas. The rigid body + is made kinematic, surface velocities are authored in local space, and the initial command is + scaled explicitly. A zero default prevents pre-runtime motion during simulation warmup. + + Args: + prim_or_path: USD prim object or exact prim path. + spec: Conveyor intent used to author the local twist. + velocity_scale: Finite multiplier for the initial signed surface speed. + stage: Optional USD stage used when ``prim_or_path`` is a string. Defaults to the current + Isaac Lab stage. + + Raises: + RuntimeError: If the PhysX schema is unavailable or the target prim does not exist. + ValueError: If ``velocity_scale`` is not finite. + """ + scale = _finite_float("velocity_scale", velocity_scale) + twist = compute_surface_velocity_twist(spec, velocity=spec.velocity * scale) + binding = _PhysxSchemaSurfaceWriter((prim_or_path,), stage=stage, apply_api=True) + try: + binding.write(0, enabled=spec.enabled, twist=twist) + finally: + binding.close() + + +def resolve_surface_velocity_paths( + num_envs: int, + surface_specs: Sequence[SurfaceVelocitySpec], + env_path_format: str = "/World/envs/env_{}", +) -> tuple[str, ...]: + """Resolve conveyor templates to exact environment-major prim paths. + + Args: + num_envs: Number of replicated environments. + surface_specs: Within-environment surface descriptions. + env_path_format: Exact environment path format containing one ``{}`` field. + + Returns: + Exact paths ordered by environment, then by ``surface_specs`` order. + + Raises: + ValueError: If inputs cannot produce one unique path per environment and belt. + """ + if not isinstance(num_envs, int) or isinstance(num_envs, bool) or num_envs <= 0: + raise ValueError(f"Conveyor num_envs must be a positive integer, got {num_envs!r}.") + specs = tuple(surface_specs) + if not specs or not all(isinstance(spec, SurfaceVelocitySpec) for spec in specs): + raise ValueError("surface_specs must contain at least one SurfaceVelocitySpec.") + if not isinstance(env_path_format, str) or env_path_format.count("{}") != 1: + raise ValueError(f"Conveyor env_path_format must contain exactly one '{{}}', got {env_path_format!r}.") + try: + env_paths = tuple(env_path_format.format(env_id) for env_id in range(num_envs)) + except (IndexError, KeyError, ValueError) as exc: + raise ValueError(f"Invalid conveyor env_path_format: {env_path_format!r}.") from exc + if any(not path.startswith("/") or "{" in path or "}" in path for path in env_paths): + raise ValueError(f"Conveyor env_path_format must produce exact absolute paths, got {env_path_format!r}.") + + templates = tuple(spec.prim_path for spec in specs) + if len(set(templates)) != len(templates): + raise ValueError("Conveyor belt spec paths must be unique within an environment.") + if num_envs > 1 and any(_ENV_REGEX_NS not in path for path in templates): + raise ValueError("Replicated PhysX conveyors require every belt prim_path to use {ENV_REGEX_NS}.") + + paths = tuple(template.replace(_ENV_REGEX_NS, env_path) for env_path in env_paths for template in templates) + if len(set(paths)) != len(paths): + raise ValueError("Resolved PhysX conveyor prim paths must be unique.") + return paths + + +class SurfaceVelocity: + """Host-side CPU reference facade for native PhysX surface velocity. + + The facade binds exact environment-major paths whose schemas were already authored by + :func:`apply_surface_velocity_api`. Call :meth:`start` to register physics-rate updates, + or call :meth:`update` manually. Commands and enabled state survive full resets; a full reset + clears encoders and restarts the one-second startup ramp. This facade authors USD attributes + on the host and is not a GPU conveyor implementation. + """ + + def __init__( + self, + num_envs: int, + surface_specs: Sequence[SurfaceVelocitySpec], + *, + env_path_format: str = "/World/envs/env_{}", + startup_duration_s: float = 1.0, + stage: Any | None = None, + writer: Any | None = None, + ) -> None: + """Bind native surface attributes and initialize host-side control state. + + Args: + num_envs: Number of replicated environments. + surface_specs: Within-environment surface descriptions. + env_path_format: Exact replicated environment path format. + startup_duration_s: Duration of the global surface-speed ramp [s]. + stage: Optional USD stage used by the default schema writer. + writer: Optional writer implementing ``write(index, enabled=..., twist=...)`` and + ``close()``. This import-light seam is primarily for focused tests. + """ + duration = _finite_float("startup_duration_s", startup_duration_s) + if duration <= 0.0: + raise ValueError(f"Conveyor startup_duration_s must be positive, got {startup_duration_s!r}.") + self._num_envs = num_envs + self._surface_specs = tuple(surface_specs) + self._surface_paths = resolve_surface_velocity_paths(num_envs, self._surface_specs, env_path_format) + self._surfaces_per_env = len(self._surface_specs) + self._startup_duration_s = duration + self._elapsed_time = 0.0 + self._velocity_scale = 0.0 + self._closed = False + self._callback_handle: Any | None = None + self._writer: _SurfaceVelocityWriter = ( + writer + if writer is not None + else _PhysxSchemaSurfaceWriter(self._surface_paths, stage=stage, apply_api=False) + ) + + self._command_velocity = np.tile( + np.asarray([spec.velocity for spec in self._surface_specs], dtype=np.float32), self._num_envs + ) + self._enabled = np.tile( + np.asarray([spec.enabled for spec in self._surface_specs], dtype=np.bool_), self._num_envs + ) + self._encoder_position = np.zeros(self.num_surfaces, dtype=np.float32) + self._last_authored: list[tuple[bool, PhysxSurfaceVelocityTwist] | None] = [None] * self.num_surfaces + self._flush(force=True) + + @property + def specs(self) -> tuple[SurfaceVelocitySpec, ...]: + """Return authored descriptions in stable within-environment order.""" + return self._surface_specs + + @property + def surfaces_per_env(self) -> int: + """Return the number of authored surfaces per environment.""" + return self._surfaces_per_env + + @property + def prim_paths(self) -> tuple[str, ...]: + """Return exact PhysX prim paths in environment-major order.""" + return self._surface_paths + + @property + def surface_paths(self) -> tuple[str, ...]: + """Return an alias for :attr:`prim_paths`.""" + return self.prim_paths + + @property + def num_surfaces(self) -> int: + """Return the total number of bound conveyor surfaces.""" + return len(self._surface_paths) + + @property + def count(self) -> int: + """Return an alias for :attr:`num_surfaces`.""" + return self.num_surfaces + + @property + def initialized(self) -> bool: + """Return whether the facade still owns live schema bindings.""" + return not self._closed + + def start(self) -> None: + """Register one lazy PhysX post-step callback; repeated calls are safe.""" + self._require_open() + if self._callback_handle is not None: + return + from isaaclab_physx.physics.physx_manager import IsaacEvents, PhysxManager + + self._callback_handle = PhysxManager.register_callback( + self.update, + IsaacEvents.POST_PHYSICS_STEP, + name="physx_surface_velocity", + ) + + def update(self, dt: float) -> None: + """Advance encoder state and the startup ramp by one physics step. + + Args: + dt: Positive physics step duration [s]. + """ + self._require_open() + step = _finite_float("physics dt", dt) + if step <= 0.0: + raise ValueError(f"Conveyor physics dt must be positive, got {dt!r}.") + effective_velocity = self._command_velocity * self._enabled + self._encoder_position += np.asarray(step * effective_velocity, dtype=np.float32) + self._elapsed_time = min(self._startup_duration_s, self._elapsed_time + step) + self._velocity_scale = self._elapsed_time / self._startup_duration_s + self._flush() + + def set_velocities(self, velocities: Any, indices: Any = None) -> None: + """Set signed speeds [m/s], preserving commands while belts are disabled.""" + selected = self._resolve_indices(indices) + self._command_velocity[selected] = self._broadcast_finite(velocities, len(selected), "velocities") + self._flush(selected) + + def get_velocities(self, indices: Any = None, clone: bool = True) -> np.ndarray: + """Return effective speeds with disabled belts reported as zero.""" + return self._get_values(self._command_velocity * self._enabled, indices, clone) + + def get_commanded_velocities(self, indices: Any = None, clone: bool = True) -> np.ndarray: + """Return commanded speeds before applying the enabled mask.""" + return self._get_values(self._command_velocity, indices, clone) + + def set_enabled(self, flags: Any, indices: Any = None) -> None: + """Enable or disable belts without discarding their commands.""" + selected = self._resolve_indices(indices) + values = _as_numpy(flags) + if values.ndim == 0: + values = np.full(len(selected), values.item()) + else: + values = values.reshape(-1) + if values.size == 1: + values = np.full(len(selected), values.item()) + if values.size != len(selected) or not np.all(np.isin(values, (False, True, 0, 1))): + raise ValueError(f"Conveyor enabled flags must contain {len(selected)} boolean values.") + self._enabled[selected] = values.astype(np.bool_) + self._flush(selected) + + def get_enabled(self, indices: Any = None, clone: bool = True) -> np.ndarray: + """Return enabled flags as integer values.""" + return self._get_values(self._enabled.astype(np.int32), indices, clone) + + def get_encoder_positions(self, indices: Any = None, clone: bool = True) -> np.ndarray: + """Return physics-rate integrated commanded belt travel [m].""" + return self._get_values(self._encoder_position, indices, clone) + + def reset(self, env_ids: Any = None) -> None: + """Clear selected encoders and restart the global ramp on a full reset. + + Velocity commands and enabled state are deliberately preserved, including across a full + hard-reset path. Partial resets do not disturb the global ramp used by other environments. + + Args: + env_ids: Environment indices to reset, or ``None`` for all environments. + """ + self._require_open() + ids = self._resolve_env_ids(env_ids) + rows = (ids[:, None] * self._surfaces_per_env + np.arange(self._surfaces_per_env)[None, :]).reshape(-1) + self._encoder_position[rows] = 0.0 + if len(np.unique(ids)) == self._num_envs: + self._elapsed_time = 0.0 + self._velocity_scale = 0.0 + self._flush(force=True) + + def close(self) -> None: + """Deregister callbacks, disable authored motion, and release bindings safely.""" + if self._closed: + return + if self._callback_handle is not None: + self._callback_handle.deregister() + self._callback_handle = None + zero = PhysxSurfaceVelocityTwist((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)) + for index in range(self.num_surfaces): + self._writer.write(index, enabled=False, twist=zero) + self._writer.close() + self._closed = True + + def _flush(self, indices: Any = None, *, force: bool = False) -> None: + """Author scaled effective commands for selected rows.""" + selected = self._resolve_indices(indices) + for index in selected: + enabled = bool(self._enabled[index]) + spec = self._surface_specs[index % self._surfaces_per_env] + speed = float(self._command_velocity[index]) * self._velocity_scale if enabled else 0.0 + twist = compute_surface_velocity_twist(spec, velocity=speed) + state = (enabled, twist) + if force or state != self._last_authored[index]: + self._writer.write(int(index), enabled=enabled, twist=twist) + self._last_authored[index] = state + + def _resolve_indices(self, indices: Any) -> np.ndarray: + """Normalize and validate a belt row selection.""" + self._require_open() + if indices is None: + return np.arange(self.num_surfaces, dtype=np.int64) + if isinstance(indices, slice): + return np.arange(self.num_surfaces, dtype=np.int64)[indices] + selected = _as_numpy(indices) + if selected.dtype == np.bool_: + if selected.ndim != 1 or selected.size != self.num_surfaces: + raise IndexError(f"Boolean surface indices must have length {self.num_surfaces}.") + return np.flatnonzero(selected).astype(np.int64) + if not np.issubdtype(selected.dtype, np.integer): + raise IndexError(f"Conveyor indices must be integers, got {indices!r}.") + selected = selected.astype(np.int64, copy=False).reshape(-1) + if np.any((selected < 0) | (selected >= self.num_surfaces)): + raise IndexError(f"Conveyor surface indices are out of range: {selected.tolist()}.") + return selected + + def _resolve_env_ids(self, env_ids: Any) -> np.ndarray: + """Normalize and validate an environment selection.""" + if env_ids is None: + return np.arange(self._num_envs, dtype=np.int64) + ids = _as_numpy(env_ids) + if not np.issubdtype(ids.dtype, np.integer): + raise IndexError(f"Conveyor reset environment indices must be integers, got {env_ids!r}.") + ids = ids.astype(np.int64, copy=False).reshape(-1) + if np.any((ids < 0) | (ids >= self._num_envs)): + raise IndexError(f"Conveyor reset environment indices are out of range: {ids.tolist()}.") + return ids + + @staticmethod + def _broadcast_finite(values: Any, count: int, name: str) -> np.ndarray: + """Return a finite float32 vector with scalar broadcasting.""" + try: + result = np.asarray(_as_numpy(values), dtype=np.float32) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError(f"Conveyor {name} must contain finite numeric values.") from exc + if result.ndim == 0: + result = np.full(count, result.item(), dtype=np.float32) + else: + result = result.reshape(-1) + if result.size == 1: + result = np.full(count, result.item(), dtype=np.float32) + if result.size != count or not np.all(np.isfinite(result)): + raise ValueError(f"Conveyor {name} must contain one or {count} finite values.") + return result + + def _get_values(self, source: np.ndarray, indices: Any, clone: bool) -> np.ndarray: + """Return a complete buffer or a selected copy.""" + self._require_open() + if indices is None: + return source.copy() if clone else source + return source[self._resolve_indices(indices)].copy() + + def _require_open(self) -> None: + """Fail predictably after backend resources have been released.""" + if self._closed: + raise RuntimeError("The PhysX conveyor surface facade is closed.") + + +class _SurfaceVelocityWriter(Protocol): + """Minimal schema-writer seam used by the runtime facade.""" + + def write(self, index: int, *, enabled: bool, twist: PhysxSurfaceVelocityTwist) -> None: + """Author one bound surface state.""" + ... + + def close(self) -> None: + """Release retained schema attributes.""" + ... + + +class _PhysxSchemaSurfaceWriter: + """Cached USD attribute writer with all schema imports kept lazy.""" + + def __init__(self, prims_or_paths: Sequence[Any], *, stage: Any | None, apply_api: bool) -> None: + """Resolve prims, optionally apply schemas, and cache surface attributes.""" + try: + from pxr import Gf, PhysxSchema, UsdPhysics + except ImportError as exc: + raise RuntimeError( + "PhysxSurfaceVelocityAPI is unavailable. Use this backend inside an Isaac Sim process with PhysX " + "schemas." + ) from exc + + if stage is None and any(isinstance(value, str) for value in prims_or_paths): + from isaaclab.sim.utils.stage import get_current_stage + + stage = get_current_stage() + self._gf = Gf + self._attributes: list[tuple[Any, Any, Any]] = [] + for value in prims_or_paths: + prim = stage.GetPrimAtPath(value) if isinstance(value, str) else value + if prim is None or not prim.IsValid(): + raise RuntimeError(f"Cannot bind PhysX conveyor surface: prim {value!r} does not exist.") + + rigid_body = UsdPhysics.RigidBodyAPI(prim) + if apply_api and not prim.HasAPI(UsdPhysics.RigidBodyAPI): + rigid_body = UsdPhysics.RigidBodyAPI.Apply(prim) + elif not prim.HasAPI(UsdPhysics.RigidBodyAPI): + raise RuntimeError(f"PhysX conveyor prim {prim.GetPath()} has no authored RigidBodyAPI.") + if apply_api: + rigid_body.CreateRigidBodyEnabledAttr().Set(True) + rigid_body.CreateKinematicEnabledAttr().Set(True) + elif rigid_body.GetKinematicEnabledAttr().Get() is not True: + raise RuntimeError(f"PhysX conveyor prim {prim.GetPath()} must be an authored kinematic rigid body.") + + if prim.HasAPI(PhysxSchema.PhysxSurfaceVelocityAPI): + surface_api = PhysxSchema.PhysxSurfaceVelocityAPI(prim) + elif apply_api: + surface_api = PhysxSchema.PhysxSurfaceVelocityAPI.Apply(prim) + else: + raise RuntimeError( + f"PhysX conveyor prim {prim.GetPath()} has no authored PhysxSurfaceVelocityAPI; " + "call apply_surface_velocity_api from its spawner before simulation starts." + ) + surface_api.CreateSurfaceVelocityLocalSpaceAttr().Set(True) + self._attributes.append( + ( + surface_api.CreateSurfaceVelocityEnabledAttr(), + surface_api.CreateSurfaceVelocityAttr(), + surface_api.CreateSurfaceAngularVelocityAttr(), + ) + ) + + def write(self, index: int, *, enabled: bool, twist: PhysxSurfaceVelocityTwist) -> None: + """Author one cached local-space surface twist.""" + enabled_attr, linear_attr, angular_attr = self._attributes[index] + # PhysX caches the contact-modification flag when a surface-velocity + # shape is parsed. Cycle it around command changes, matching the + # official Isaac Conveyor node, so live updates are picked up without + # leaving a stale contact modifier attached to the shape. + enabled_attr.Set(False) + linear_attr.Set(self._gf.Vec3f(*twist.linear_velocity)) + angular_attr.Set(self._gf.Vec3f(*twist.angular_velocity_deg)) + enabled_attr.Set(enabled) + + def close(self) -> None: + """Release cached schema attribute handles.""" + self._attributes.clear() + + +def _finite_float(name: str, value: Any) -> float: + """Coerce one finite scalar with a stable validation error.""" + try: + result = float(value) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError(f"Conveyor {name} must be finite, got {value!r}.") from exc + if not math.isfinite(result): + raise ValueError(f"Conveyor {name} must be finite, got {value!r}.") + return result + + +def _as_numpy(values: Any) -> np.ndarray: + """Move supported tensor-like values to a host NumPy array without importing their libraries.""" + if isinstance(values, np.ndarray): + return values + if hasattr(values, "detach"): + values = values.detach() + if hasattr(values, "cpu"): + values = values.cpu() + if hasattr(values, "numpy"): + return np.asarray(values.numpy()) + return np.asarray(values) diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py new file mode 100644 index 000000000000..f00badb6b941 --- /dev/null +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py @@ -0,0 +1,179 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Backend-neutral surface-velocity descriptions and control interface.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + +_ENV_REGEX_NS = "{ENV_REGEX_NS}" + + +def _validate_prim_path(value: str) -> None: + """Validate one exact USD prim path or supported replicated-path template.""" + if not isinstance(value, str) or not value: + raise ValueError("Surface prim_path must be a non-empty string.") + if value.startswith(f"{_ENV_REGEX_NS}/"): + path = value[len(_ENV_REGEX_NS) :] + elif value.startswith("/"): + path = value + else: + raise ValueError(f"Surface prim_path must be absolute or start with '{_ENV_REGEX_NS}/', got {value!r}.") + if "{" in path or "}" in path: + raise ValueError(f"Surface prim_path supports only a leading '{_ENV_REGEX_NS}' placeholder, got {value!r}.") + components = path[1:].split("/") + if not components or any(not component or component in {".", ".."} for component in components): + raise ValueError(f"Surface prim_path must identify a concrete prim without empty components, got {value!r}.") + if any(any(character.isspace() for character in component) for component in components): + raise ValueError(f"Surface prim_path components must not contain whitespace, got {value!r}.") + + +def _validate_scalar(name: str, value: Any) -> float: + """Return one finite float with consistent validation errors.""" + try: + result = float(value) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError(f"Surface {name} must be finite, got {value!r}.") from exc + if not math.isfinite(result): + raise ValueError(f"Surface {name} must be finite, got {value!r}.") + return result + + +def _validate_vector(name: str, value: tuple[float, ...], length: int, *, nonzero: bool = False) -> tuple[float, ...]: + """Return one finite vector with a stable tuple representation.""" + try: + result = tuple(float(component) for component in value) + except (TypeError, ValueError) as exc: + raise ValueError(f"Surface {name} must contain {length} finite values, got {value!r}.") from exc + if len(result) != length or not all(math.isfinite(component) for component in result): + raise ValueError(f"Surface {name} must contain {length} finite values, got {value!r}.") + if nonzero and math.sqrt(sum(component * component for component in result)) <= 1.0e-8: + raise ValueError(f"Surface {name} must be non-zero, got {value!r}.") + return result + + +@dataclass(frozen=True, slots=True) +class SurfaceVelocitySpec: + """Persistent intent for one collision surface with prescribed tangential velocity. + + The fields follow the authored conveyor model proposed for Isaac Sim while remaining independent of + Kit, OpenUSD, and any physics backend. Directions, surface normals, and the optional pivot point are + expressed in the collision prim's local frame. Runtime state such as encoder positions and applied + forces intentionally does not belong in this description. + + ``prim_path`` may contain Isaac Lab's ``{ENV_REGEX_NS}`` placeholder. A backend resolves that template + to every replicated collision prim while preserving one deterministic belt index per environment. An + absolute path addresses one unreplicated surface; replicated environments should use the placeholder. + + Args: + prim_path: Collision prim path or replicated Isaac Lab prim-path template. + velocity: Initial signed centerline surface velocity [m/s]. + enabled: Whether the surface initially transports contacting bodies. + direction: Local travel direction, or local rotation axis for a curved belt. + curved: Whether the velocity field rotates about ``pivot_point``. + pivot_point: Local rotation center for a curved belt [m]. An OpenUSD adapter may map this point to the + world transform of a prim targeted by the proposed schema's pivot relationship; it is not copied + into a schema attribute. + radius: Optional centerline radius used by backends that cannot derive it from geometry [m]. + surface_normal: Local outward normal of the carrying surface. + contact_threshold: Minimum contact-normal alignment accepted for traction. + friction_coefficient: Coulomb limit for synthetic surface traction. + """ + + prim_path: str + velocity: float = 0.0 + enabled: bool = True + direction: tuple[float, float, float] = (1.0, 0.0, 0.0) + curved: bool = False + pivot_point: tuple[float, float, float] = (0.0, 0.0, 0.0) + radius: float | None = None + surface_normal: tuple[float, float, float] = (0.0, 0.0, 1.0) + contact_threshold: float = 0.997 + friction_coefficient: float = 0.7 + + def __post_init__(self) -> None: + """Normalize immutable vectors and validate authored values.""" + _validate_prim_path(self.prim_path) + for name in ("enabled", "curved"): + if not isinstance(getattr(self, name), bool): + raise ValueError(f"Surface {name} must be a bool, got {getattr(self, name)!r}.") + + velocity = _validate_scalar("velocity", self.velocity) + contact_threshold = _validate_scalar("contact_threshold", self.contact_threshold) + friction_coefficient = _validate_scalar("friction_coefficient", self.friction_coefficient) + if not 0.0 <= contact_threshold <= 1.0: + raise ValueError(f"Surface contact_threshold must be in [0, 1], got {self.contact_threshold!r}.") + if friction_coefficient < 0.0: + raise ValueError( + f"Surface friction_coefficient must be finite and non-negative, got {self.friction_coefficient!r}." + ) + radius = None if self.radius is None else _validate_scalar("radius", self.radius) + if radius is not None and radius <= 0.0: + raise ValueError(f"Surface radius must be finite and positive when provided, got {self.radius!r}.") + + object.__setattr__(self, "velocity", velocity) + object.__setattr__(self, "direction", _validate_vector("direction", self.direction, 3, nonzero=True)) + object.__setattr__(self, "pivot_point", _validate_vector("pivot_point", self.pivot_point, 3)) + object.__setattr__( + self, "surface_normal", _validate_vector("surface_normal", self.surface_normal, 3, nonzero=True) + ) + object.__setattr__(self, "radius", radius) + object.__setattr__(self, "contact_threshold", contact_threshold) + object.__setattr__(self, "friction_coefficient", friction_coefficient) + + +@runtime_checkable +class SurfaceVelocityView(Protocol): + """Common tensorized control contract implemented by physics backends.""" + + @property + def prim_paths(self) -> tuple[str, ...]: + """Resolved collision prim paths in stable surface-index order.""" + ... + + @property + def num_surfaces(self) -> int: + """Number of resolved moving surfaces.""" + ... + + @property + def count(self) -> int: + """Alias for :attr:`num_surfaces`, matching tensor-view naming.""" + ... + + def set_velocities(self, velocities: Any, indices: Any = None) -> None: + """Set signed surface velocities [m/s] for selected surfaces.""" + ... + + def get_velocities(self, indices: Any = None, clone: bool = True) -> Any: + """Return effective surface velocities [m/s] for selected surfaces.""" + ... + + def get_commanded_velocities(self, indices: Any = None, clone: bool = True) -> Any: + """Return commanded surface velocities [m/s] before the enabled mask.""" + ... + + def set_enabled(self, flags: Any, indices: Any = None) -> None: + """Enable or disable selected surfaces without discarding their commands.""" + ... + + def get_enabled(self, indices: Any = None, clone: bool = True) -> Any: + """Return integer enabled flags for selected surfaces.""" + ... + + def get_encoder_positions(self, indices: Any = None, clone: bool = True) -> Any: + """Return integrated surface travel [m] for selected surfaces.""" + ... + + def reset(self, env_ids: Any = None) -> None: + """Clear runtime state for selected replicated environments.""" + ... + + def close(self) -> None: + """Release backend resources and callbacks.""" + ... diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py new file mode 100644 index 000000000000..9bea66335600 --- /dev/null +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py @@ -0,0 +1,166 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Native CPU contracts for the Newton conveyor adapter.""" + +import numpy as np +import pytest +import warp as wp +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonBuilderCfg, NewtonCfg, NewtonManager + +from isaaclab.sim import SimulationCfg, build_simulation_context + +from isaaclab_contrib.conveyors.newton import SurfaceVelocity +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec + + +def test_native_conveyor_controls_rebind_and_reset_independently(monkeypatch): + """Real contacts, models and callbacks preserve commands and discard only selected-world traction.""" + cfg = SimulationCfg( + device="cpu", + dt=1 / 120, + physics=NewtonCfg(solver_cfg=MJWarpSolverCfg(use_mujoco_contacts=False), use_cuda_graph=False), + ) + with build_simulation_context(sim_cfg=cfg) as sim: + builder = sim.get_or_create_backend(NewtonBuilderCfg(physics_cfg=cfg.physics)) + for world in range(2): + builder.begin_world() + root = f"/World/envs/env_{world}" + for name, y in (("B", 0.5), ("A", -0.5), ("Nested/BeltA", 2.0)): + label = f"{root}/{name}" if "/" in name else f"{root}/Belt{name}" + builder.add_shape_box( + -1, xform=wp.transform((0, y, 0), wp.quat_identity()), hx=1, hy=0.2, hz=0.1, label=label + ) + cube = builder.add_link(xform=wp.transform((0, -0.5, 0.21), wp.quat_identity()), label=f"{root}/Cube0") + builder.add_shape_box(cube, hx=0.1, hy=0.1, hz=0.1) + builder.add_articulation([builder.add_joint_free(cube)]) + builder.end_world() + builder.add_shape_box(-1, hx=0.1, hy=0.1, hz=0.1, label="/World/props/BeltA") + callbacks_before = set(NewtonManager._callbacks) + specs = ( + SurfaceVelocitySpec("{ENV_REGEX_NS}/BeltA", velocity=0.1), + SurfaceVelocitySpec("{ENV_REGEX_NS}/BeltB", velocity=-0.2, enabled=False), + ) + calls, bindings = [], [] + + def actuator(): + calls.append("actuator") + + def force(state): + calls.append("force") + + def substep(solver, contacts, state, dt): + calls.append("substep") + + def initialized(model, contacts): + bindings.append((model, contacts)) + + observers = ( + (actuator, NewtonManager.register_post_actuator_callback, NewtonManager.unregister_post_actuator_callback), + (force, NewtonManager.register_state_force_callback, NewtonManager.unregister_state_force_callback), + ( + substep, + NewtonManager.register_post_solver_substep_callback, + NewtonManager.unregister_post_solver_substep_callback, + ), + (initialized, NewtonManager.register_solver_init_callback, NewtonManager.unregister_solver_init_callback), + ) + for callback, register, _ in observers: + register(callback) + NewtonManager.register_solver_init_callback(initialized) + driver = SurfaceVelocity(2, specs, body_pattern="Cube0$", body_count_per_env=1) + try: + with pytest.raises(RuntimeError, match="not bound"): + driver.set_velocities(0.2) + sim.reset() + assert bindings == [(NewtonManager.get_model(), NewtonManager.get_contacts())] + first = driver._binding + assert NewtonManager.get_contacts().force is not None + assert driver.prim_paths == tuple( + f"/World/envs/env_{world}/Belt{name}" for world in range(2) for name in ("A", "B") + ) + model = NewtonManager.get_model() + mapping = first._conveyor.shape_conveyor.numpy() + assert [ + mapping[i] for i, label in enumerate(model.shape_label) if "Nested" in label or "/props/" in label + ] == [-1] * 3 + driver.set_velocities([0.3, -0.25, -0.4], indices=[0, 1, 3]) + driver.set_enabled([False, True], indices=[2, 3]) + np.testing.assert_allclose(driver.get_velocities().numpy(), [0.3, 0, 0, -0.4]) + sim.step(render=False) + assert calls == ["actuator"] + ["force", "substep"] * cfg.physics.num_substeps + np.testing.assert_allclose(driver.get_encoder_positions().numpy(), np.array([0.3, 0, 0, -0.4]) / 120) + first._conveyor.conveyor_body_f.assign(np.ones((2, 6), dtype=np.float32)) + driver.reset([0]) + state = model.state() + first.apply(state) + np.testing.assert_array_equal(state.body_f.numpy(), [[0] * 6, [1] * 6]) + np.testing.assert_allclose(driver.get_encoder_positions().numpy(), [0, 0, 0, -0.4 / 120]) + sim.reset() + assert first._closed and driver._binding is not first + assert driver._binding._model is NewtonManager.get_model() + assert driver._binding._contacts is NewtonManager.get_contacts() + np.testing.assert_allclose(driver.get_velocities().numpy(), [0.3, 0, 0, -0.4]) + assert len(bindings) == 2 and bindings[1] == (NewtonManager.get_model(), NewtonManager.get_contacts()) + assert bindings[0][0] is not bindings[1][0] and bindings[0][1] is not bindings[1][1] + for callback, _, unregister in observers: + unregister(callback) + unregister(callback) + previous_calls = list(calls) + sim.step(render=False) + sim.reset() + assert calls == previous_calls and len(bindings) == 2 + np.testing.assert_array_equal(driver.get_enabled().numpy(), [1, 0, 0, 1]) + np.testing.assert_allclose(driver.get_commanded_velocities().numpy(), [0.3, -0.25, 0.1, -0.4]) + callbacks_live = set(NewtonManager._callbacks) + with monkeypatch.context() as patch: + + def fail_registration(cls, callback): + raise RuntimeError("solver callback unavailable") + + patch.setattr(NewtonManager, "register_solver_init_callback", classmethod(fail_registration)) + with pytest.raises(RuntimeError, match="solver callback unavailable"): + SurfaceVelocity(2, specs, body_pattern="Cube0$") + assert set(NewtonManager._callbacks) == callbacks_live + finally: + driver.close() + driver.close() + assert set(NewtonManager._callbacks) == callbacks_before + assert not NewtonManager._solver_init_callbacks + assert not NewtonManager._state_force_callbacks + assert not NewtonManager._post_solver_substep_callbacks + + +@pytest.mark.parametrize( + ("specs", "num_envs", "env_path", "error", "message"), + [ + ((), 1, "/World/envs/env_{}", ValueError, "At least one"), + ((object(),), 1, "/World/envs/env_{}", TypeError, "SurfaceVelocitySpec"), + ((SurfaceVelocitySpec("/Belt"),) * 2, 1, "/World/envs/env_{}", ValueError, "unique"), + ( + (SurfaceVelocitySpec("/Belt"), SurfaceVelocitySpec("/Belt/Child")), + 1, + "/World/envs/env_{}", + ValueError, + "ancestors", + ), + ((SurfaceVelocitySpec("/Curve", curved=True),), 1, "/World/envs/env_{}", ValueError, "positive radius"), + ((SurfaceVelocitySpec("/Shared/Belt"),), 2, "/World/envs/env_{}", ValueError, "Replicated"), + ((SurfaceVelocitySpec("{ENV_REGEX_NS}/Belt"),), 1, "/World/envs/env_.*", ValueError, "env_path_format"), + ], +) +def test_invalid_conveyors_leave_no_callbacks(specs, num_envs, env_path, error, message): + """Rejected authoring must not leave a partially registered adapter.""" + before = set(NewtonManager._callbacks) + with pytest.raises(error, match=message): + SurfaceVelocity(num_envs, specs, env_path_format=env_path, body_pattern="Cube0$") + assert set(NewtonManager._callbacks) == before + + +def test_unreplicated_absolute_surface_path_is_preserved(): + """A single-environment surface can target a global authored path.""" + from isaaclab_contrib.conveyors.newton import _resolve_belt_prim_path + + assert _resolve_belt_prim_path("/World/Shared/Belt", "/World/envs/env_{}", 0) == "/World/Shared/Belt" diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py new file mode 100644 index 000000000000..91348d447553 --- /dev/null +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py @@ -0,0 +1,147 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Import-light tests for native PhysX surface velocity.""" + +from __future__ import annotations + +import math +from types import SimpleNamespace +from unittest.mock import Mock, call + +import numpy as np +import pytest +import torch + +import isaaclab_contrib.conveyors.physx as surface_module +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec + + +class _FakeWriter: + """Record authored states without importing USD or PhysX schemas.""" + + def __init__(self) -> None: + self.writes: list[tuple[int, bool, surface_module.PhysxSurfaceVelocityTwist]] = [] + self.close_count = 0 + + def write(self, index: int, *, enabled: bool, twist: surface_module.PhysxSurfaceVelocityTwist) -> None: + self.writes.append((index, enabled, twist)) + + def close(self) -> None: + self.close_count += 1 + + +def _surface_spec(name: str = "Belt", **kwargs) -> SurfaceVelocitySpec: + """Return one replicated belt description for facade tests.""" + return SurfaceVelocitySpec(prim_path=f"{{ENV_REGEX_NS}}/{name}", velocity=0.4, **kwargs) + + +def test_twist_conversion_normalizes_straight_direction() -> None: + """Straight surface speed is independent of the authored direction magnitude.""" + spec = _surface_spec(direction=(3.0, 4.0, 0.0)) + + twist = surface_module.compute_surface_velocity_twist(spec, velocity=2.0) + + np.testing.assert_allclose(twist.linear_velocity, (1.2, 1.6, 0.0)) + assert twist.angular_velocity_deg == (0.0, 0.0, 0.0) + + +def test_twist_conversion_uses_degrees_and_compensates_curved_pivot() -> None: + """Curved belts rotate about their local pivot rather than the rigid-body origin.""" + spec = _surface_spec( + direction=(0.0, 0.0, 2.0), + curved=True, + radius=2.0, + pivot_point=(2.0, 0.0, 0.0), + ) + + twist = surface_module.compute_surface_velocity_twist(spec, velocity=math.pi) + + np.testing.assert_allclose(twist.angular_velocity_deg, (0.0, 0.0, 90.0), atol=1.0e-12) + np.testing.assert_allclose(twist.linear_velocity, (0.0, -math.pi, 0.0), atol=1.0e-12) + + +def test_curved_twist_requires_an_explicit_radius() -> None: + """A native angular rate cannot be inferred from unspecified task geometry.""" + spec = _surface_spec(curved=True) + + with pytest.raises(ValueError, match="positive radius"): + surface_module.compute_surface_velocity_twist(spec) + + +def test_paths_are_resolved_in_environment_major_order() -> None: + """Runtime rows stay deterministic across stage discovery ordering.""" + specs = (_surface_spec("BeltA"), _surface_spec("Nested/BeltB")) + + paths = surface_module.resolve_surface_velocity_paths(2, specs) + + assert paths == ( + "/World/envs/env_0/BeltA", + "/World/envs/env_0/Nested/BeltB", + "/World/envs/env_1/BeltA", + "/World/envs/env_1/Nested/BeltB", + ) + with pytest.raises(ValueError, match="require every belt"): + surface_module.resolve_surface_velocity_paths(2, (SurfaceVelocitySpec(prim_path="/World/Shared/Belt"),)) + + +def test_facade_ramps_playback_integrates_encoders_and_preserves_commands_on_reset() -> None: + """Full resets restart playback without erasing policy-visible command state.""" + writer = _FakeWriter() + facade = surface_module.SurfaceVelocity(2, (_surface_spec(),), writer=writer) + + assert facade.prim_paths == ("/World/envs/env_0/Belt", "/World/envs/env_1/Belt") + assert facade.num_surfaces == facade.count == 2 + assert [record[2].linear_velocity for record in writer.writes] == [(0.0, 0.0, 0.0)] * 2 + + facade.update(0.25) + np.testing.assert_allclose([record[2].linear_velocity[0] for record in writer.writes[-2:]], (0.1, 0.1)) + np.testing.assert_allclose(facade.get_encoder_positions(), (0.1, 0.1)) + + facade.set_velocities(torch.tensor([0.8], requires_grad=True), indices=torch.tensor([0])) + facade.set_enabled(False, indices=[1]) + facade.update(0.25) + np.testing.assert_allclose(facade.get_commanded_velocities(), (0.8, 0.4)) + np.testing.assert_allclose(facade.get_velocities(), (0.8, 0.0)) + np.testing.assert_allclose(facade.get_encoder_positions(), (0.3, 0.1)) + + facade.reset(env_ids=torch.tensor([0])) + np.testing.assert_allclose(facade.get_encoder_positions(), (0.0, 0.1)) + facade.reset(env_ids=[1, 0]) + np.testing.assert_allclose(facade.get_encoder_positions(), (0.0, 0.0)) + np.testing.assert_allclose(facade.get_commanded_velocities(), (0.8, 0.4)) + assert facade.get_enabled().tolist() == [1, 0] + np.testing.assert_allclose(writer.writes[-2][2].linear_velocity, (0.0, 0.0, 0.0)) + + facade.close() + facade.close() + assert writer.close_count == 1 + assert [(index, enabled) for index, enabled, _ in writer.writes[-2:]] == [(0, False), (1, False)] + + +def test_authoring_helper_applies_kinematic_local_surface_schema(monkeypatch: pytest.MonkeyPatch) -> None: + """Author a real kinematic USD body; mock only the Kit-dependent PhysX API.""" + import pxr + from pxr import Gf, Usd, UsdPhysics + + stage = Usd.Stage.CreateInMemory() + prim = stage.DefinePrim("/World/Belt", "Xform") + surface_api = Mock() + # HasAPI needs a registered USD type. Substitute only the unavailable PhysX schema. + schema = SimpleNamespace(PhysxSurfaceVelocityAPI=UsdPhysics.CollisionAPI) + monkeypatch.setattr(UsdPhysics.CollisionAPI, "Apply", Mock(return_value=surface_api)) + monkeypatch.setattr(pxr, "PhysxSchema", schema, raising=False) + + surface_module.apply_surface_velocity_api(prim, _surface_spec(), velocity_scale=0.0) + + assert prim.HasAPI(UsdPhysics.RigidBodyAPI) + rigid_body = UsdPhysics.RigidBodyAPI(prim) + assert rigid_body.GetRigidBodyEnabledAttr().Get() is True + assert rigid_body.GetKinematicEnabledAttr().Get() is True + schema.PhysxSurfaceVelocityAPI.Apply.assert_called_once_with(prim) + surface_api.CreateSurfaceVelocityLocalSpaceAttr().Set.assert_called_once_with(True) + assert surface_api.CreateSurfaceVelocityEnabledAttr().Set.call_args_list == [call(False), call(True)] + surface_api.CreateSurfaceVelocityAttr().Set.assert_called_once_with(Gf.Vec3f(0.0)) + surface_api.CreateSurfaceAngularVelocityAttr().Set.assert_called_once_with(Gf.Vec3f(0.0)) diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py new file mode 100644 index 000000000000..db0a7adedb90 --- /dev/null +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py @@ -0,0 +1,45 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Focused tests for backend-neutral surface-velocity descriptions.""" + +from __future__ import annotations + +import pytest + +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec + + +def test_surface_velocity_spec_normalizes_vectors() -> None: + """List inputs become immutable vectors before either backend consumes them.""" + spec = SurfaceVelocitySpec( + prim_path="{ENV_REGEX_NS}/Belt", direction=[3, 4, 0], pivot_point=[1, 2, 3], surface_normal=[0, 0, 1] + ) + assert spec.direction == (3.0, 4.0, 0.0) + assert spec.pivot_point == (1.0, 2.0, 3.0) + assert spec.surface_normal == (0.0, 0.0, 1.0) + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"prim_path": "relative/Belt"}, "prim_path"), + ({"prim_path": "/"}, "prim_path"), + ({"prim_path": "/World/{ENV_REGEX_NS}/Belt"}, "prim_path"), + ({"prim_path": "/World/Belt", "velocity": None}, "velocity"), + ({"prim_path": "/World/Belt", "velocity": float("nan")}, "velocity"), + ({"prim_path": "/World/Belt", "enabled": 1}, "enabled"), + ({"prim_path": "/World/Belt", "curved": "yes"}, "curved"), + ({"prim_path": "/World/Belt", "direction": (0.0, 0.0, 0.0)}, "direction"), + ({"prim_path": "/World/Belt", "surface_normal": (0.0, 0.0)}, "surface_normal"), + ({"prim_path": "/World/Belt", "radius": 0.0}, "radius"), + ({"prim_path": "/World/Belt", "contact_threshold": 1.1}, "contact_threshold"), + ({"prim_path": "/World/Belt", "friction_coefficient": -0.1}, "friction_coefficient"), + ], +) +def test_surface_velocity_spec_rejects_invalid_authored_values(kwargs: dict, message: str) -> None: + """Invalid persistent intent fails before any physics lifecycle is registered.""" + with pytest.raises(ValueError, match=message): + SurfaceVelocitySpec(**kwargs) diff --git a/source/isaaclab_newton/changelog.d/maximiliank-conveyor-lifecycle.added.rst b/source/isaaclab_newton/changelog.d/maximiliank-conveyor-lifecycle.added.rst new file mode 100644 index 000000000000..53086e356237 --- /dev/null +++ b/source/isaaclab_newton/changelog.d/maximiliank-conveyor-lifecycle.added.rst @@ -0,0 +1,2 @@ +* Added solver-initialization and post-substep hooks with callback cleanup for scene-owned force systems. +* Exposed collision filtering for static and kinematic shape pairs. diff --git a/source/isaaclab_newton/changelog.d/maximiliank-conveyor-lifecycle.minor b/source/isaaclab_newton/changelog.d/maximiliank-conveyor-lifecycle.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py index 8bb8b75cae0f..a13c4a84c21e 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py @@ -106,6 +106,13 @@ class NewtonCollisionPipelineCfg: Defaults to ``"explicit"`` (same as Newton's default when ``broad_phase=None``). """ + include_static_kinematic_pairs: bool = True + """Whether to generate contacts between two immovable shapes. + + Set to ``False`` to exclude static-static, static-kinematic, and kinematic-kinematic + pairs. Defaults to ``True``, matching Newton's default. + """ + reduce_contacts: bool = True """Whether to reduce contacts for mesh-mesh collisions. diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 93ff618a4bb8..bfbe8a2d506f 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -515,6 +515,13 @@ def setup_deformable_body(cls, prim: Any, deformable_type: str, sim_mesh_prim: A _post_actuator_callbacks: list[Callable[[], None]] = [] # In-graph hooks invoked immediately before every solver substep. _state_force_callbacks: list[Callable[[State], None]] = [] + # In-graph hooks invoked after every solver substep, before the post-step + # state is swapped into the active buffer and its external forces cleared. + _post_solver_substep_callbacks: list[Callable[[SolverBase, Contacts | None, State, float], None]] = [] + # Lifecycle hooks invoked after the solver and contact buffers are created, + # but before any CUDA graph is captured. Scene-owned force systems use this + # seam to bind model-specific buffers and register their in-graph callbacks. + _solver_init_callbacks: list[Callable[[Model, Contacts | None], None]] = [] # In-graph hooks invoked after the last solver substep and before sensors, # in registration order. Articulations with non-identity ordering register # their backend-to-user state republish kernels here so the reorders are @@ -825,6 +832,8 @@ def clear(cls): NewtonManager._adapter = None NewtonManager._post_actuator_callbacks = [] NewtonManager._state_force_callbacks = [] + NewtonManager._post_solver_substep_callbacks = [] + NewtonManager._solver_init_callbacks = [] NewtonManager._post_step_callbacks = [] # Set by an articulation that took the ``use_newton_actuators=True`` # branch in ``_process_actuators_cfg``. Together with the adapter @@ -1611,6 +1620,12 @@ def initialize_solver(cls) -> None: ) cls._initialize_contacts() + # Scene-owned systems that consume solver/contact buffers must bind + # here: both resources now exist, while CUDA graph capture has not yet + # started. The callbacks intentionally persist across hard resets and + # rebind to each re-finalized model. + cls._run_solver_init_callbacks() + # Picking callbacks must be registered after the concrete solver has # published its force-input capability, but before CUDA graph capture. sim = PhysicsManager._sim @@ -1678,6 +1693,8 @@ def _run_solver_substeps(cls, contacts) -> None: for callback in cls._state_force_callbacks: callback(backend.state_0) cls._step_solver(backend.state_0, backend.state_0, backend.control, contacts, cls._solver_dt) + for cb in cls._post_solver_substep_callbacks: + cb(cls._solver, contacts, backend.state_0, cls._solver_dt) backend.state_0.clear_forces() if collide_mid_loop and (i + 1) % collide_every == 0 and i + 1 < cls._num_substeps: cls._collision_pipeline.collide(backend.state_0, contacts) @@ -1688,6 +1705,8 @@ def _run_solver_substeps(cls, contacts) -> None: for callback in cls._state_force_callbacks: callback(backend.state_0) cls._step_solver(backend.state_0, backend.state_1, backend.control, contacts, cls._solver_dt) + for cb in cls._post_solver_substep_callbacks: + cb(cls._solver, contacts, backend.state_1, cls._solver_dt) if need_copy_on_last and i == cls._num_substeps - 1: backend.state_0.assign(backend.state_1) else: @@ -1902,6 +1921,98 @@ def register_state_force_callback(cls, callback: Callable[[State], None]) -> Non return NewtonManager._state_force_callbacks.append(callback) + @classmethod + def unregister_state_force_callback(cls, callback: Callable[[State], None]) -> None: + """Remove a previously registered state-force callback. + + Removing a callback that was never registered or was already removed is + a safe no-op. This lets scene-owned systems release bound-method + references before the global Newton manager is cleared. + + Args: + callback: Previously registered callback. + """ + with contextlib.suppress(ValueError): + NewtonManager._state_force_callbacks.remove(callback) + + @classmethod + def unregister_post_actuator_callback(cls, callback: Callable[[], None]) -> None: + """Remove a previously registered post-actuator callback. + + Removing a callback that was never registered or was already removed is + a safe no-op. This lets scene-owned systems release bound-method + references before the global Newton manager is cleared. + + Args: + callback: Previously registered callback. + """ + with contextlib.suppress(ValueError): + cls._post_actuator_callbacks.remove(callback) + + @classmethod + def register_post_solver_substep_callback( + cls, callback: Callable[[SolverBase, Contacts | None, State, float], None] + ) -> None: + """Append a hook invoked immediately after every solver substep. + + The callback receives the active solver, contacts, post-substep state, + and solver timestep [s]. It runs before double-buffered state swapping + and before external forces are cleared, matching Newton's native + per-substep force-feedback loop. Callbacks must be graph-safe. + + Args: + callback: Function called after each solver substep. + """ + if callback in NewtonManager._post_solver_substep_callbacks: + return + cls._post_solver_substep_callbacks.append(callback) + + @classmethod + def register_solver_init_callback(cls, callback: Callable[[Model, Contacts | None], None]) -> None: + """Register a callback that binds resources before CUDA graph capture. + + The callback runs after every solver/contact initialization, including + hard resets, and before any simulation graph is captured. It may + allocate model-specific buffers and register graph-safe step callbacks. + + Args: + callback: Function receiving the active model and contact buffer. + """ + if callback in NewtonManager._solver_init_callbacks: + return + NewtonManager._solver_init_callbacks.append(callback) + + @classmethod + def _run_solver_init_callbacks(cls) -> None: + """Bind scene-owned solver resources before graph capture.""" + for callback in tuple(NewtonManager._solver_init_callbacks): + callback(cls.backend.model, cls._contacts) + + @classmethod + def unregister_solver_init_callback(cls, callback: Callable[[Model, Contacts | None], None]) -> None: + """Remove a previously registered solver-initialization callback. + + Removing a callback that was never registered or was already removed is + a safe no-op. + + Args: + callback: Previously registered callback. + """ + with contextlib.suppress(ValueError): + NewtonManager._solver_init_callbacks.remove(callback) + + @classmethod + def unregister_post_solver_substep_callback( + cls, callback: Callable[[SolverBase, Contacts | None, State, float], None] + ) -> None: + """Remove a previously registered post-solver-substep callback. + + Args: + callback: Previously registered callback. + """ + with contextlib.suppress(ValueError): + cls._post_solver_substep_callbacks.remove(callback) + @classmethod def register_post_step_callback(cls, callback: Callable[[], None]) -> None: """Append a hook to the list invoked after the last solver substep on every step. From a558e2554703bd8bd082d7c7e2526bc3213f2b09 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Tue, 6 Oct 2026 15:38:33 +0200 Subject: [PATCH 03/45] Add contributed conveyor racetrack transfer task --- .../source/_static/css/environment-browser.js | 2 + .../tasks/conveyor/racetrack_transfer.jpg | Bin 0 -> 164158 bytes .../maximiliank-conveyor-racetrack.added.rst | 1 + .../maximiliank-conveyor-racetrack.minor | 0 .../contrib/conveyor_franka/README.md | 96 +++ .../contrib/conveyor_franka/__init__.py | 33 + .../conveyor_franka/agents/__init__.py | 6 + .../conveyor_franka/agents/conveyor_ppo.py | 48 ++ .../conveyor_franka/agents/rsl_rl_ppo_cfg.py | 183 +++++ .../conveyor_franka/conveyor_franka_env.py | 139 ++++ .../conveyor_franka_env_cfg.py | 699 ++++++++++++++++++ .../conveyor_franka_physx_env_cfg.py | 308 ++++++++ .../conveyor_franka/conveyor_geometry.py | 397 ++++++++++ .../conveyor_franka/conveyor_goal_selector.py | 105 +++ .../contrib/conveyor_franka/evaluate.py | 106 +++ .../conveyor_franka/franka_robot_cfg.py | 69 ++ .../contrib/conveyor_franka/mdp/__init__.py | 10 + .../contrib/conveyor_franka/mdp/__init__.pyi | 79 ++ .../contrib/conveyor_franka/mdp/actions.py | 135 ++++ .../conveyor_franka/mdp/actions_cfg.py | 48 ++ .../contrib/conveyor_franka/mdp/commands.py | 345 +++++++++ .../contrib/conveyor_franka/mdp/cube_state.py | 22 + .../conveyor_franka/mdp/curriculums.py | 327 ++++++++ .../contrib/conveyor_franka/mdp/kinematics.py | 71 ++ .../conveyor_franka/mdp/observations.py | 133 ++++ .../conveyor_franka/mdp/reset_events.py | 545 ++++++++++++++ .../contrib/conveyor_franka/mdp/rewards.py | 156 ++++ .../conveyor_franka/mdp/terminations.py | 113 +++ source/isaaclab_tasks/pyproject.toml | 6 + .../contrib/test_contrib_environments_kit.py | 2 +- .../contrib/test_conveyor_franka_geometry.py | 55 ++ .../test/contrib/test_conveyor_franka_mdp.py | 356 +++++++++ .../contrib/test_conveyor_franka_physx_cfg.py | 42 ++ source/isaaclab_tasks/test/env_test_utils.py | 8 +- uv.lock | 6 + 35 files changed, 4646 insertions(+), 5 deletions(-) create mode 100644 docs/source/_static/tasks/conveyor/racetrack_transfer.jpg create mode 100644 source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst create mode 100644 source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.minor create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_goal_selector.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/evaluate.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/cube_state.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/observations.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/terminations.py create mode 100644 source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py create mode 100644 source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py create mode 100644 source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py diff --git a/docs/source/_static/css/environment-browser.js b/docs/source/_static/css/environment-browser.js index 567d87999296..76769b871605 100644 --- a/docs/source/_static/css/environment-browser.js +++ b/docs/source/_static/css/environment-browser.js @@ -61,6 +61,8 @@ ["IsaacContrib-AutoMate-Disassembly-Direct", "rl_games", "", "", "", "tasks/automate/01053_disassembly.jpg"], ["IsaacContrib-Cartpole-Camera-Showcase-Direct", "skrl", "", "", "box_box,box_discrete,box_multidiscrete,dict_box,dict_discrete,dict_multidiscrete,tuple_box,tuple_discrete,tuple_multidiscrete"], ["IsaacContrib-Cartpole-Showcase-Direct", "skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "box_box,box_discrete,box_multidiscrete,dict_box,dict_discrete,dict_multidiscrete,discrete_box,discrete_discrete,discrete_multidiscrete,multidiscrete_box,multidiscrete_discrete,multidiscrete_multidiscrete,tuple_box,tuple_discrete,tuple_multidiscrete"], + ["IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0", "rsl_rl", "", "", "", "./tasks/conveyor/racetrack_transfer.jpg"], + ["IsaacContrib-Conveyor-Racetrack-Transfer-v0", "rsl_rl", "", "", "", "./tasks/conveyor/racetrack_transfer.jpg"], ["IsaacContrib-Deploy-GearAssembly-Rizon4s-Grav", "rsl_rl", "", "", ""], ["IsaacContrib-Deploy-GearAssembly-Rizon4s-Grav-ROS-Inference", "rsl_rl", "", "", ""], ["IsaacContrib-Deploy-GearAssembly-UR10e-2F140", "rsl_rl", "", "", ""], diff --git a/docs/source/_static/tasks/conveyor/racetrack_transfer.jpg b/docs/source/_static/tasks/conveyor/racetrack_transfer.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3335ad4c15f6a07391511759c490fe3dc91bebb7 GIT binary patch literal 164158 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 vtZ#PuFz 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½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 NfwQ8r!BPRh0-4$9V_}4V6ZPSB#Pmf+ z7Z~=2s`d`2uo{aSEPX$#bvpLG`xNQU_m^Ylrw7wZ&s`DR?@Pkt-&)>Pxe7rY`i#G` zjL9p4pvr|wG&nuA76cStw$s1h5Y-j+h4%!=DS_9I?3cnzdgT`ryDi;1JybSIIfees zIrS2xedd3AeRPZ&FPNuXxJ0VEN2GSDEUv2IPNKh}PNpp}kYeBvZX{?0a3ROR8oD$x zr$6ce#H5c3aga?$evv`FkVDa<3DQG1K*P=i&gjAGL6BsZpr}K@^4cEj&jl*T1kD#Kmu2#SA?whgth5Sfy|}ZELvFCfC~Y5uf+%*i+hd;ymmfF%1ac z2ylTr2Y&opG2udSj3Rx%)j8to>Ja)1p?5nP$!Szk!$}_+-2WNPdOGqMB)T33F5}_C|D}Edh9k1 zD^ajtP#jq40rC$C6=_az$^eN7)IJ)~f`A5Kfu zZ613dNVHhm1S+_tsA6C~ZmUL6`YT#raI<6vMo@Yq4D@S&5>5upF$4I&3^`Ppk-Gjab>ebuc-Yj#W1NZ0qlK_d=h( e;&1<}5i0b}sXx5-UT2A>qSn_-Ndq1Ko&7&Iqg-$R literal 0 HcmV?d00001 diff --git a/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst new file mode 100644 index 000000000000..5c44c350b4f9 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst @@ -0,0 +1 @@ +* Added four-cube conveyor racetrack transfer with Newton and native CPU PhysX backends, a shared pretrained policy, reset curriculum, and seeded evaluation. diff --git a/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.minor b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md new file mode 100644 index 000000000000..4d9d5451d739 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md @@ -0,0 +1,96 @@ +# Conveyor racetrack transfer + +A Franka continuously transfers four numbered 40 mm cubes between two counter-rotating +racetrack conveyors. The trained policy uses 123 observations and eight actions at 60 Hz, +with 120 Hz physics and a 0.35 m/s belt speed. + +![Racetrack transfer](../../../../../docs/source/_static/tasks/conveyor/racetrack_transfer.jpg) + +## Backend support + +The racetrack task runs on Newton GPU. `IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0` provides a CPU-only +native PhysX reference for the original four-cube racetrack task. + +The PhysX task rejects CUDA during configuration validation. In the supported Isaac Sim runtime, +enabling the native surface-velocity contact-modification path under GPU dynamics can drop the belt +contacts and let packages pass through the conveyor. CPU PhysX preserves those contacts. Use the +Newton task whenever GPU simulation or vectorized throughput is required. + +The two backends deliberately share the policy tensor contract, so an RSL-RL checkpoint can be +loaded by either task without reshaping or reordering tensors. Their contact and actuator dynamics +are not numerically identical; validate task behavior when transferring a policy between them. + +Surface-velocity intent and the tensorized control contract live in +`isaaclab_contrib.conveyors.surface_velocity`. Newton traction uses the complete upstream +`ConveyorForceModel` from the pinned Newton release, including its MuJoCo contact reporting. +`isaaclab_contrib.conveyors.newton` adapts replicated surfaces, parcel selection, controls, and +reset lifecycle; it contains no separate traction solver. +PhysX schema authoring and live attribute control live in `isaaclab_contrib.conveyors.physx`. +The task package owns only the racetrack geometry, backend lifecycle selection, and task-level commands. + +## Original racetrack task + +Newton is kitless and supports the lightweight GL viewer: + +```bash +DISPLAY=:1 uv run isaaclab play --rl_library rsl_rl \ + --task IsaacContrib-Conveyor-Racetrack-Transfer-v0 \ + --checkpoint pretrained \ + --num_envs 8 --device cuda:0 --viz newton_gl --real-time +``` + +The `pretrained` selector downloads the RSL-RL policy published specifically for the Newton MJWarp +backend. The Newton checkpoint is published on Isaac Dev. While the public mirror is syncing, +use a local checkpoint path if the download is unavailable. +The PhysX task resolves a different backend-specific artifact name, so transferring this Newton policy +to PhysX currently requires the explicit local checkpoint path shown below. + +Training uses the same task ID and defaults to 256 environments: + +```bash +uv run isaaclab train --rl_library rsl_rl \ + --task IsaacContrib-Conveyor-Racetrack-Transfer-v0 \ + --num_envs 256 --device cuda:0 +``` + +### Curriculum and evaluation + +Racetrack training samples physically calibrated resets from release through moving-belt pickup. +The shared `SuccessMonitorCfg` tracks **phase progress** per reset row and weights intermediate +variants around a 50% progress target; this is not the full-transfer success rate. Sampling remains +balanced across phases, cube identities, and directions. Moving-belt starts receive at least 35% +of resets, increasing toward 90% only when completed transfers from those starts and reset-row +coverage improve. `deployment_transfer_success_rate` reports that separate rolling signal. + +New RSL-RL checkpoints save the curriculum evidence alongside standard PPO state and restore it +with `--checkpoint /path/to/model.pt`. Older checkpoints load normally with fresh evidence; +loading actor weights alone leaves the current curriculum intact. Playback disables the curriculum. + +Evaluate from home on moving belts, with fixed reset settings and a chosen seed: + +```bash +uv run python -m isaaclab_tasks.contrib.conveyor_franka.evaluate \ + --checkpoint /path/to/model.pt --num_envs 8 --device cuda:0 \ + --steps 3600 --seed 0 --output /tmp/conveyor-evaluation.json +``` + +The report counts completed transfers in both directions, safety failures, non-finite observations, +and transfers per simulated environment minute. Repeat with different seeds when comparing policies; +intermediate-reset training reward alone does not establish deployment reliability. + +## PhysX CPU playback + +The native PhysX variant requires an Isaac Sim-enabled launch and an explicit CPU device. One +environment is the default and recommended interactive configuration: + +```bash +DISPLAY=:1 uv run isaaclab play --rl_library rsl_rl \ + --task IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0 \ + --checkpoint /path/to/model.pt \ + --num_envs 1 --device cpu --viz kit --real-time \ + agent.device=cpu +``` + +Overriding the task to CUDA is an error by design; keep the explicit `--device cpu` in launch +commands for clarity. The native surface-velocity backend stages commands through USD, while the +Newton backend keeps its batched state, contact processing, and force application on the GPU. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py new file mode 100644 index 000000000000..bcb810f13ff1 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py @@ -0,0 +1,33 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Four-cube racetrack transfer with Newton and native CPU PhysX backends.""" + +import gymnasium as gym + +from . import agents + +gym.register( + id="IsaacContrib-Conveyor-Racetrack-Transfer-v0", + entry_point=f"{__name__}.conveyor_franka_env:ConveyorFrankaEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.conveyor_franka_env_cfg:ConveyorFrankaEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:ConveyorFrankaPPORunnerCfg", + }, +) + +gym.register( + # The native PhysxSurfaceVelocityAPI path is intentionally CPU-only. Keep + # that execution contract visible in the public task ID so a CUDA launch is + # never mistaken for a supported configuration. + id="IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0", + entry_point=f"{__name__}.conveyor_franka_env:ConveyorFrankaEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.conveyor_franka_physx_env_cfg:ConveyorFrankaPhysxEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:ConveyorFrankaPPORunnerCfg", + }, +) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/__init__.py new file mode 100644 index 000000000000..0e6e80a26022 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Agent configurations for conveyor transfer.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py new file mode 100644 index 000000000000..e7626cf9ba72 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py @@ -0,0 +1,48 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""RSL-RL checkpoint integration for the conveyor's adaptive reset evidence.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rsl_rl.algorithms import PPO + +if TYPE_CHECKING: + from rsl_rl.env import VecEnv + from tensordict import TensorDict + + from ..mdp.curriculums import ConveyorResetCurriculum + + +class ConveyorPPO(PPO): + """Use standard PPO while saving and restoring the task's reset curriculum.""" + + _curriculum: ConveyorResetCurriculum | None + + @staticmethod + def construct_algorithm(obs: TensorDict, env: VecEnv, cfg: dict, device: str) -> ConveyorPPO: + """Bind the active curriculum through RSL-RL's algorithm construction hook.""" + algorithm = PPO.construct_algorithm(obs, env, cfg, device) + curriculum = env.unwrapped.curriculum_manager + algorithm._curriculum = ( + curriculum.cfg.reset_sampling.func if "reset_sampling" in curriculum.active_terms else None + ) + return algorithm + + def save(self) -> dict: + """Include reset evidence in the standard model and optimizer checkpoint.""" + state = super().save() + if self._curriculum is not None: + state["conveyor_reset_curriculum"] = self._curriculum.get_state() + return state + + def load(self, loaded_dict: dict, load_cfg: dict | None, strict: bool) -> bool: + """Restore reset evidence on resume; older policy-only checkpoints remain supported.""" + resume = super().load(loaded_dict, load_cfg, strict) + if resume and self._curriculum is not None and "conveyor_reset_curriculum" in loaded_dict: + self._curriculum.set_state(loaded_dict["conveyor_reset_curriculum"]) + return resume diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py new file mode 100644 index 000000000000..6d0b0bc04d70 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py @@ -0,0 +1,183 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""RSL-RL PPO configuration for conveyor transfer.""" + +import torch +import torch.nn as nn +from rsl_rl.modules.distribution import GaussianDistribution +from torch.distributions import Bernoulli, Normal + +from isaaclab.utils.configclass import configclass + +from isaaclab_rl.rsl_rl import RslRlMLPModelCfg, RslRlOnPolicyRunnerCfg, RslRlPpoAlgorithmCfg + + +class _ConveyorDeterministicOutput(nn.Module): + """Threshold the gripper logit into the action used by the environment.""" + + def forward(self, output: torch.Tensor) -> torch.Tensor: + gripper = torch.where( + output[..., -1:] >= 0.0, + torch.ones_like(output[..., -1:]), + -torch.ones_like(output[..., -1:]), + ) + return torch.cat((output[..., :-1], gripper), dim=-1) + + +class ConveyorGaussianBernoulliDistribution(GaussianDistribution): + """Use Gaussian exploration for the arm and a Bernoulli gripper. + + The final policy output controls a physically binary action. Sampling it + from a Gaussian would assign different log probabilities to values that + become the same open or close command after thresholding. A Bernoulli + instead optimizes exactly the physical decision seen by the environment. + """ + + def __init__( + self, + output_dim: int, + init_std: float = 0.45, + std_range: tuple[float, float] = (0.15, 0.65), + std_type: str = "scalar", + **kwargs, + ) -> None: + if output_dim < 2: + raise ValueError("The conveyor distribution requires arm outputs followed by one gripper output.") + if len(std_range) != 2 or std_range[0] <= 0.0 or std_range[0] >= std_range[1]: + raise ValueError("std_range must contain positive, increasing bounds.") + if not std_range[0] < init_std < std_range[1]: + raise ValueError("init_std must lie strictly inside std_range.") + if std_type != "scalar": + raise ValueError("The conveyor distribution supports only scalar standard-deviation parameters.") + super().__init__(output_dim, init_std=init_std, std_type=std_type, **kwargs) + self.std_range = (float(std_range[0]), float(std_range[1])) + self._arm_distribution: Normal | None = None + self._gripper_distribution: Bernoulli | None = None + initial_fraction = (init_std - self.std_range[0]) / (self.std_range[1] - self.std_range[0]) + initial_logit = torch.logit(torch.tensor(initial_fraction, dtype=self.std_param.dtype)) + with torch.no_grad(): + self.std_param.fill_(initial_logit) + + def update(self, mlp_output: torch.Tensor) -> None: + """Update continuous-arm and binary-gripper distributions.""" + minimum_std, maximum_std = self.std_range + arm_std = minimum_std + (maximum_std - minimum_std) * torch.sigmoid(self.std_param[:-1]) + self._arm_distribution = Normal(mlp_output[..., :-1], arm_std) + self._gripper_distribution = Bernoulli(logits=mlp_output[..., -1:]) + + def sample(self) -> torch.Tensor: + """Sample seven residuals followed by an exact signed binary action.""" + gripper_open = self._gripper_distribution.sample() + return torch.cat((self._arm_distribution.sample(), 2.0 * gripper_open - 1.0), dim=-1) + + def deterministic_output(self, mlp_output: torch.Tensor) -> torch.Tensor: + """Return arm means and the most likely binary gripper command.""" + return _ConveyorDeterministicOutput()(mlp_output) + + def as_deterministic_output_module(self) -> nn.Module: + """Return an exportable deterministic-output transform.""" + return _ConveyorDeterministicOutput() + + @property + def mean(self) -> torch.Tensor: + """Return arm means and the expected signed gripper command.""" + gripper_mean = 2.0 * self._gripper_distribution.probs - 1.0 + return torch.cat((self._arm_distribution.mean, gripper_mean), dim=-1) + + @property + def std(self) -> torch.Tensor: + """Return arm standard deviations and signed-Bernoulli spread.""" + gripper_std = 2.0 * torch.sqrt(self._gripper_distribution.probs * (1.0 - self._gripper_distribution.probs)) + return torch.cat((self._arm_distribution.stddev, gripper_std), dim=-1) + + @property + def entropy(self) -> torch.Tensor: + """Return joint Gaussian-plus-Bernoulli entropy.""" + return self._arm_distribution.entropy().sum(dim=-1) + self._gripper_distribution.entropy().sum(dim=-1) + + @property + def params(self) -> tuple[torch.Tensor, ...]: + """Return parameters needed to evaluate the mixed KL divergence.""" + return self._arm_distribution.mean, self._arm_distribution.stddev, self._gripper_distribution.logits + + def log_prob(self, outputs: torch.Tensor) -> torch.Tensor: + """Evaluate the exact continuous/binary physical action.""" + arm_log_prob = self._arm_distribution.log_prob(outputs[..., :-1]).sum(dim=-1) + gripper_open = (outputs[..., -1:] >= 0.0).to(outputs.dtype) + return arm_log_prob + self._gripper_distribution.log_prob(gripper_open).sum(dim=-1) + + def kl_divergence( + self, + old_params: tuple[torch.Tensor, ...], + new_params: tuple[torch.Tensor, ...], + ) -> torch.Tensor: + """Return ``KL(old || new)`` for both action families.""" + old_arm_mean, old_arm_std, old_gripper_logits = old_params + new_arm_mean, new_arm_std, new_gripper_logits = new_params + arm_kl = torch.distributions.kl_divergence( + Normal(old_arm_mean, old_arm_std), + Normal(new_arm_mean, new_arm_std), + ).sum(dim=-1) + old_gripper_probability = torch.sigmoid(old_gripper_logits) + gripper_kl = ( + old_gripper_probability * (old_gripper_logits - new_gripper_logits) + - torch.nn.functional.softplus(old_gripper_logits) + + torch.nn.functional.softplus(new_gripper_logits) + ).sum(dim=-1) + return arm_kl + gripper_kl.clamp_min(0.0) + + +@configclass +class ConveyorGaussianBernoulliDistributionCfg(RslRlMLPModelCfg.GaussianDistributionCfg): + """Bounded Gaussian arm exploration with a Bernoulli gripper.""" + + class_name: str = ( + "isaaclab_tasks.contrib.conveyor_franka.agents.rsl_rl_ppo_cfg:ConveyorGaussianBernoulliDistribution" + ) + std_range: tuple[float, float] = (0.15, 0.65) + + +@configclass +class ConveyorFrankaPPORunnerCfg(RslRlOnPolicyRunnerCfg): + """PPO configuration for four-cube commanded transfer.""" + + num_steps_per_env = 32 + # RSL-RL's usual randomized episode counters would desynchronize the + # per-subgoal timeout clock before the first policy step. + init_at_random_ep_len = False + max_iterations = 4000 + save_interval = 50 + experiment_name = "conveyor_franka_transfer" + clip_actions = 1.0 + obs_groups = {"actor": ["policy"], "critic": ["policy"]} + actor = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=True, + distribution_cfg=ConveyorGaussianBernoulliDistributionCfg(init_std=0.45), + ) + critic = RslRlMLPModelCfg( + hidden_dims=[512, 256, 128], + activation="elu", + obs_normalization=True, + ) + algorithm = RslRlPpoAlgorithmCfg( + class_name="isaaclab_tasks.contrib.conveyor_franka.agents.conveyor_ppo:ConveyorPPO", + value_loss_coef=1.0, + use_clipped_value_loss=True, + clip_param=0.2, + entropy_coef=0.001, + num_learning_epochs=5, + num_mini_batches=16, + learning_rate=1.0e-4, + schedule="fixed", + # At 60 Hz, the ten-second pickup-to-placement horizon needs the same + # long-horizon discount used by the reset-driven Franka stack task. + gamma=0.999, + lam=0.95, + desired_kl=0.01, + max_grad_norm=1.0, + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py new file mode 100644 index 000000000000..9bdd7e0822f7 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py @@ -0,0 +1,139 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Manager-based environment that installs the selected conveyor physics adapter.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from isaaclab.envs import ManagerBasedRLEnv + +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocityView + +from .conveyor_franka_env_cfg import ConveyorFrankaEnvCfg +from .conveyor_geometry import belt_collision_section_specs +from .conveyor_goal_selector import ConveyorGoalSelector + + +class ConveyorFrankaEnv(ManagerBasedRLEnv): + """Manager-based environment with backend-native conveyor surfaces.""" + + cfg: ConveyorFrankaEnvCfg + + def __init__(self, cfg: ConveyorFrankaEnvCfg, render_mode: str | None = None, **kwargs): + self._conveyor_driver: SurfaceVelocityView | None = None + super().__init__(cfg, render_mode=render_mode, **kwargs) + self._goal_selector: ConveyorGoalSelector | None = None + self._setup_goal_selector() + + def _init_sim(self) -> None: + """Install the conveyor adapter at the lifecycle point required by its backend.""" + belt_spec_kwargs = { + "velocity": self.cfg.conveyor_force.speed, + "friction_coefficient": self.cfg.conveyor_force.friction, + "contact_threshold": self.cfg.conveyor_force.normal_threshold, + } + spec_builder = getattr(self.cfg.scene, "build_conveyor_belt_specs", None) + if spec_builder is None: + belt_specs = tuple( + section.belt + for side in ("Left", "Right") + for section in belt_collision_section_specs(side, **belt_spec_kwargs) + ) + else: + belt_specs = tuple(spec_builder(**belt_spec_kwargs)) + env_path_format = self.cfg.scene.clone_cfg.clone_template + + # Newton needs solved-contact attributes and graph callbacks registered + # before the first reset finalizes and captures the solver. PhysX belt + # schemas, by contrast, are authored by the scene spawners and its live + # command adapter is attached only after PhysX has parsed that scene. + from isaaclab_newton.physics import NewtonCfg + + from isaaclab_contrib.conveyors.newton import SurfaceVelocity + + if isinstance(self.cfg.sim.physics, NewtonCfg): + driver = SurfaceVelocity( + num_envs=self.cfg.scene.num_envs, + surface_specs=belt_specs, + startup_duration_s=self.cfg.conveyor_force.startup_duration_s, + env_path_format=env_path_format, + body_pattern=self.cfg.conveyor_force.transported_body_pattern, + body_count_per_env=self.cfg.conveyor_force.transported_body_count_per_env, + ) + self._conveyor_driver = driver + try: + super()._init_sim() + except Exception: + driver.close() + self._conveyor_driver = None + raise + return + + from isaaclab_physx.physics import PhysxCfg + + from isaaclab_contrib.conveyors.physx import SurfaceVelocity + + if not isinstance(self.cfg.sim.physics, PhysxCfg): + raise ValueError(f"Unsupported conveyor physics backend: {type(self.cfg.sim.physics).__name__}.") + + configure_conveyor = getattr(self.cfg.scene, "configure_conveyor", None) + if configure_conveyor is not None: + configure_conveyor(friction_coefficient=self.cfg.conveyor_force.friction) + super()._init_sim() + driver = SurfaceVelocity( + num_envs=self.cfg.scene.num_envs, + surface_specs=belt_specs, + env_path_format=env_path_format, + startup_duration_s=self.cfg.conveyor_force.startup_duration_s, + stage=self.sim.stage, + ) + try: + driver.start() + except Exception: + driver.close() + raise + self._conveyor_driver = driver + + @property + def conveyor_belt(self) -> SurfaceVelocityView: + """Tensorized conveyor control view for this environment.""" + driver = self._conveyor_driver + if driver is None: + raise RuntimeError("The conveyor belt is unavailable before simulation initialization or after close().") + return driver + + def _setup_goal_selector(self) -> None: + """Attach one task panel to the first supported interactive visualizer.""" + for visualizer in self.sim.visualizers: + if getattr(visualizer.cfg, "visualizer_type", None) not in {"newton_gl", "newton_rtx"}: + continue + register_callback = getattr(visualizer, "register_ui_callback", None) + if register_callback is None: + continue + visible_env_ids = visualizer.get_visualized_env_ids() + env_id = visible_env_ids[0] if visible_env_ids else 0 + self._goal_selector = ConveyorGoalSelector(self, env_id) + register_callback(self._goal_selector.render, position="panel") + return + + def _reset_idx(self, env_ids: Sequence[int] | slice): + """Reset selected environments and discard stale conveyor forces.""" + if isinstance(env_ids, slice): + env_ids = self.scene._ALL_INDICES[env_ids] + super()._reset_idx(env_ids) + + conveyor_driver = getattr(self, "_conveyor_driver", None) + if conveyor_driver is not None: + conveyor_driver.reset(env_ids) + + def close(self): + """Release conveyor callbacks before the physics scene is destroyed.""" + conveyor_driver = getattr(self, "_conveyor_driver", None) + if conveyor_driver is not None: + conveyor_driver.close() + self._conveyor_driver = None + super().close() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py new file mode 100644 index 000000000000..0d91becceea2 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py @@ -0,0 +1,699 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Configuration for the force-driven conveyor and Franka demonstration scene.""" + +from __future__ import annotations + +import math +import re + +from isaaclab_newton.physics import MJWarpSolverCfg, NewtonCfg, NewtonCollisionPipelineCfg, NewtonShapeCfg +from isaaclab_newton.sim.schemas import MujocoCollisionCfg, NewtonCollisionCfg, NewtonMaterialPropertiesCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import ArticulationCfg, AssetBaseCfg, RigidObjectCfg +from isaaclab.envs import ManagerBasedRLEnvCfg +from isaaclab.managers import CurriculumTermCfg as CurrTerm +from isaaclab.managers import EventTermCfg as EventTerm +from isaaclab.managers import ObservationGroupCfg as ObsGroup +from isaaclab.managers import ObservationTermCfg as ObsTerm +from isaaclab.managers import RewardTermCfg as RewTerm +from isaaclab.managers import SceneEntityCfg +from isaaclab.managers import TerminationTermCfg as DoneTerm +from isaaclab.scene import InteractiveSceneCfg +from isaaclab.sim import SimulationCfg +from isaaclab.sim.schemas import CollisionFragment, UsdPhysicsCollisionCfg +from isaaclab.sim.spawners.materials import RigidBodyMaterialBaseCfg +from isaaclab.utils.configclass import configclass +from isaaclab.visualizers import VisualizerCfg + +from . import mdp +from .conveyor_geometry import ( + BELT_COLOR, + BELT_INNER_STRAIGHT_Y, + BELT_OUTER_STRAIGHT_Y, + CUBE_COLORS, + CUBE_INNER_SLOT_X, + CUBE_OUTER_SLOT_X, + GUARD_COLOR, + CuboidSpec, + MeshSpec, + belt_collision_geometry_specs, + belt_mesh_spec, + guard_mesh_specs, +) +from .franka_robot_cfg import FRANKA_PANDA_CONVEYOR_CFG +from .mdp.terminations import invalid_action as invalid_policy_action + +_DYNAMIC_PROPERTIES = sim_utils.RigidBodyBaseCfg() +_CONTACT_GAP = 0.01 +_CUBE_CONTACT_MARGIN = 0.003 +_MUJOCO_SOLIMP = (0.9, 0.95, 0.001, 0.5, 2.0) +_MUJOCO_SOLREF = (0.02, 1.0) +_SUBGOAL_TIMEOUT_S = 20.0 +_TRANSFER_SEQUENCE_LENGTH = 8 +_ARM_JOINT_NAMES = tuple(f"panda_joint{joint_id}" for joint_id in range(1, 8)) +_FINGER_JOINT_NAMES = ("panda_finger_joint1", "panda_finger_joint2") +_ROBOT_INITIAL_STATE = ArticulationCfg.InitialStateCfg( + joint_pos={ + "panda_joint1": 0.0, + "panda_joint2": -0.35, + "panda_joint3": 0.0, + "panda_joint4": -2.35, + "panda_joint5": 0.0, + "panda_joint6": 2.0, + "panda_joint7": 0.78, + "panda_finger_joint.*": 0.04, + } +) + + +def _validate_common_config(cfg: ConveyorFrankaEnvCfg) -> None: + """Validate backend-independent timing and policy tensor contracts.""" + cfg.conveyor_force.validate_config() + if not cfg.sim.use_newton_actuators: + raise ValueError("The conveyor Franka requires the shared Newton-actuator execution path.") + if not math.isfinite(cfg.sim.dt) or cfg.sim.dt <= 0.0 or cfg.decimation <= 0: + raise ValueError("Simulation dt and environment decimation must be positive.") + arm_action = cfg.actions.arm_action + if arm_action.joint_names != list(_ARM_JOINT_NAMES) or not arm_action.preserve_order: + raise ValueError("Arm actions must preserve the explicit panda_joint1-to-panda_joint7 ordering.") + if not math.isfinite(arm_action.max_delta) or arm_action.max_delta <= 0.0: + raise ValueError("Arm max_delta must be finite and positive.") + if not math.isfinite(arm_action.joint_limit_margin) or arm_action.joint_limit_margin < 0.0: + raise ValueError("Arm joint_limit_margin must be finite and non-negative.") + lower = arm_action.workspace_lower + upper = arm_action.workspace_upper + if len(lower) != len(_ARM_JOINT_NAMES) or len(upper) != len(_ARM_JOINT_NAMES): + raise ValueError("Arm workspace bounds must contain one value per controlled joint.") + if any(not math.isfinite(value) for value in (*lower, *upper)): + raise ValueError("Arm workspace bounds must be finite.") + if any(low >= high for low, high in zip(lower, upper, strict=True)): + raise ValueError("Every arm workspace lower bound must be less than its upper bound.") + + +def _collision_properties(contact_margin: float = 0.0, mujoco_priority: int = 0) -> list[CollisionFragment]: + """Build explicit Newton and MuJoCo contact properties for one collider.""" + return [ + UsdPhysicsCollisionCfg(collision_enabled=True), + NewtonCollisionCfg(contact_margin=contact_margin, contact_gap=_CONTACT_GAP), + MujocoCollisionCfg( + condim=3, + priority=mujoco_priority, + solimp=_MUJOCO_SOLIMP, + solmix=1.0, + solref=_MUJOCO_SOLREF, + ), + ] + + +def _srgb_to_linear_channel(value: float) -> float: + """Convert an sRGB channel to the linear value expected by USD displayColor.""" + if value <= 0.04045: + return value / 12.92 + return ((value + 0.055) / 1.055) ** 2.4 + + +@configclass +class ActionsCfg: + """Relative arm and binary gripper actions.""" + + arm_action = mdp.ConveyorRelativeJointPositionActionCfg( + asset_name="robot", + joint_names=list(_ARM_JOINT_NAMES), + preserve_order=True, + scale=0.12, + max_delta=0.12, + ) + gripper_action = mdp.ResetBufferedGripperActionCfg( + asset_name="robot", + joint_names=list(_FINGER_JOINT_NAMES), + open_command_expr={"panda_finger_joint.*": 0.04}, + close_command_expr={"panda_finger_joint.*": 0.0}, + force_close_steps=5, + ) + + +@configclass +class CommandsCfg: + """Success-driven cube-transfer command.""" + + transfer = mdp.ConveyorTransferCommandCfg( + reset_event_name="reset_from_state_table", + minimum_subgoal_steps=2, + hold_steps=3, + lateral_tolerance=0.055, + maximum_cube_speed=0.65, + minimum_finger_position=0.027, + minimum_tool_clearance=0.055, + minimum_progress_steps=3, + minimum_progress=0.35, + maximum_target_potential=5.0, + minimum_acquisition_lift=0.025, + maximum_acquisition_tool_distance=0.075, + maximum_acquisition_finger_position=0.030, + ) + + +@configclass +class ObservationsCfg: + """Policy observations with stable cube identity and transfer commands.""" + + @configclass + class PolicyCfg(ObsGroup): + """Fully observed transfer policy input.""" + + joint_pos = ObsTerm( + func=mdp.joint_pos_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=list(_ARM_JOINT_NAMES), preserve_order=True)}, + ) + joint_vel = ObsTerm( + func=mdp.joint_vel_rel, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=list(_ARM_JOINT_NAMES), preserve_order=True)}, + ) + gripper_pos = ObsTerm( + func=mdp.gripper_joint_positions, + params={"robot_cfg": SceneEntityCfg("robot", joint_names=list(_FINGER_JOINT_NAMES), preserve_order=True)}, + ) + objects = ObsTerm(func=mdp.transfer_object_observation) + active_transfer = ObsTerm(func=mdp.active_transfer_features) + target_cube = ObsTerm(func=mdp.target_cube_one_hot) + cube_conveyors = ObsTerm(func=mdp.cube_conveyor_state) + target_side = ObsTerm(func=mdp.target_side_one_hot) + eef_velocity = ObsTerm(func=mdp.end_effector_velocity) + eef_axes = ObsTerm(func=mdp.end_effector_axes) + last_action = ObsTerm(func=mdp.last_action) + + def __post_init__(self) -> None: + self.enable_corruption = False + self.concatenate_terms = True + + policy: PolicyCfg = PolicyCfg() + + +@configclass +class EventCfg: + """Restore validated physical reset states.""" + + reset_all = EventTerm(func=mdp.reset_scene_to_default, mode="reset") + reset_from_state_table = EventTerm( + func=mdp.ConveyorResetStateTable, + mode="reset", + params={ + "fixed_recipe": None, + "fixed_variant_id": None, + "fixed_target_cube_id": None, + "fixed_source_side_id": None, + "belt_start_x_range": (0.30, 0.82), + "cube_position_noise": 0.015, + "arm_joint_noise": 0.015, + }, + ) + + +@configclass +class RewardsCfg: + """Sparse transfer completion plus safety regularization rewards.""" + + success = RewTerm( + func=mdp.transfer_success_reward, + params={"command_name": "transfer"}, + weight=600.0, + ) + failure = RewTerm( + func=mdp.terminal_failure, + params={"command_name": "transfer"}, + weight=-60.0, + ) + arm_action_l2 = RewTerm( + func=mdp.action_term_l2, + params={"action_name": "arm_action"}, + weight=-1.0e-3, + ) + action_rate_l2 = RewTerm( + func=mdp.finite_action_rate_l2, + params={"action_names": ("arm_action", "gripper_action")}, + weight=-1.0e-3, + ) + joint_velocity_l2 = RewTerm( + func=mdp.finite_joint_velocity_l2, + params={"asset_cfg": SceneEntityCfg("robot", joint_names=list(_ARM_JOINT_NAMES), preserve_order=True)}, + weight=-1.0e-4, + ) + + +@configclass +class TerminationsCfg: + """Safety failures and bounded training sequences.""" + + cube_out_of_workspace = DoneTerm(func=mdp.cube_out_of_workspace) + invalid_action = DoneTerm( + func=invalid_policy_action, + params={"action_names": ("arm_action", "gripper_action")}, + ) + nonfinite_scene_state = DoneTerm(func=mdp.nonfinite_scene_state) + subgoal_time_out = DoneTerm( + func=mdp.subgoal_time_out, + params={"timeout_s": _SUBGOAL_TIMEOUT_S, "command_name": "transfer"}, + time_out=True, + ) + transfer_sequence_time_out = DoneTerm( + func=mdp.transfer_sequence_time_out, + params={"maximum_transfers": _TRANSFER_SEQUENCE_LENGTH, "command_name": "transfer"}, + time_out=True, + ) + + +@configclass +class CurriculumCfg: + """Adaptive phase-balanced reset-state sampling.""" + + reset_sampling = CurrTerm( + func=mdp.ConveyorResetCurriculum, + params={ + "command_name": "transfer", + # Intermediate rows adapt around 50% phase progress, not completed transfers. + "success_monitor": mdp.SuccessMonitorCfg( + monitored_history_len=50, + target_success_rate=0.5, + kappa=1.0, + temperature=1.0, + ), + # Keep a deployment-facing stream while the remaining starts + # adapt around the rolling pickup-to-placement frontier. Deployment + # mass increases only after actual transfers from moving-belt starts. Every + # recipe, cube identity, and direction retains equal total mass. + "deployment_probability_initial": 0.35, + "deployment_probability_final": 0.90, + "deployment_progress_start": 0.45, + "deployment_progress_end": 0.80, + "deployment_coverage_target": 0.50, + # Optional staged-training control. None keeps the deployable + # bidirectional task; a side id can focus the same adaptive reset + # distribution on one weak direction without changing reset rows. + "fixed_source_side_id": None, + }, + ) + + +@configclass +class ConveyorForceCfg: + """Configuration for force-based conveyor traction.""" + + speed: float = 0.35 + """Tangential conveyor surface speed [m/s].""" + + friction: float = 0.5 + """Coulomb friction coefficient used to limit traction.""" + + normal_threshold: float = 0.997 + """Minimum upward contact-normal alignment in the range [0, 1].""" + + startup_duration_s: float = 1.0 + """Duration over which conveyor traction ramps to full speed [s].""" + + transported_body_pattern: str = r"(?:^|/)Cube_?[0-3](?:/|$)" + """Regular expression selecting rigid bodies that receive conveyor forces.""" + + transported_body_count_per_env: int = 4 + """Expected number of transported rigid bodies in each environment.""" + + def __post_init__(self) -> None: + """Validate conveyor force parameters.""" + self.validate_config() + + def validate_config(self) -> None: + """Validate final conveyor-force values after overrides are applied.""" + if not math.isfinite(self.speed) or self.speed < 0.0: + raise ValueError(f"Conveyor speed must be non-negative, got {self.speed}.") + if not math.isfinite(self.friction) or self.friction < 0.0: + raise ValueError(f"Conveyor friction must be non-negative, got {self.friction}.") + if not math.isfinite(self.normal_threshold) or not 0.0 <= self.normal_threshold <= 1.0: + raise ValueError(f"Conveyor normal threshold must be in [0, 1], got {self.normal_threshold}.") + if not math.isfinite(self.startup_duration_s) or self.startup_duration_s <= 0.0: + raise ValueError(f"Conveyor startup duration must be positive, got {self.startup_duration_s}.") + if self.transported_body_count_per_env <= 0: + raise ValueError( + "Conveyor transported body count per environment must be positive, got " + f"{self.transported_body_count_per_env}." + ) + try: + re.compile(self.transported_body_pattern) + except re.error as exc: + raise ValueError(f"Invalid conveyor transported-body pattern: {self.transported_body_pattern!r}.") from exc + + +@sim_utils.clone +def _spawn_shape_with_display_color( + prim_path: str, + cfg: sim_utils.ShapeCfg, + translation: tuple[float, float, float] | None = None, + orientation: tuple[float, float, float, float] | None = None, + **kwargs, +): + """Spawn a primitive and author a renderer-independent USD display color.""" + if isinstance(cfg, sim_utils.CuboidCfg): + prim = sim_utils.spawn_cuboid(prim_path, cfg, translation, orientation, **kwargs) + elif isinstance(cfg, sim_utils.CylinderCfg): + prim = sim_utils.spawn_cylinder(prim_path, cfg, translation, orientation, **kwargs) + elif isinstance(cfg, sim_utils.CapsuleCfg): + prim = sim_utils.spawn_capsule(prim_path, cfg, translation, orientation, **kwargs) + elif isinstance(cfg, sim_utils.SphereCfg): + prim = sim_utils.spawn_sphere(prim_path, cfg, translation, orientation, **kwargs) + else: + raise TypeError(f"Unsupported colored primitive configuration: {type(cfg).__name__}") + + if cfg.visual_material is not None: + from pxr import Usd, UsdGeom + + display_color = tuple(_srgb_to_linear_channel(value) for value in cfg.visual_material.diffuse_color) + for child in Usd.PrimRange(prim): + if child.IsA(UsdGeom.Gprim): + UsdGeom.Gprim(child).CreateDisplayColorAttr([display_color]) + return prim + + +@sim_utils.clone +def _spawn_hidden_collision_mesh( + prim_path: str, + cfg: sim_utils.MeshCustomCfg, + translation: tuple[float, float, float] | None = None, + orientation: tuple[float, float, float, float] | None = None, + **kwargs, +): + """Spawn a collision-only custom mesh and hide it from all visualizers.""" + prim = sim_utils.spawn_mesh_custom(prim_path, cfg, translation, orientation, **kwargs) + sim_utils.set_prim_visibility(prim, False) + return prim + + +def _static_cuboid( + prim_path: str, + size: tuple[float, float, float], + pos: tuple[float, float, float], + color: tuple[float, float, float], + rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), + friction: float = 0.7, + roughness: float = 0.75, + metallic: float = 0.0, +) -> AssetBaseCfg: + """Build a static colliding cuboid configuration.""" + spawn = sim_utils.CuboidCfg( + size=size, + collision_props=_collision_properties(), + physics_material=RigidBodyMaterialBaseCfg( + static_friction=friction, + dynamic_friction=friction, + restitution=0.0, + ), + visual_material=sim_utils.PreviewSurfaceCfg( + diffuse_color=color, + roughness=roughness, + metallic=metallic, + ), + ) + spawn.func = _spawn_shape_with_display_color + return AssetBaseCfg( + prim_path=prim_path, + init_state=AssetBaseCfg.InitialStateCfg(pos=pos, rot=rot), + spawn=spawn, + ) + + +def _visual_mesh( + prim_path: str, + spec: MeshSpec, + color: tuple[float, float, float], + roughness: float, + metallic: float, +) -> AssetBaseCfg: + """Build a non-colliding custom mesh used only for rendering.""" + return AssetBaseCfg( + prim_path=prim_path, + spawn=sim_utils.MeshCustomCfg( + vertices=spec.vertices, + faces=spec.faces, + visual_material=sim_utils.PreviewSurfaceCfg( + diffuse_color=color, + roughness=roughness, + metallic=metallic, + ), + ), + ) + + +def _collision_material(friction: float) -> NewtonMaterialPropertiesCfg: + """Build a contact material for surfaces using the task-wide raw MuJoCo response.""" + return NewtonMaterialPropertiesCfg( + static_friction=friction, + dynamic_friction=friction, + restitution=0.0, + ) + + +def _hidden_collision_geometry( + prim_path: str, + spec: MeshSpec | CuboidSpec, + friction: float, + mujoco_priority: int, +) -> AssetBaseCfg: + """Build a hidden collider, using native cuboids for straight sections.""" + properties = _collision_properties(mujoco_priority=mujoco_priority) + material = _collision_material(friction) + if isinstance(spec, CuboidSpec): + spawn = sim_utils.CuboidCfg( + size=spec.size, visible=False, collision_props=properties, physics_material=material + ) + position = spec.position + else: + spawn = sim_utils.MeshCustomCfg( + vertices=spec.vertices, + faces=spec.faces, + visible=False, + collision_props=properties, + physics_material=material, + ) + spawn.func = _spawn_hidden_collision_mesh + position = (0.0, 0.0, 0.0) + return AssetBaseCfg( + prim_path=prim_path, + init_state=AssetBaseCfg.InitialStateCfg(pos=position), + spawn=spawn, + ) + + +def _cube( + name: str, + color: tuple[float, float, float], + pos: tuple[float, float, float], +) -> RigidObjectCfg: + """Build one numbered dynamic transfer cube.""" + spawn = sim_utils.CuboidCfg( + size=(0.04, 0.04, 0.04), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=color, roughness=0.75), + ) + spawn.rigid_props = _DYNAMIC_PROPERTIES + spawn.mass_props = sim_utils.MassPropertiesCfg(mass=0.05) + spawn.collision_props = _collision_properties(contact_margin=_CUBE_CONTACT_MARGIN) + spawn.physics_material = NewtonMaterialPropertiesCfg( + # The belt's higher MuJoCo contact priority overrides this friction + # only for belt/cube pairs, leaving physical finger/cube friction. + static_friction=0.8, + dynamic_friction=0.6, + restitution=0.0, + ) + spawn.func = _spawn_shape_with_display_color + return RigidObjectCfg( + prim_path=f"{{ENV_REGEX_NS}}/{name}", + init_state=RigidObjectCfg.InitialStateCfg(pos=pos), + spawn=spawn, + ) + + +@configclass +class ConveyorFrankaSceneCfg(InteractiveSceneCfg): + """Scene with two counter-rotating racetrack conveyors around a table-mounted Franka.""" + + # Use the MuJoCo Menagerie-derived model with explicit manipulation gains. + robot = FRANKA_PANDA_CONVEYOR_CFG.replace( + prim_path="{ENV_REGEX_NS}/Robot", + init_state=_ROBOT_INITIAL_STATE, + ) + + tabletop = _static_cuboid( + prim_path="{ENV_REGEX_NS}/Tabletop", + size=(2.0, 1.9, 0.08), + pos=(0.50, 0.0, -0.04), + color=(0.32, 0.34, 0.37), + ) + table_pedestal = _static_cuboid( + prim_path="{ENV_REGEX_NS}/TablePedestal", + size=(0.75, 0.55, 0.76), + pos=(0.25, 0.0, -0.46), + color=(0.18, 0.20, 0.23), + ) + + cube_0 = _cube("Cube0", CUBE_COLORS[0], (CUBE_INNER_SLOT_X, BELT_INNER_STRAIGHT_Y, 0.06)) + cube_1 = _cube("Cube1", CUBE_COLORS[1], (CUBE_OUTER_SLOT_X, BELT_OUTER_STRAIGHT_Y, 0.06)) + cube_2 = _cube("Cube2", CUBE_COLORS[2], (CUBE_INNER_SLOT_X, -BELT_INNER_STRAIGHT_Y, 0.06)) + cube_3 = _cube("Cube3", CUBE_COLORS[3], (CUBE_OUTER_SLOT_X, -BELT_OUTER_STRAIGHT_Y, 0.06)) + + ground = AssetBaseCfg( + prim_path="/World/GroundPlane", + init_state=AssetBaseCfg.InitialStateCfg(pos=(0.0, 0.0, -0.85)), + spawn=sim_utils.GroundPlaneCfg(), + ) + dome_light = AssetBaseCfg( + prim_path="/World/DomeLight", + spawn=sim_utils.DomeLightCfg(color=(0.8, 0.8, 0.8), intensity=2500.0), + ) + + def __post_init__(self) -> None: + """Generate visual belts, velocity-field collision sections, and guardrails.""" + for side in ("Left", "Right"): + belt_spec = belt_mesh_spec(side) + setattr( + self, + f"conveyor_{side.lower()}_belt_visual", + _visual_mesh( + prim_path=f"{{ENV_REGEX_NS}}/{belt_spec.name}", + spec=belt_spec, + color=BELT_COLOR, + roughness=0.9, + metallic=0.0, + ), + ) + + section_keys = ("top_straight", "bottom_straight", "right_turn", "left_turn") + for section_key, spec in zip(section_keys, belt_collision_geometry_specs(side), strict=True): + setattr( + self, + f"conveyor_{side.lower()}_{section_key}_collision", + _hidden_collision_geometry( + prim_path=f"{{ENV_REGEX_NS}}/{spec.name}", + spec=spec, + # MuJoCo requires a tiny positive value even though the force driver, + # rather than solver friction, supplies the belt motion. + friction=1.1e-5, + # Override cube friction only for collision-section/cube pairs. + mujoco_priority=1, + ), + ) + + for spec in guard_mesh_specs(side): + boundary = "inner" if spec.name.endswith("Inner") else "outer" + setattr( + self, + f"guard_{side.lower()}_{boundary}_visual", + _visual_mesh( + prim_path=f"{{ENV_REGEX_NS}}/{spec.name}Visual", + spec=spec, + color=GUARD_COLOR, + roughness=0.3, + metallic=0.8, + ), + ) + setattr( + self, + f"guard_{side.lower()}_{boundary}_collision", + _hidden_collision_geometry( + prim_path=f"{{ENV_REGEX_NS}}/{spec.name}Collision", + spec=spec, + # The compact turns need freely sliding guide contacts; + # tangential rail friction can wedge a cube against the + # wall even though its belt drive remains valid. + friction=1.1e-5, + # Override the cube's grasp friction only for rail contacts. + mujoco_priority=1, + ), + ) + + +@configclass +class ConveyorFrankaEnvCfg(ManagerBasedRLEnvCfg): + """Manager-based RL task for commanded conveyor-to-conveyor cube transfer.""" + + class_type: type | str = "{DIR}.conveyor_franka_env:ConveyorFrankaEnv" + scene: ConveyorFrankaSceneCfg = ConveyorFrankaSceneCfg(num_envs=256, env_spacing=3.0, replicate_physics=True) + conveyor_force: ConveyorForceCfg = ConveyorForceCfg() + actions: ActionsCfg = ActionsCfg() + commands: CommandsCfg = CommandsCfg() + observations: ObservationsCfg = ObservationsCfg() + events: EventCfg = EventCfg() + rewards: RewardsCfg = RewardsCfg() + terminations: TerminationsCfg = TerminationsCfg() + curriculum: CurriculumCfg = CurriculumCfg() + decimation: int = 2 + episode_length_s: float = _SUBGOAL_TIMEOUT_S * _TRANSFER_SEQUENCE_LENGTH + + sim: SimulationCfg = SimulationCfg( + dt=1.0 / 120.0, + render_interval=2, + physics=NewtonCfg( + solver_cfg=MJWarpSolverCfg( + solver="newton", + integrator="implicitfast", + # Per-environment capacities include headroom for all four cubes, + # the gripper, table, belts, and guard contacts without allocating + # the previous 256k-contact scene-wide driver buffers. + njmax=300, + nconmax=200, + impratio=1.0, + cone="elliptic", + update_data_interval=1, + iterations=100, + ls_iterations=50, + use_mujoco_contacts=False, + ccd_iterations=35, + ), + collision_cfg=NewtonCollisionPipelineCfg(), + # Manager decimation supplies the reference's two 120 Hz solves + # per 60 Hz policy step, with contacts refreshed before each solve. + collision_decimation=0, + default_shape_cfg=NewtonShapeCfg(margin=0.0, gap=_CONTACT_GAP, ke=2.5e3, kd=100.0), + num_substeps=1, + use_cuda_graph=True, + # Import render-only geometry only when a visualizer or camera needs it. + load_visual_shapes=None, + ), + use_newton_actuators=True, + ) + + def __post_init__(self) -> None: + # Any visualizer selected at runtime receives these shared camera hints. + # Newton camera pose: position (2.13, 0.0, 1.0), pitch -23.9 degrees, + # yaw 180 degrees. The look-at point is one unit along that view ray. + self.sim.default_visualizer_cfg = VisualizerCfg( + eye=(2.13, 0.0, 1.0), + lookat=(1.2157460448, 0.0, 0.5948584132), + max_visible_envs=1, + randomly_sample_visible_envs=False, + ) + + def validate_config(self) -> None: + """Validate the final task configuration after command-line overrides are applied.""" + _validate_common_config(self) + physics = self.sim.physics + if not isinstance(physics, NewtonCfg) or not isinstance(physics.solver_cfg, MJWarpSolverCfg): + raise ValueError("The conveyor force driver requires the Newton MJWarp backend.") + if physics.solver_cfg.use_mujoco_contacts: + raise ValueError("The conveyor force driver requires the Newton collision-pipeline contact path.") + if physics.collision_cfg is None: + raise ValueError("The conveyor force driver requires an explicit Newton collision pipeline.") + + def play_mode(self) -> None: + """Run continuing transfers from evenly distributed moving-belt starts.""" + super().play_mode() + self.scene.num_envs = min(self.scene.num_envs, 8) + self.events.reset_from_state_table.params["fixed_recipe"] = int(mdp.ConveyorResetRecipe.BELT) + self.events.reset_from_state_table.params["fixed_variant_id"] = mdp.BELT_DEPLOYMENT_VARIANT + self.events.reset_from_state_table.params["cube_position_noise"] = 0.0 + # Successful placements already transition to a new commanded cube. + # Playback removes training-only refreshes and runs until physics leaves + # the recoverable workspace. + self.terminations.subgoal_time_out = None + self.terminations.transfer_sequence_time_out = None + self.curriculum = None diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py new file mode 100644 index 000000000000..2590b16a1fdc --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py @@ -0,0 +1,308 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""CPU-only native-PhysX configuration for conveyor-Franka policy playback.""" + +from __future__ import annotations + +import functools +from dataclasses import replace + +from isaaclab_physx.physics import PhysxCfg +from isaaclab_physx.sim.schemas import PhysxCollisionCfg, PhysxSDFMeshCfg +from isaaclab_physx.sim.spawners.materials import PhysxRigidBodyMaterialCfg + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg +from isaaclab.sim import SimulationCfg +from isaaclab.sim.schemas import CollisionFragment, UsdPhysicsCollisionCfg +from isaaclab.utils.configclass import configclass + +from isaaclab_contrib.conveyors.physx import apply_surface_velocity_api +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec + +from .conveyor_franka_env_cfg import ( + _CONTACT_GAP, + _CUBE_CONTACT_MARGIN, + _ROBOT_INITIAL_STATE, + ConveyorFrankaEnvCfg, + ConveyorFrankaSceneCfg, + _spawn_hidden_collision_mesh, + _validate_common_config, +) +from .conveyor_geometry import ( + ConveyorSectionSpec, + CuboidSpec, + MeshSpec, + belt_collision_section_specs, + guard_mesh_specs, +) +from .franka_robot_cfg import FRANKA_PANDA_CONVEYOR_PHYSX_CFG + +_PHYSX_KINEMATIC_PROPERTIES = sim_utils.RigidBodyBaseCfg( + rigid_body_enabled=True, + kinematic_enabled=True, + disable_gravity=True, +) + + +def _physx_collision_properties(contact_offset: float = 0.005) -> list[CollisionFragment]: + """Build the standard collision and PhysX offset fragments for one collider.""" + return [ + UsdPhysicsCollisionCfg(collision_enabled=True), + PhysxCollisionCfg(contact_offset=contact_offset, rest_offset=0.0), + ] + + +def _physx_material(friction: float) -> PhysxRigidBodyMaterialCfg: + """Build a deterministic PhysX material for a conveyor-task collision surface.""" + return PhysxRigidBodyMaterialCfg( + static_friction=friction, + dynamic_friction=friction, + restitution=0.0, + friction_combine_mode="min", + restitution_combine_mode="min", + ) + + +def _pivot_centered_section(section: ConveyorSectionSpec) -> tuple[ConveyorSectionSpec, tuple[float, float, float]]: + """Express a curved section around its rigid-body origin for native angular surface velocity.""" + if not section.belt.curved: + geometry = section.geometry + if not isinstance(geometry, CuboidSpec): + raise TypeError("Straight PhysX conveyor sections must use native cuboids.") + return section, geometry.position + + geometry = section.geometry + if not isinstance(geometry, MeshSpec): + raise TypeError("Curved PhysX conveyor sections must use closed meshes.") + pivot = section.belt.pivot_point + local_geometry = MeshSpec( + name=geometry.name, + vertices=tuple( + (vertex[0] - pivot[0], vertex[1] - pivot[1], vertex[2] - pivot[2]) for vertex in geometry.vertices + ), + faces=geometry.faces, + ) + local_belt = replace(section.belt, pivot_point=(0.0, 0.0, 0.0)) + return ConveyorSectionSpec(geometry=local_geometry, belt=local_belt), pivot + + +def physx_belt_section_specs( + side: str, + *, + velocity: float = 0.0, + friction_coefficient: float = 0.5, + contact_threshold: float = 0.997, +) -> tuple[tuple[ConveyorSectionSpec, tuple[float, float, float]], ...]: + """Return pivot-centered sections sharing the exact runtime PhysX semantics.""" + return tuple( + _pivot_centered_section(section) + for section in belt_collision_section_specs( + side, + velocity=velocity, + friction_coefficient=friction_coefficient, + contact_threshold=contact_threshold, + ) + ) + + +@sim_utils.clone +def _spawn_physx_conveyor( + prim_path: str, + cfg: sim_utils.MeshCustomCfg | sim_utils.CuboidCfg, + translation: tuple[float, float, float] | None = None, + orientation: tuple[float, float, float, float] | None = None, + *, + belt_spec: SurfaceVelocitySpec, + **kwargs, +): + """Spawn a hidden belt section and author native surface velocity before PhysX parsing.""" + spawn = sim_utils.spawn_mesh_custom if isinstance(cfg, sim_utils.MeshCustomCfg) else sim_utils.spawn_cuboid + prim = spawn(prim_path, cfg, translation, orientation, **kwargs) + sim_utils.set_prim_visibility(prim, False) + apply_surface_velocity_api(prim, belt_spec, velocity_scale=0.0) + return prim + + +def _physx_conveyor_collision( + prim_path: str, + section: ConveyorSectionSpec, + root_position: tuple[float, float, float], + friction: float, +) -> AssetBaseCfg: + """Build one native-velocity belt section with analytic or watertight-SDF collision.""" + geometry = section.geometry + if isinstance(geometry, CuboidSpec): + spawn = sim_utils.CuboidCfg( + size=geometry.size, + visible=False, + rigid_props=_PHYSX_KINEMATIC_PROPERTIES, + collision_props=_physx_collision_properties(contact_offset=_CONTACT_GAP), + physics_material=_physx_material(friction), + ) + else: + spawn = sim_utils.MeshCustomCfg( + vertices=geometry.vertices, + faces=geometry.faces, + visible=False, + rigid_props=_PHYSX_KINEMATIC_PROPERTIES, + collision_props=[ + *_physx_collision_properties(contact_offset=_CONTACT_GAP), + PhysxSDFMeshCfg(sdf_resolution=128, sdf_subgrid_resolution=6), + ], + collision_approximation="sdf", + physics_material=_physx_material(friction), + ) + spawn.func = functools.partial(_spawn_physx_conveyor, belt_spec=section.belt) + return AssetBaseCfg( + prim_path=prim_path, + init_state=AssetBaseCfg.InitialStateCfg(pos=root_position), + spawn=spawn, + ) + + +def _physx_guard_collision(prim_path: str, spec: MeshSpec) -> AssetBaseCfg: + """Build one freely sliding static guide collider.""" + spawn = sim_utils.MeshCustomCfg( + vertices=spec.vertices, + faces=spec.faces, + visible=False, + collision_props=_physx_collision_properties(), + collision_approximation="none", + physics_material=_physx_material(1.1e-5), + ) + spawn.func = _spawn_hidden_collision_mesh + return AssetBaseCfg(prim_path=prim_path, spawn=spawn) + + +@configclass +class ConveyorFrankaPhysxSceneCfg(ConveyorFrankaSceneCfg): + """PhysX scene preserving the Newton task's names, layout, and tensor contracts.""" + + robot = FRANKA_PANDA_CONVEYOR_PHYSX_CFG.replace( + prim_path="{ENV_REGEX_NS}/Robot", + init_state=_ROBOT_INITIAL_STATE, + ) + + def __post_init__(self) -> None: + """Reuse the racetrack layout and visuals, replacing only backend physics properties.""" + super().__post_init__() + for support in (self.tabletop, self.table_pedestal): + support.spawn.collision_props = _physx_collision_properties() + support.spawn.physics_material = _physx_material(0.7) + for cube_id in range(4): + cube = getattr(self, f"cube_{cube_id}") + cube.spawn.collision_props = _physx_collision_properties(contact_offset=_CUBE_CONTACT_MARGIN) + cube.spawn.physics_material = PhysxRigidBodyMaterialCfg( + static_friction=0.8, + dynamic_friction=0.6, + restitution=0.0, + restitution_combine_mode="min", + ) + for side in ("Left", "Right"): + section_keys = ("top_straight", "bottom_straight", "right_turn", "left_turn") + for section_key, (section, root_position) in zip(section_keys, physx_belt_section_specs(side), strict=True): + setattr( + self, + f"conveyor_{side.lower()}_{section_key}_collision", + _physx_conveyor_collision( + prim_path=f"{{ENV_REGEX_NS}}/{section.geometry.name}", + section=section, + root_position=root_position, + friction=0.5, + ), + ) + + for guard in guard_mesh_specs(side): + boundary = "inner" if guard.name.endswith("Inner") else "outer" + setattr( + self, + f"guard_{side.lower()}_{boundary}_collision", + _physx_guard_collision(f"{{ENV_REGEX_NS}}/{guard.name}Collision", guard), + ) + + def build_conveyor_belt_specs( + self, + *, + velocity: float, + friction_coefficient: float, + contact_threshold: float, + ) -> tuple[SurfaceVelocitySpec, ...]: + """Return the same pivot-local belt descriptions used by the PhysX spawners.""" + return tuple( + section.belt + for side in ("Left", "Right") + for section, _ in physx_belt_section_specs( + side, + velocity=velocity, + friction_coefficient=friction_coefficient, + contact_threshold=contact_threshold, + ) + ) + + def configure_conveyor(self, *, friction_coefficient: float) -> None: + """Propagate a final command-line friction override into every belt material before spawning.""" + for side in ("left", "right"): + for section_key in ("top_straight", "bottom_straight", "right_turn", "left_turn"): + asset = getattr(self, f"conveyor_{side}_{section_key}_collision") + material = asset.spawn.physics_material + material.static_friction = friction_coefficient + material.dynamic_friction = friction_coefficient + + +@configclass +class ConveyorFrankaPhysxEnvCfg(ConveyorFrankaEnvCfg): + """Checkpoint-compatible CPU reference using native PhysX surface velocity. + + The supported and tested native ``PhysxSurfaceVelocityAPI`` path is CPU-only. Use + :class:`ConveyorFrankaEnvCfg` for scalable GPU simulation with Newton. + """ + + scene: ConveyorFrankaPhysxSceneCfg = ConveyorFrankaPhysxSceneCfg( + # USD surface-velocity commands are authored on the host. One environment + # is the useful default for this CPU reference; callers may opt into a + # small replicated batch explicitly. + num_envs=1, + env_spacing=3.0, + replicate_physics=True, + ) + sim: SimulationCfg = SimulationCfg( + # The supported GPU contact-modification path drops contacts for shapes + # with PhysxSurfaceVelocityAPI enabled. CPU PhysX preserves the authored + # API and its belt contacts; fail validation on an accidental CUDA + # override instead of letting cubes tunnel silently. + device="cpu", + dt=1.0 / 120.0, + render_interval=2, + physics=PhysxCfg( + solver_type=1, + solve_articulation_contact_last=True, + max_position_iteration_count=64, + max_velocity_iteration_count=16, + bounce_threshold_velocity=0.2, + friction_offset_threshold=0.01, + friction_correlation_distance=0.00625, + enable_ccd=True, + ), + use_fabric=True, + use_newton_actuators=True, + ) + + def validate_config(self) -> None: + """Validate PhysX selection and the shared checkpoint-facing policy contract.""" + _validate_common_config(self) + if not isinstance(self.sim.physics, PhysxCfg): + raise ValueError("The PhysX conveyor task requires the native Isaac Sim PhysX backend.") + if self.sim.device != "cpu": + raise ValueError( + "The native PhysX conveyor is CPU-only because enabled PhysxSurfaceVelocityAPI shapes can lose " + "contacts under GPU dynamics. Run this task with '--device cpu'; use the Newton task for GPU " + "simulation." + ) + if self.scene.robot.spawn.joint_drive_props is not None: + raise ValueError("The PhysX Franka must not author MuJoCo-only joint-drive properties.") + if self.scene.robot.spawn.rigid_props.disable_gravity is not True: + raise ValueError("The PhysX Franka must preserve the trained gravity-compensated policy contract.") diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py new file mode 100644 index 000000000000..1c8d7c6e2bab --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py @@ -0,0 +1,397 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Shared racetrack geometry and velocity-field descriptions.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec + +BELT_COLOR = (0.09, 0.09, 0.09) +"""Dark-rubber color used by Newton's conveyor example.""" + +GUARD_COLOR = (0.66, 0.69, 0.74) +"""Brushed-metal color used by Newton's conveyor example.""" + +PARCEL_COLOR = (0.72, 0.55, 0.35) +CUBE_COLORS = ( + (0.15, 0.35, 0.90), + (0.90, 0.20, 0.15), + (0.15, 0.75, 0.25), + PARCEL_COLOR, +) +"""Stable sRGB colors for the four numbered transfer cubes.""" +"""Cardboard color used by Newton's conveyor example.""" + +BELT_CENTER_X = 0.58 +BELT_CENTER_Y = 0.51 +BELT_TURN_RADIUS = 0.24 +BELT_HALF_STRAIGHT = 0.44 +BELT_WIDTH = 0.15 +BELT_THICKNESS = 0.04 +BELT_TOP_Z = 0.04 +TURN_SEGMENT_COUNT = 96 + +# Four deployment slots place one cube on each straight run of the two +# racetracks. The x coordinates are mirrored about the racetrack center, so +# the inner/outer pair on a belt is separated by exactly half a lap. +BELT_INNER_STRAIGHT_Y = BELT_CENTER_Y - BELT_TURN_RADIUS +BELT_OUTER_STRAIGHT_Y = BELT_CENTER_Y + BELT_TURN_RADIUS +CUBE_INNER_SLOT_X = BELT_CENTER_X - BELT_HALF_STRAIGHT / 3.0 +CUBE_OUTER_SLOT_X = BELT_CENTER_X + BELT_HALF_STRAIGHT / 3.0 + +# Collision surfaces extend underneath the rails and overlap at section seams. +# This keeps the dynamic parcels on a continuous +Z-facing surface without +# exposing the belt prism's vertical side faces to the contact solver. +BELT_COLLISION_OVERHANG = 0.02 +BELT_COLLISION_SEAM_OVERLAP = 0.004 + +GUARD_THICKNESS = 0.018 +# Keep the rails below the parcel tops so both lanes remain easy to read from +# the default oblique camera. +GUARD_HEIGHT = 0.02 +GUARD_BASE_OVERLAP = 0.005 + + +@dataclass(frozen=True) +class MeshSpec: + """Triangle mesh and semantic name for one static racetrack component.""" + + name: str + vertices: tuple[tuple[float, float, float], ...] + faces: tuple[tuple[int, int, int], ...] + + +@dataclass(frozen=True) +class CuboidSpec: + """Native cuboid and semantic name for one static racetrack component.""" + + name: str + size: tuple[float, float, float] + position: tuple[float, float, float] + + +@dataclass(frozen=True) +class ConveyorSectionSpec: + """Task geometry paired with its backend-neutral conveyor description.""" + + geometry: MeshSpec | CuboidSpec + belt: SurfaceVelocitySpec + + +def belt_direction(side: str) -> float: + """Return ``1`` for clockwise motion and ``-1`` for counter-clockwise motion.""" + if side == "Left": + return 1.0 + if side == "Right": + return -1.0 + raise ValueError(f"Unknown conveyor side: {side!r}.") + + +def _racetrack_centerline(center_y: float) -> tuple[tuple[float, float, float, float], ...]: + """Sample a clockwise racetrack centerline with an outward normal at every point.""" + left_x = BELT_CENTER_X - BELT_HALF_STRAIGHT + right_x = BELT_CENTER_X + BELT_HALF_STRAIGHT + radius = BELT_TURN_RADIUS + points: list[tuple[float, float, float, float]] = [ + (left_x, center_y + radius, 0.0, 1.0), + (right_x, center_y + radius, 0.0, 1.0), + ] + + for index in range(1, TURN_SEGMENT_COUNT + 1): + angle = 0.5 * math.pi - index * math.pi / TURN_SEGMENT_COUNT + normal_x = math.cos(angle) + normal_y = math.sin(angle) + points.append((right_x + radius * normal_x, center_y + radius * normal_y, normal_x, normal_y)) + + points.append((left_x, center_y - radius, 0.0, -1.0)) + for index in range(1, TURN_SEGMENT_COUNT): + angle = -0.5 * math.pi - index * math.pi / TURN_SEGMENT_COUNT + normal_x = math.cos(angle) + normal_y = math.sin(angle) + points.append((left_x + radius * normal_x, center_y + radius * normal_y, normal_x, normal_y)) + return tuple(points) + + +def _racetrack_prism_mesh( + name: str, + center_y: float, + lateral_offset: float, + width: float, + z_min: float, + z_max: float, +) -> MeshSpec: + """Build one closed prism following a racetrack centerline.""" + centerline = _racetrack_centerline(center_y) + half_width = 0.5 * width + outer_offset = lateral_offset + half_width + inner_offset = lateral_offset - half_width + + outer_top = [(x + nx * outer_offset, y + ny * outer_offset, z_max) for x, y, nx, ny in centerline] + inner_top = [(x + nx * inner_offset, y + ny * inner_offset, z_max) for x, y, nx, ny in centerline] + outer_bottom = [(x + nx * outer_offset, y + ny * outer_offset, z_min) for x, y, nx, ny in centerline] + inner_bottom = [(x + nx * inner_offset, y + ny * inner_offset, z_min) for x, y, nx, ny in centerline] + points = tuple(inner_top + outer_top + inner_bottom + outer_bottom) + + count = len(centerline) + outer_top_offset = count + inner_bottom_offset = 2 * count + outer_bottom_offset = 3 * count + indices: list[int] = [] + for index in range(count): + next_index = (index + 1) % count + inner_top_i = index + inner_top_j = next_index + outer_top_i = outer_top_offset + index + outer_top_j = outer_top_offset + next_index + inner_bottom_i = inner_bottom_offset + index + inner_bottom_j = inner_bottom_offset + next_index + outer_bottom_i = outer_bottom_offset + index + outer_bottom_j = outer_bottom_offset + next_index + + # Top, bottom, outer wall, and inner wall; two triangles per surface. + indices.extend((inner_top_i, outer_top_i, outer_top_j, inner_top_i, outer_top_j, inner_top_j)) + indices.extend( + ( + inner_bottom_i, + inner_bottom_j, + outer_bottom_j, + inner_bottom_i, + outer_bottom_j, + outer_bottom_i, + ) + ) + indices.extend( + ( + outer_bottom_i, + outer_bottom_j, + outer_top_j, + outer_bottom_i, + outer_top_j, + outer_top_i, + ) + ) + indices.extend( + ( + inner_bottom_i, + inner_top_i, + inner_top_j, + inner_bottom_i, + inner_top_j, + inner_bottom_j, + ) + ) + + # The centerline is sampled clockwise so its tangent matches the positive + # conveyor direction. The face pattern above is written for a + # counter-clockwise ring, so reverse every triangle to keep its collision + # normal outward (most importantly, the belt top must point +Z). + for triangle_start in range(0, len(indices), 3): + indices[triangle_start + 1], indices[triangle_start + 2] = ( + indices[triangle_start + 2], + indices[triangle_start + 1], + ) + + return MeshSpec( + name=name, + vertices=points, + faces=tuple(tuple(indices[offset : offset + 3]) for offset in range(0, len(indices), 3)), + ) + + +def belt_mesh_spec(side: str) -> MeshSpec: + """Build the seamless, watertight visual mesh for one conveyor belt.""" + center_y = BELT_CENTER_Y if side == "Left" else -BELT_CENTER_Y + return _racetrack_prism_mesh( + name=f"Conveyor{side}BeltVisual", + center_y=center_y, + lateral_offset=0.0, + width=BELT_WIDTH, + z_min=BELT_TOP_Z - BELT_THICKNESS, + z_max=BELT_TOP_Z, + ) + + +def _straight_collision_cuboid(name: str, center_y: float) -> CuboidSpec: + """Build one solid native cuboid for a straight conveyor section.""" + half_width = 0.5 * BELT_WIDTH + BELT_COLLISION_OVERHANG + x_min = BELT_CENTER_X - BELT_HALF_STRAIGHT - BELT_COLLISION_SEAM_OVERLAP + x_max = BELT_CENTER_X + BELT_HALF_STRAIGHT + BELT_COLLISION_SEAM_OVERLAP + return CuboidSpec( + name=name, + size=(x_max - x_min, 2.0 * half_width, BELT_THICKNESS), + position=(0.5 * (x_min + x_max), center_y, BELT_TOP_Z - 0.5 * BELT_THICKNESS), + ) + + +def _turn_collision_mesh( + name: str, pivot_x: float, center_y: float, start_angle: float, angle_span: float = math.pi +) -> MeshSpec: + """Build a closed annular turn with a +Z top surface and an angular span [rad].""" + half_width = 0.5 * BELT_WIDTH + BELT_COLLISION_OVERHANG + inner_radius = BELT_TURN_RADIUS - half_width + outer_radius = BELT_TURN_RADIUS + half_width + angle_overlap = BELT_COLLISION_SEAM_OVERLAP / BELT_TURN_RADIUS + angle_start = start_angle - angle_overlap + segment_count = round(TURN_SEGMENT_COUNT * angle_span / math.pi) + angle_step = (angle_span + 2.0 * angle_overlap) / segment_count + angles = tuple(angle_start + index * angle_step for index in range(segment_count + 1)) + + inner_top = tuple( + (pivot_x + inner_radius * math.cos(angle), center_y + inner_radius * math.sin(angle), BELT_TOP_Z) + for angle in angles + ) + outer_top = tuple( + (pivot_x + outer_radius * math.cos(angle), center_y + outer_radius * math.sin(angle), BELT_TOP_Z) + for angle in angles + ) + bottom_z = BELT_TOP_Z - BELT_THICKNESS + inner_bottom = tuple((x, y, bottom_z) for x, y, _ in inner_top) + outer_bottom = tuple((x, y, bottom_z) for x, y, _ in outer_top) + + count = len(inner_top) + outer_top_offset = count + inner_bottom_offset = 2 * count + outer_bottom_offset = 3 * count + faces: list[tuple[int, int, int]] = [] + for index in range(segment_count): + next_index = index + 1 + inner_top_i = index + inner_top_j = next_index + outer_top_i = outer_top_offset + index + outer_top_j = outer_top_offset + next_index + inner_bottom_i = inner_bottom_offset + index + inner_bottom_j = inner_bottom_offset + next_index + outer_bottom_i = outer_bottom_offset + index + outer_bottom_j = outer_bottom_offset + next_index + faces.extend( + ( + # Top, bottom, outer wall, and inner wall. + (inner_top_i, outer_top_i, outer_top_j), + (inner_top_i, outer_top_j, inner_top_j), + (inner_bottom_i, inner_bottom_j, outer_bottom_j), + (inner_bottom_i, outer_bottom_j, outer_bottom_i), + (outer_bottom_i, outer_bottom_j, outer_top_j), + (outer_bottom_i, outer_top_j, outer_top_i), + (inner_bottom_i, inner_top_i, inner_top_j), + (inner_bottom_i, inner_top_j, inner_bottom_j), + ) + ) + + # Close both radial ends of the annular prism. + end = segment_count + faces.extend( + ( + (0, inner_bottom_offset, outer_bottom_offset), + (0, outer_bottom_offset, outer_top_offset), + (end, outer_top_offset + end, outer_bottom_offset + end), + (end, outer_bottom_offset + end, inner_bottom_offset + end), + ) + ) + return MeshSpec( + name=name, + vertices=inner_top + outer_top + inner_bottom + outer_bottom, + faces=tuple(faces), + ) + + +def belt_collision_geometry_specs(side: str) -> tuple[CuboidSpec | MeshSpec, ...]: + """Build native straight and closed-mesh turn collision geometry for one racetrack.""" + center_y = BELT_CENTER_Y if side == "Left" else -BELT_CENTER_Y + left_x = BELT_CENTER_X - BELT_HALF_STRAIGHT + right_x = BELT_CENTER_X + BELT_HALF_STRAIGHT + return ( + _straight_collision_cuboid(f"Conveyor{side}TopStraightCollision", center_y + BELT_TURN_RADIUS), + _straight_collision_cuboid(f"Conveyor{side}BottomStraightCollision", center_y - BELT_TURN_RADIUS), + _turn_collision_mesh(f"Conveyor{side}RightTurnCollision", right_x, center_y, -0.5 * math.pi), + _turn_collision_mesh(f"Conveyor{side}LeftTurnCollision", left_x, center_y, 0.5 * math.pi), + ) + + +def belt_collision_section_specs( + side: str, + *, + velocity: float = 0.0, + friction_coefficient: float = 0.7, + contact_threshold: float = 0.997, + enabled: bool = True, +) -> tuple[ConveyorSectionSpec, ConveyorSectionSpec, ConveyorSectionSpec, ConveyorSectionSpec]: + """Build collision geometry and authored conveyor intent for one racetrack.""" + center_y = BELT_CENTER_Y if side == "Left" else -BELT_CENTER_Y + left_x = BELT_CENTER_X - BELT_HALF_STRAIGHT + right_x = BELT_CENTER_X + BELT_HALF_STRAIGHT + direction = belt_direction(side) + top_straight, bottom_straight, right_turn, left_turn = belt_collision_geometry_specs(side) + + def belt( + geometry: MeshSpec | CuboidSpec, + travel_direction: tuple[float, float, float], + *, + curved: bool = False, + pivot_point: tuple[float, float, float] = (0.0, 0.0, 0.0), + radius: float | None = None, + ) -> ConveyorSectionSpec: + return ConveyorSectionSpec( + geometry=geometry, + belt=SurfaceVelocitySpec( + prim_path=f"{{ENV_REGEX_NS}}/{geometry.name}", + velocity=velocity, + enabled=enabled, + direction=travel_direction, + curved=curved, + pivot_point=pivot_point, + radius=radius, + contact_threshold=contact_threshold, + friction_coefficient=friction_coefficient, + ), + ) + + return ( + belt(top_straight, (direction, 0.0, 0.0)), + belt(bottom_straight, (-direction, 0.0, 0.0)), + belt( + right_turn, + (0.0, 0.0, -direction), + curved=True, + pivot_point=(right_x, center_y, 0.0), + radius=BELT_TURN_RADIUS, + ), + belt( + left_turn, + (0.0, 0.0, -direction), + curved=True, + pivot_point=(left_x, center_y, 0.0), + radius=BELT_TURN_RADIUS, + ), + ) + + +def guard_mesh_specs(side: str) -> tuple[MeshSpec, MeshSpec]: + """Build seamless inner and outer guardrail meshes for one racetrack.""" + center_y = BELT_CENTER_Y if side == "Left" else -BELT_CENTER_Y + rail_offset = 0.5 * (BELT_WIDTH + GUARD_THICKNESS) + z_min = BELT_TOP_Z - GUARD_BASE_OVERLAP + z_max = BELT_TOP_Z + GUARD_HEIGHT + return ( + _racetrack_prism_mesh( + name=f"Guard{side}Inner", + center_y=center_y, + lateral_offset=-rail_offset, + width=GUARD_THICKNESS, + z_min=z_min, + z_max=z_max, + ), + _racetrack_prism_mesh( + name=f"Guard{side}Outer", + center_y=center_y, + lateral_offset=rail_offset, + width=GUARD_THICKNESS, + z_min=z_min, + z_max=z_max, + ), + ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_goal_selector.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_goal_selector.py new file mode 100644 index 000000000000..853580a8a95e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_goal_selector.py @@ -0,0 +1,105 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Minimal Newton-viewer selector for conveyor transfer goals.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any + +from .conveyor_geometry import CUBE_COLORS +from .mdp.reset_events import LEFT_SIDE + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + + +def _mix_color( + color: tuple[float, float, float], + target: tuple[float, float, float], + amount: float, +) -> tuple[float, float, float]: + """Linearly mix two RGB colors.""" + return tuple(value + amount * (target_value - value) for value, target_value in zip(color, target, strict=True)) + + +class ConveyorGoalSelector: + """Render four color-matched cube buttons for one displayed environment.""" + + def __init__(self, env: ManagerBasedRLEnv, env_id: int) -> None: + """Create a selector bound to one vectorized environment index.""" + if not 0 <= env_id < env.num_envs: + raise IndexError(f"Conveyor selector environment {env_id} is out of range.") + self._env = env + self._env_id = env_id + self._command = env.command_manager.get_term("transfer") + self._target_cube_id = 0 + self._source_side_id = LEFT_SIDE + self._last_refresh_time = float("-inf") + + def render(self, imgui: Any) -> None: + """Draw the selector and publish a new transfer command when clicked.""" + imgui.set_next_item_open(True, imgui.Cond_.appearing) + if not imgui.collapsing_header(f"Transfer Goal · Env {self._env_id}"): + return + imgui.separator() + + if not self._refresh_command(): + imgui.text_disabled("Waiting for transfer state...") + return + + style = imgui.get_style() + available_width = float(imgui.get_content_region_avail().x) + spacing = float(style.item_spacing.x) + button_width = max(36.0, (available_width - spacing * (len(CUBE_COLORS) - 1)) / len(CUBE_COLORS)) + button_height = max(34.0, min(46.0, button_width * 0.72)) + + selected_cube_id: int | None = None + for cube_id, color in enumerate(CUBE_COLORS): + selected = cube_id == self._target_cube_id + text_color = (0.05, 0.05, 0.05) if sum(color) > 1.45 else (1.0, 1.0, 1.0) + border_color = (1.0, 0.88, 0.20) if selected else (0.12, 0.12, 0.12) + label = f"{cube_id + 1}##conveyor_goal_{cube_id}" + + imgui.push_style_color(imgui.Col_.button, imgui.ImVec4(*color, 1.0)) + imgui.push_style_color( + imgui.Col_.button_hovered, + imgui.ImVec4(*_mix_color(color, (1.0, 1.0, 1.0), 0.18), 1.0), + ) + imgui.push_style_color( + imgui.Col_.button_active, + imgui.ImVec4(*_mix_color(color, (0.0, 0.0, 0.0), 0.12), 1.0), + ) + imgui.push_style_color(imgui.Col_.border, imgui.ImVec4(*border_color, 1.0)) + imgui.push_style_color(imgui.Col_.text, imgui.ImVec4(*text_color, 1.0)) + imgui.push_style_var(imgui.StyleVar_.frame_border_size, 3.0 if selected else 1.0) + imgui.push_style_var(imgui.StyleVar_.frame_rounding, 5.0) + + if imgui.button(label, imgui.ImVec2(button_width, button_height)): + selected_cube_id = cube_id + + imgui.pop_style_var(2) + imgui.pop_style_color(5) + if cube_id + 1 < len(CUBE_COLORS): + imgui.same_line() + + if selected_cube_id is not None and selected_cube_id != self._target_cube_id: + self._command.set_goal(selected_cube_id, env_ids=(self._env_id,)) + self._refresh_command(force=True) + + source_name = "Left" if self._source_side_id == LEFT_SIDE else "Right" + target_name = "Right" if self._source_side_id == LEFT_SIDE else "Left" + imgui.text(f"Cube {self._target_cube_id + 1}: {source_name} -> {target_name}") + imgui.text_disabled("Click a color to change the next transfer.") + + def _refresh_command(self, force: bool = False) -> bool: + """Refresh the small host-side UI cache at most ten times per second.""" + current_time = time.monotonic() + if force or current_time - self._last_refresh_time >= 0.1: + self._target_cube_id = int(self._command.target_cube_ids[self._env_id].item()) + self._source_side_id = int(self._command.source_side_ids[self._env_id].item()) + self._last_refresh_time = current_time + return True diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/evaluate.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/evaluate.py new file mode 100644 index 000000000000..eb9d5abbfb98 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/evaluate.py @@ -0,0 +1,106 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Evaluate a conveyor checkpoint from reproducible, full moving-belt starts.""" + +from __future__ import annotations + +import argparse +import json +from contextlib import closing +from pathlib import Path + +import gymnasium as gym +import torch +from rsl_rl.runners import OnPolicyRunner + +from isaaclab.app import add_launcher_args, launch_simulation +from isaaclab.utils import to_dict +from isaaclab.utils.assets import retrieve_file_path + +from isaaclab_rl.entrypoints.common import resolve_published_checkpoint +from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.utils import load_cfg_from_registry, parse_env_cfg + +_TASK = "IsaacContrib-Conveyor-Racetrack-Transfer-v0" + + +def main() -> None: + """Run a bounded evaluation and report transfers and failures instead of training reward.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--checkpoint", default="pretrained") + parser.add_argument("--num_envs", type=int, default=8) + parser.add_argument("--steps", type=int, default=3600) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--output", type=Path) + add_launcher_args(parser) + parser.set_defaults(device="cpu", headless=True) + args = parser.parse_args() + if args.num_envs < 1 or args.steps < 1: + parser.error("--num_envs and --steps must be positive.") + cfg = parse_env_cfg(_TASK, device=args.device, num_envs=args.num_envs) + cfg.play_mode() + cfg.scene.num_envs = args.num_envs + cfg.seed = args.seed + cfg.events.reset_from_state_table.params["arm_joint_noise"] = 0.0 + cfg.sim.use_fabric = False + cfg.sim.save_logs_to_file = False + cfg.sim.physics.use_cuda_graph = torch.device(args.device).type == "cuda" + agent = load_cfg_from_registry(_TASK, "rsl_rl_cfg_entry_point") + agent.device = args.device + checkpoint = ( + resolve_published_checkpoint("rsl_rl", _TASK, cfg) + if args.checkpoint == "pretrained" + else retrieve_file_path(args.checkpoint) + ) + if checkpoint is None: + raise FileNotFoundError("No published conveyor checkpoint was found; pass --checkpoint /path/to/model.pt.") + with launch_simulation(cfg, args), closing(gym.make(_TASK, cfg=cfg).unwrapped) as env: + wrapped = RslRlVecEnvWrapper(env, clip_actions=agent.clip_actions) + runner = OnPolicyRunner(wrapped, to_dict(agent), device=agent.device) + runner.load(checkpoint, map_location=args.device) + policy = runner.get_inference_policy(device=args.device) + env.reset(seed=args.seed) + observations = wrapped.get_observations() + transfers = torch.zeros(2, device=env.device) + failures = 0 + nonfinite_observations = 0 + with torch.inference_mode(): + for _ in range(args.steps): + observations, _, dones, info = wrapped.step(policy(observations)) + policy.reset(dones) + ended = int(dones.sum()) + failures += ended + nonfinite_observations += int((~torch.isfinite(observations["policy"])).any(dim=1).sum()) + if ended: + for side, direction in enumerate(("left_to_right", "right_to_left")): + transfers[side] += info["log"][f"Metrics/transfer/{direction}_transfers"] * ended + transfers += env.command_manager.get_term("transfer").direction_transfer_counts.sum(dim=0) + seconds = args.steps * env.step_dt * args.num_envs + result = { + "task": _TASK, + "checkpoint": checkpoint, + "seed": args.seed, + "num_envs": args.num_envs, + "steps": args.steps, + "simulated_environment_seconds": seconds, + "completed_transfers": int(transfers.sum().round()), + "left_to_right_transfers": int(transfers[0].round()), + "right_to_left_transfers": int(transfers[1].round()), + "failures": failures, + "nonfinite_observations": nonfinite_observations, + "transfers_per_environment_minute": float(transfers.sum()) * 60.0 / seconds, + } + report = json.dumps(result, indent=2) + print(report) + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(report + "\n") + + +if __name__ == "__main__": + main() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py new file mode 100644 index 000000000000..12a0adba8338 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py @@ -0,0 +1,69 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Task-calibrated Franka configuration for conveyor manipulation.""" + +from isaaclab_newton.sim.schemas import MujocoJointCfg +from isaaclab_physx.sim.schemas import PhysxArticulationCfg + +from isaaclab.actuators import ImplicitActuatorCfg + +from isaaclab_assets.robots.franka import FRANKA_PANDA_MENAGERIE_CFG + +FRANKA_PANDA_CONVEYOR_CFG = FRANKA_PANDA_MENAGERIE_CFG.copy() +FRANKA_PANDA_CONVEYOR_CFG.spawn.rigid_props.disable_gravity = False +# Route gravity compensation through MuJoCo's actuator channel so effort limits +# and the solver apply it consistently with the configured implicit drives. +FRANKA_PANDA_CONVEYOR_CFG.spawn.joint_drive_props = [MujocoJointCfg(actuatorgravcomp=True)] +FRANKA_PANDA_CONVEYOR_CFG.actuators = { + "panda_arm": ImplicitActuatorCfg( + joint_names_expr=["panda_joint[1-7]"], + effort_limit_sim={"panda_joint[1-4]": 87.0, "panda_joint[5-7]": 12.0}, + velocity_limit_sim={"panda_joint[1-4]": 20.0, "panda_joint[5-7]": 25.0}, + stiffness={ + "panda_joint[1-4]": 600.0, + "panda_joint5": 250.0, + "panda_joint6": 150.0, + "panda_joint7": 50.0, + }, + damping={ + "panda_joint[1-4]": 50.0, + "panda_joint5": 30.0, + "panda_joint6": 25.0, + "panda_joint7": 15.0, + }, + armature={ + "panda_joint[1-2]": 0.6057, + "panda_joint[3-4]": 0.4625, + "panda_joint[5-7]": 0.2055, + }, + ), + "panda_hand": ImplicitActuatorCfg( + joint_names_expr=["panda_finger_joint[1-2]"], + effort_limit_sim=70.0, + velocity_limit_sim=2.0, + stiffness=350.0, + # Keep the 0.1 kg m^2 armature close to critical damping so the + # fingers establish contact within a few 50 Hz policy steps. + damping=10.0, + armature=0.1, + ), +} +"""Menagerie Franka with explicit manipulation gains and solver-native gravity compensation.""" + + +FRANKA_PANDA_CONVEYOR_PHYSX_CFG = FRANKA_PANDA_CONVEYOR_CFG.copy() +# PhysX does not consume MuJoCo's actuator-gravity-compensation attribute. Disabling +# gravity on the robot is the closest solver-native equivalent and keeps the trained +# position-policy contract unchanged without adding a task-side effort loop. +FRANKA_PANDA_CONVEYOR_PHYSX_CFG.spawn.rigid_props.disable_gravity = True +FRANKA_PANDA_CONVEYOR_PHYSX_CFG.spawn.joint_drive_props = None +# Contact-rich manipulation benefits from resolving the articulation for more than +# the generic asset defaults, especially with the deliberately stiff trained gains. +for _properties in FRANKA_PANDA_CONVEYOR_PHYSX_CFG.spawn.articulation_props: + if isinstance(_properties, PhysxArticulationCfg): + _properties.solver_position_iteration_count = 32 + _properties.solver_velocity_iteration_count = 4 +"""PhysX variant with the same joints, gains, action ordering, and gravity-compensated policy contract.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.py new file mode 100644 index 000000000000..56b9c4494587 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.py @@ -0,0 +1,10 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""MDP terms for the conveyor-to-conveyor Franka transfer task.""" + +from isaaclab.utils.module import lazy_export + +lazy_export() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi new file mode 100644 index 000000000000..5bd90e830c86 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi @@ -0,0 +1,79 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +__all__ = [ + "BELT_DEPLOYMENT_VARIANT", + "ConveyorRelativeJointPositionAction", + "ConveyorRelativeJointPositionActionCfg", + "ConveyorResetCurriculum", + "ConveyorResetRecipe", + "ConveyorResetStateTable", + "ConveyorTransferCommand", + "ConveyorTransferCommandCfg", + "ResetBufferedGripperAction", + "ResetBufferedGripperActionCfg", + "SuccessMonitorCfg", + "action_term_l2", + "active_transfer_features", + "build_reset_rows", + "cube_conveyor_state", + "cube_out_of_workspace", + "end_effector_axes", + "end_effector_velocity", + "finite_action_rate_l2", + "finite_joint_velocity_l2", + "gripper_joint_positions", + "invalid_action", + "nonfinite_scene_state", + "physical_cube_acquisition_mask", + "select_next_transfer_cube", + "subgoal_time_out", + "target_cube_one_hot", + "target_side_one_hot", + "terminal_failure", + "transfer_object_observation", + "transfer_sequence_time_out", + "transfer_success_mask", + "transfer_success_reward", +] + +from .actions import ConveyorRelativeJointPositionAction, ResetBufferedGripperAction +from .actions_cfg import ConveyorRelativeJointPositionActionCfg, ResetBufferedGripperActionCfg +from .commands import ConveyorTransferCommand, ConveyorTransferCommandCfg, transfer_success_mask +from .curriculums import ConveyorResetCurriculum +from .observations import ( + active_transfer_features, + cube_conveyor_state, + end_effector_axes, + end_effector_velocity, + gripper_joint_positions, + target_cube_one_hot, + target_side_one_hot, + transfer_object_observation, +) +from .reset_events import ( + BELT_DEPLOYMENT_VARIANT, + ConveyorResetRecipe, + ConveyorResetStateTable, + build_reset_rows, + select_next_transfer_cube, +) +from .rewards import ( + action_term_l2, + finite_action_rate_l2, + finite_joint_velocity_l2, + physical_cube_acquisition_mask, + terminal_failure, + transfer_success_reward, +) +from .terminations import ( + cube_out_of_workspace, + invalid_action, + nonfinite_scene_state, + subgoal_time_out, + transfer_sequence_time_out, +) +from isaaclab.envs.mdp import * +from isaaclab_tasks.utils.success_monitor import SuccessMonitorCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py new file mode 100644 index 000000000000..bdadbac2b7b7 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py @@ -0,0 +1,135 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Reset-safe relative joint actions for conveyor transfer.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.envs.mdp.actions.binary_joint_actions import BinaryJointPositionAction +from isaaclab.envs.mdp.actions.joint_actions import JointAction + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedEnv + + from .actions_cfg import ConveyorRelativeJointPositionActionCfg, ResetBufferedGripperActionCfg + + +class ConveyorRelativeJointPositionAction(JointAction): + """Apply one measured-state-relative target per policy step. + + Isaac Lab's generic relative action adds the residual during every physics + substep. This term computes the target once in :meth:`process_actions`, so + simulation decimation does not multiply the requested displacement. + """ + + cfg: ConveyorRelativeJointPositionActionCfg + + def __init__(self, cfg: ConveyorRelativeJointPositionActionCfg, env: ManagerBasedEnv) -> None: + super().__init__(cfg, env) + if not math.isfinite(cfg.max_delta) or cfg.max_delta <= 0.0: + raise ValueError("max_delta must be finite and positive.") + if not math.isfinite(cfg.joint_limit_margin) or cfg.joint_limit_margin < 0.0: + raise ValueError("joint_limit_margin must be finite and non-negative.") + self._workspace_lower = torch.tensor(cfg.workspace_lower, dtype=torch.float32, device=self.device) + self._workspace_upper = torch.tensor(cfg.workspace_upper, dtype=torch.float32, device=self.device) + if self._workspace_lower.shape != (self.action_dim,) or self._workspace_upper.shape != (self.action_dim,): + raise ValueError("workspace bounds must contain one value per controlled joint.") + if torch.any(self._workspace_lower >= self._workspace_upper): + raise ValueError("Every lower workspace bound must be less than its upper bound.") + if not torch.all(torch.isfinite(self._workspace_lower)) or not torch.all(torch.isfinite(self._workspace_upper)): + raise ValueError("workspace bounds must be finite.") + self._previous_actions = torch.zeros_like(self._raw_actions) + self._invalid_actions = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device) + self._position_targets = self._asset.data.joint_pos.torch[:, self._joint_ids].clone() + + @property + def previous_actions(self) -> torch.Tensor: + """Previous finite policy actions, shape ``(num_envs, action_dim)``.""" + return self._previous_actions + + @property + def invalid_actions(self) -> torch.Tensor: + """Whether the latest policy action contained a non-finite component.""" + return self._invalid_actions + + def process_actions(self, actions: torch.Tensor) -> None: + """Convert normalized residuals into bounded position targets [rad].""" + self._previous_actions.copy_(self._raw_actions) + self._invalid_actions.copy_(~torch.isfinite(actions).all(dim=1)) + finite_actions = torch.nan_to_num(actions, nan=0.0, posinf=1.0, neginf=-1.0).clamp(-1.0, 1.0) + super().process_actions(finite_actions) + delta = torch.clamp(self._processed_actions, min=-self.cfg.max_delta, max=self.cfg.max_delta) + positions = self._asset.data.joint_pos.torch[:, self._joint_ids] + limits = self._asset.data.soft_joint_pos_limits.torch[:, self._joint_ids] + lower = torch.maximum(limits[..., 0] + self.cfg.joint_limit_margin, self._workspace_lower) + upper = torch.minimum(limits[..., 1] - self.cfg.joint_limit_margin, self._workspace_upper) + self._position_targets = torch.clamp(positions + delta, min=lower, max=upper) + self._processed_actions = self._position_targets + + def apply_actions(self) -> None: + """Hold the policy-step target through all physics substeps.""" + self._asset.set_joint_position_target_index(target=self._position_targets, joint_ids=self._joint_ids) + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Initialize targets from the sampled reset pose.""" + super().reset(env_ids) + positions = self._asset.data.joint_pos.torch[:, self._joint_ids] + if env_ids is None: + self._position_targets[:] = positions + self._processed_actions[:] = positions + else: + self._position_targets[env_ids] = positions[env_ids] + self._processed_actions[env_ids] = positions[env_ids] + self._previous_actions[env_ids] = 0.0 + self._invalid_actions[env_ids] = False + + +class ResetBufferedGripperAction(BinaryJointPositionAction): + """Keep reset-authored grasps closed during a short settling window.""" + + cfg: ResetBufferedGripperActionCfg + + def __init__(self, cfg: ResetBufferedGripperActionCfg, env: ManagerBasedEnv) -> None: + super().__init__(cfg, env) + self._previous_actions = torch.zeros_like(self._raw_actions) + self._invalid_actions = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device) + + @property + def previous_actions(self) -> torch.Tensor: + """Previous finite policy actions, shape ``(num_envs, action_dim)``.""" + return self._previous_actions + + @property + def invalid_actions(self) -> torch.Tensor: + """Whether the latest gripper command contained a non-finite value.""" + return self._invalid_actions + + def process_actions(self, actions: torch.Tensor) -> None: + """Map finite binary commands and preserve initially held cubes.""" + self._previous_actions.copy_(self._raw_actions) + if actions.dtype == torch.bool: + self._invalid_actions.zero_() + finite_actions = actions + else: + self._invalid_actions.copy_(~torch.isfinite(actions).all(dim=1)) + # A non-finite gripper command closes the fingers for the final safe + # step before the invalid-action termination is evaluated. + finite_actions = torch.nan_to_num(actions, nan=-1.0, posinf=1.0, neginf=-1.0).clamp(-1.0, 1.0) + super().process_actions(finite_actions) + command = self._env.command_manager.get_term(self.cfg.command_name) + force_close = (command.held_cube_ids >= 0) & (self._env.episode_length_buf < self.cfg.force_close_steps) + self._processed_actions[force_close] = self._close_command + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Clear buffered invalid-command state for selected environments.""" + super().reset(env_ids) + self._previous_actions[env_ids] = 0.0 + self._invalid_actions[env_ids] = False diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions_cfg.py new file mode 100644 index 000000000000..40bc923f779e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions_cfg.py @@ -0,0 +1,48 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Action configurations for conveyor transfer.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from isaaclab.envs.mdp.actions.actions_cfg import BinaryJointPositionActionCfg, JointActionCfg +from isaaclab.utils.configclass import configclass + +if TYPE_CHECKING: + from .actions import ConveyorRelativeJointPositionAction, ResetBufferedGripperAction + + +@configclass +class ConveyorRelativeJointPositionActionCfg(JointActionCfg): + """Configuration for measured-state relative Franka joint control.""" + + class_type: type[ConveyorRelativeJointPositionAction] | str = "{DIR}.actions:ConveyorRelativeJointPositionAction" + + joint_limit_margin: float = 0.02 + """Distance kept from each soft joint limit [rad].""" + + max_delta: float = 0.12 + """Maximum target change per policy step [rad].""" + + workspace_lower: tuple[float, ...] = (-0.75, -0.45, -0.55, -2.75, -0.45, 1.85, -0.10) + """Lower boundary of the validated transfer workspace [rad].""" + + workspace_upper: tuple[float, ...] = (0.85, 0.85, 0.35, -1.75, 0.45, 3.05, 1.65) + """Upper boundary of the validated transfer workspace [rad].""" + + +@configclass +class ResetBufferedGripperActionCfg(BinaryJointPositionActionCfg): + """Configuration for reset-grasp protection.""" + + class_type: type[ResetBufferedGripperAction] | str = "{DIR}.actions:ResetBufferedGripperAction" + + force_close_steps: int = 5 + """Initial policy steps that preserve a reset-authored grasp.""" + + command_name: str = "transfer" + """Command term that owns reset-authored held-cube state.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py new file mode 100644 index 000000000000..8185334767e1 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py @@ -0,0 +1,345 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Success-driven transfer commands for the conveyor Franka task.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import CommandTerm, CommandTermCfg +from isaaclab.utils.configclass import configclass + +from ..conveyor_geometry import BELT_CENTER_X, BELT_HALF_STRAIGHT +from .cube_state import cube_values +from .kinematics import end_effector_pose +from .reset_events import CUBE_COUNT, ConveyorResetRecipe, select_next_transfer_cube, side_inner_y +from .rewards import current_transfer_potential, physical_cube_acquisition_mask + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + +def transfer_success_mask( + cube_positions: torch.Tensor, + cube_linear_velocities: torch.Tensor, + tool_positions: torch.Tensor, + finger_positions: torch.Tensor, + target_side_ids: torch.Tensor, + lateral_tolerance: float = 0.055, + maximum_cube_speed: float = 0.65, + minimum_finger_position: float = 0.027, + minimum_tool_clearance: float = 0.055, +) -> torch.Tensor: + """Return whether the active cube is released on its destination belt.""" + target_y = side_inner_y(target_side_ids) + on_straight = torch.abs(cube_positions[:, 0] - BELT_CENTER_X) < BELT_HALF_STRAIGHT + on_lane = torch.abs(cube_positions[:, 1] - target_y) < lateral_tolerance + supported_height = (cube_positions[:, 2] > 0.045) & (cube_positions[:, 2] < 0.095) + moving_safely = torch.linalg.vector_norm(cube_linear_velocities, dim=1) < maximum_cube_speed + released = torch.amin(finger_positions, dim=1) > minimum_finger_position + hand_clear = torch.linalg.vector_norm(tool_positions - cube_positions, dim=1) > minimum_tool_clearance + return on_straight & on_lane & supported_height & moving_safely & released & hand_clear + + +class ConveyorTransferCommand(CommandTerm): + """Command one numbered cube to the opposite belt and redraw on success.""" + + cfg: ConveyorTransferCommandCfg + + def __init__(self, cfg: ConveyorTransferCommandCfg, env: ManagerBasedRLEnv) -> None: + self._validate_cfg(cfg) + super().__init__(cfg, env) + + reset_term = env.event_manager.get_term_cfg(cfg.reset_event_name).func + required_reset_fields = ("row_ids", "recipe_ids", "target_cube_ids", "source_side_ids", "held_rows") + if not all(hasattr(reset_term, name) for name in required_reset_fields): + raise RuntimeError("ConveyorTransferCommand requires ConveyorResetStateTable reset metadata.") + self._reset_term = reset_term + self._robot: Articulation = env.scene["robot"] + self._finger_joint_ids = self._robot.find_joints("panda_finger_joint[1-2]", preserve_order=True)[0] + + self.target_cube_ids = torch.zeros(self.num_envs, dtype=torch.long, device=self.device) + self.source_side_ids = torch.zeros_like(self.target_cube_ids) + self.recipe_ids = torch.zeros_like(self.target_cube_ids) + self.held_cube_ids = torch.full_like(self.target_cube_ids, -1) + self.subgoal_start_steps = torch.zeros_like(self.target_cube_ids) + self.transfer_counts = torch.zeros_like(self.target_cube_ids) + self.direction_transfer_counts = torch.zeros((self.num_envs, 2), dtype=torch.long, device=self.device) + + self._stable_steps = torch.zeros_like(self.target_cube_ids) + self.is_success = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device) + self.new_success = torch.zeros_like(self.is_success) + self.pending_success = torch.zeros_like(self.is_success) + self.ever_success = torch.zeros_like(self.is_success) + self.progress_ever_success = torch.zeros_like(self.is_success) + self._target_potential = torch.zeros(self.num_envs, dtype=torch.float32, device=self.device) + self._last_evaluation_steps = torch.full_like(self.target_cube_ids, -1) + self._resampling_from_reset = False + + self.metrics["success_rate"] = torch.zeros(self.num_envs, dtype=torch.float32, device=self.device) + self.metrics["transfer_count"] = torch.zeros_like(self.metrics["success_rate"]) + self.metrics["left_to_right_transfers"] = torch.zeros_like(self.metrics["success_rate"]) + self.metrics["right_to_left_transfers"] = torch.zeros_like(self.metrics["success_rate"]) + + @staticmethod + def _validate_cfg(cfg: ConveyorTransferCommandCfg) -> None: + """Validate success and reset-progress thresholds.""" + if cfg.minimum_subgoal_steps < 0 or cfg.hold_steps < 1: + raise ValueError("minimum_subgoal_steps must be non-negative and hold_steps must be positive.") + if cfg.lateral_tolerance <= 0.0 or cfg.maximum_cube_speed <= 0.0: + raise ValueError("Conveyor placement tolerances and speed limits must be positive.") + if cfg.minimum_finger_position <= 0.0 or cfg.minimum_tool_clearance <= 0.0: + raise ValueError("Conveyor release thresholds must be positive.") + if cfg.minimum_progress_steps < 0 or cfg.minimum_progress <= 0.0 or cfg.maximum_target_potential <= 0.0: + raise ValueError("Conveyor reset-progress thresholds are invalid.") + if ( + cfg.minimum_acquisition_lift <= 0.0 + or cfg.maximum_acquisition_tool_distance <= 0.0 + or cfg.maximum_acquisition_finger_position <= 0.0 + ): + raise ValueError("Conveyor acquisition thresholds must be positive.") + + @property + def command(self) -> torch.Tensor: + """Return target-cube and destination-belt one-hot commands.""" + cube = torch.nn.functional.one_hot(self.target_cube_ids, num_classes=CUBE_COUNT) + destination = torch.nn.functional.one_hot(1 - self.source_side_ids, num_classes=2) + return torch.cat((cube, destination), dim=1).float() + + def reset(self, env_ids: Sequence[int] | slice | None = None) -> dict[str, float]: + """Log per-command completion and initialize commands from sampled reset rows.""" + ids = self._resolve_env_ids(env_ids) + valid = self.command_counter[ids] > 0 + attempts = self.command_counter[ids].clamp_min(1) + self.metrics["success_rate"][ids] = torch.where( + valid, + self.transfer_counts[ids].float() / attempts.float(), + 0.0, + ) + self.metrics["transfer_count"][ids] = self.transfer_counts[ids].float() + self.metrics["left_to_right_transfers"][ids] = self.direction_transfer_counts[ids, 0].float() + self.metrics["right_to_left_transfers"][ids] = self.direction_transfer_counts[ids, 1].float() + + self._resampling_from_reset = True + try: + extras = super().reset(ids) + finally: + self._resampling_from_reset = False + + initial_potential = current_transfer_potential(self._env, command=self) + self._target_potential[ids] = torch.clamp_max( + initial_potential[ids] + self.cfg.minimum_progress, + self.cfg.maximum_target_potential, + ) + self._last_evaluation_steps[ids] = -1 + self._env.extras.setdefault("log", {})["Metrics/success_rate"] = extras.pop("success_rate") + return extras + + def evaluate(self) -> None: + """Evaluate stable completion and reset-learning progress once per policy step.""" + evaluation_steps = self._env.episode_length_buf + evaluate_mask = (self.command_counter > 0) & (self._last_evaluation_steps != evaluation_steps) + if not bool(torch.any(evaluate_mask)): + return + + positions = cube_values(self._env, "root_pos_w") + velocities = cube_values(self._env, "root_lin_vel_w") + index = self.target_cube_ids.view(self.num_envs, 1, 1).expand(-1, 1, 3) + active_position = torch.gather(positions, 1, index).squeeze(1) - self._env.scene.env_origins + active_velocity = torch.gather(velocities, 1, index).squeeze(1) + tool_position, _ = end_effector_pose(self._env) + tool_position = tool_position - self._env.scene.env_origins + finger_positions = self._robot.data.joint_pos.torch[:, self._finger_joint_ids] + successful = transfer_success_mask( + active_position, + active_velocity, + tool_position, + finger_positions, + 1 - self.source_side_ids, + lateral_tolerance=self.cfg.lateral_tolerance, + maximum_cube_speed=self.cfg.maximum_cube_speed, + minimum_finger_position=self.cfg.minimum_finger_position, + minimum_tool_clearance=self.cfg.minimum_tool_clearance, + ) + subgoal_steps = evaluation_steps - self.subgoal_start_steps + successful &= subgoal_steps >= self.cfg.minimum_subgoal_steps + + next_stable_steps = torch.where(successful, self._stable_steps + 1, torch.zeros_like(self._stable_steps)) + self._stable_steps[evaluate_mask] = next_stable_steps[evaluate_mask] + stable = self._stable_steps >= self.cfg.hold_steps + new_success = evaluate_mask & stable & ~self.is_success & ~self.pending_success + self.is_success[evaluate_mask] = stable[evaluate_mask] + self.new_success.copy_(new_success) + self.pending_success |= new_success + self.ever_success |= new_success + success_ids = new_success.nonzero(as_tuple=False).squeeze(-1) + if success_ids.numel(): + source_sides = self.source_side_ids[success_ids] + self.transfer_counts[success_ids] += 1 + self.direction_transfer_counts[success_ids, source_sides] += 1 + + potential = current_transfer_potential(self._env, command=self) + progressed = (potential >= self._target_potential) & (evaluation_steps >= self.cfg.minimum_progress_steps) + acquisition_recipe = ( + (self.recipe_ids == int(ConveyorResetRecipe.GRASP)) + | (self.recipe_ids == int(ConveyorResetRecipe.PREGRASP)) + | (self.recipe_ids == int(ConveyorResetRecipe.BELT)) + ) + physically_acquired = physical_cube_acquisition_mask( + self._env, + command=self, + minimum_lift=self.cfg.minimum_acquisition_lift, + maximum_tool_distance=self.cfg.maximum_acquisition_tool_distance, + maximum_finger_position=self.cfg.maximum_acquisition_finger_position, + ) + progressed &= ~acquisition_recipe | physically_acquired + self.progress_ever_success |= evaluate_mask & progressed + self._last_evaluation_steps[evaluate_mask] = evaluation_steps[evaluate_mask] + self._env.extras["successes"] = self.ever_success.clone() + + def set_goal(self, target_cube_id: int, env_ids: Sequence[int] | torch.Tensor | None = None) -> None: + """Replace the active command using the selected cube's current conveyor.""" + if not isinstance(target_cube_id, int) or isinstance(target_cube_id, bool): + raise TypeError("target_cube_id must be an integer.") + if not 0 <= target_cube_id < CUBE_COUNT: + raise ValueError(f"target_cube_id must lie in [0, {CUBE_COUNT - 1}].") + ids = self._resolve_env_ids(env_ids) + if ids.numel() == 0: + return + local_y = cube_values(self._env, "root_pos_w")[ids, target_cube_id, 1] - self._env.scene.env_origins[ids, 1] + source_side_ids = (local_y < 0.0).long() + target_cube_ids = torch.full_like(ids, target_cube_id) + self._assign_goal(ids, target_cube_ids, source_side_ids) + + def _update_metrics(self) -> None: + self.evaluate() + + def _resample_command(self, env_ids: Sequence[int]) -> None: + ids = self._resolve_env_ids(env_ids) + if ids.numel() == 0: + return + if self._resampling_from_reset: + rows = self._reset_term.row_ids[ids] + target_cube_ids = self._reset_term.target_cube_ids[rows] + source_side_ids = self._reset_term.source_side_ids[rows] + self.recipe_ids[ids] = self._reset_term.recipe_ids[rows] + held_rows = self._reset_term.held_rows[rows] + self.transfer_counts[ids] = 0 + self.direction_transfer_counts[ids] = 0 + self.ever_success[ids] = False + self.progress_ever_success[ids] = False + self._assign_goal(ids, target_cube_ids, source_side_ids) + self.held_cube_ids[ids] = torch.where(held_rows, target_cube_ids, -1) + self.subgoal_start_steps[ids] = 0 + return + + positions = cube_values(self._env, "root_pos_w")[ids] + positions -= self._env.scene.env_origins[ids].unsqueeze(1) + next_source_side_ids = 1 - self.source_side_ids[ids] + next_cube_ids = select_next_transfer_cube( + positions, + self.target_cube_ids[ids], + next_source_side_ids, + transit_half_width=self.cfg.transit_half_width, + ) + self._assign_goal(ids, next_cube_ids, next_source_side_ids) + + def _update_command(self) -> None: + completed_ids = self.pending_success.nonzero(as_tuple=False).squeeze(-1) + if completed_ids.numel(): + self._resample(completed_ids) + + def _assign_goal( + self, + env_ids: torch.Tensor, + target_cube_ids: torch.Tensor, + source_side_ids: torch.Tensor, + ) -> None: + """Publish one command and clear completion state from the previous command.""" + self.target_cube_ids[env_ids] = target_cube_ids + self.source_side_ids[env_ids] = source_side_ids + self.held_cube_ids[env_ids] = -1 + self.subgoal_start_steps[env_ids] = self._env.episode_length_buf[env_ids] + self._stable_steps[env_ids] = 0 + self.is_success[env_ids] = False + self.new_success[env_ids] = False + self.pending_success[env_ids] = False + self._last_evaluation_steps[env_ids] = -1 + + def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | slice | None) -> torch.Tensor: + """Return validated environment indices on the command device.""" + if env_ids is None: + ids = torch.arange(self.num_envs, dtype=torch.long, device=self.device) + elif isinstance(env_ids, slice): + ids = torch.arange(self.num_envs, dtype=torch.long, device=self.device)[env_ids] + else: + ids = torch.as_tensor(env_ids, dtype=torch.long, device=self.device).flatten() + if bool(torch.any((ids < 0) | (ids >= self.num_envs))): + raise IndexError("Conveyor command environment indices are out of range.") + return ids + + def _set_debug_vis_impl(self, debug_vis: bool) -> None: + raise NotImplementedError("Conveyor commands are visualized by the Newton goal selector.") + + def _debug_vis_callback(self, event) -> None: + raise NotImplementedError("Conveyor commands are visualized by the Newton goal selector.") + + +@configclass +class ConveyorTransferCommandCfg(CommandTermCfg): + """Configuration for success-driven, bidirectional cube-transfer commands.""" + + class_type: type[ConveyorTransferCommand] | str = "{DIR}.commands:ConveyorTransferCommand" + """Command-term implementation resolved lazily by the manager.""" + + resampling_time_range: tuple[float, float] = (1.0e6, 1.0e6) + """Time-based resampling interval [s]; success normally resamples first.""" + + reset_event_name: str = "reset_from_state_table" + """Reset event containing the immutable physical-state table.""" + + transit_half_width: float = 0.14 + """Half-width of the corridor excluded when selecting a source-belt cube [m].""" + + minimum_subgoal_steps: int = 2 + """Minimum policy steps before a placement can succeed.""" + + hold_steps: int = 3 + """Consecutive policy steps for which a placement must remain valid.""" + + lateral_tolerance: float = 0.055 + """Maximum lateral error from the destination belt center [m].""" + + maximum_cube_speed: float = 0.65 + """Maximum cube speed for a completed placement [m/s].""" + + minimum_finger_position: float = 0.027 + """Minimum position of each finger for the cube to count as released [m].""" + + minimum_tool_clearance: float = 0.055 + """Minimum tool-to-cube distance for the cube to count as released [m].""" + + minimum_progress_steps: int = 3 + """Minimum policy steps before reset-learning progress can be credited.""" + + minimum_progress: float = 0.35 + """Required increase in the dimensionless transfer potential after reset.""" + + maximum_target_potential: float = 5.0 + """Upper bound for the dimensionless reset-progress target.""" + + minimum_acquisition_lift: float = 0.025 + """Minimum cube lift used to validate physical acquisition [m].""" + + maximum_acquisition_tool_distance: float = 0.075 + """Maximum tool-to-cube distance used to validate physical acquisition [m].""" + + maximum_acquisition_finger_position: float = 0.030 + """Maximum finger position used to validate physical acquisition [m].""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/cube_state.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/cube_state.py new file mode 100644 index 000000000000..9ce81862d05b --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/cube_state.py @@ -0,0 +1,22 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Rigid-object data in the policy's four fixed cube identities.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from .reset_events import CUBE_COUNT + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + + +def cube_values(env: ManagerBasedRLEnv, attribute: str, *, all_cubes: bool = False) -> torch.Tensor: + """Gather cube data in identity order, with shape (num_envs, four, attribute_size).""" + return torch.stack([getattr(env.scene[f"cube_{i}"].data, attribute).torch for i in range(CUBE_COUNT)], dim=1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py new file mode 100644 index 000000000000..e85da72345b6 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py @@ -0,0 +1,327 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Adaptive reset-state curriculum for conveyor transfer.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import CurriculumTermCfg, ManagerTermBase + +from isaaclab_tasks.utils.success_monitor import SuccessMonitorCfg + +from .reset_events import BELT_DEPLOYMENT_VARIANT, CUBE_COUNT, ConveyorResetRecipe, reset_variant_counts + +if TYPE_CHECKING: + from isaaclab.envs import ManagerBasedRLEnv + + +def reset_sampling_probabilities( + recipe_ids: torch.Tensor, + variant_ids: torch.Tensor, + target_cube_ids: torch.Tensor, + source_side_ids: torch.Tensor, + target_weights: torch.Tensor, + deployment_probability: float | torch.Tensor, +) -> torch.Tensor: + """Balance target-rate weights and mix in guaranteed deployment starts.""" + if not ( + recipe_ids.shape == variant_ids.shape == target_cube_ids.shape == source_side_ids.shape == target_weights.shape + ): + raise ValueError("Reset row metadata and target weights must have matching shapes.") + deployment_probability = torch.as_tensor( + deployment_probability, + dtype=torch.float32, + device=target_weights.device, + ) + if deployment_probability.numel() != 1: + raise ValueError("deployment_probability must be a scalar.") + deployment_probability = deployment_probability.reshape(()) + if bool((deployment_probability <= 0.0) | (deployment_probability >= 1.0)): + raise ValueError("deployment_probability must lie strictly between zero and one.") + if not bool(torch.all(torch.isfinite(target_weights))) or bool(torch.any(target_weights < 0.0)): + raise ValueError("target_weights must be finite and non-negative.") + + deployment_rows = (recipe_ids == int(ConveyorResetRecipe.BELT)) & (variant_ids == BELT_DEPLOYMENT_VARIANT) + if not bool(torch.any(deployment_rows)) or bool(torch.all(deployment_rows)): + raise ValueError("Reset table must contain deployment and intermediate rows.") + + adaptive = target_weights.clone() + adaptive[deployment_rows] = 0.0 + + # Mirror Franka Stack's recipe/layout balancing: success in one physical + # phase must not starve another cube identity or transfer direction. + command_ids = 2 * target_cube_ids + source_side_ids + command_count = 2 * CUBE_COUNT + if bool(torch.any((command_ids < 0) | (command_ids >= command_count))): + raise ValueError("Reset table contains an invalid cube or source-side id.") + stratum_ids = recipe_ids * command_count + command_ids + stratum_count = len(ConveyorResetRecipe) * command_count + stratum_mass = torch.zeros(stratum_count, dtype=adaptive.dtype, device=adaptive.device) + stratum_mass.scatter_add_(0, stratum_ids, adaptive) + if bool(torch.any(stratum_mass <= 0.0)): + raise ValueError("Every recipe, cube, and source-side stratum must have intermediate reset rows.") + adaptive /= stratum_mass[stratum_ids] + adaptive[deployment_rows] = 0.0 + adaptive *= (1.0 - deployment_probability) / adaptive.sum() + deployment = deployment_rows.to(dtype=adaptive.dtype) + deployment *= deployment_probability / deployment.sum() + return adaptive + deployment + + +def deployment_probability_from_progress( + progress_rate: torch.Tensor, + row_coverage: torch.Tensor, + initial_probability: float = 0.35, + final_probability: float = 0.90, + progress_start: float = 0.45, + progress_end: float = 0.80, + coverage_target: float = 0.50, +) -> torch.Tensor: + """Interpolate deployment sampling from rolling completed-transfer success and row coverage.""" + if progress_rate.numel() != 1 or row_coverage.numel() != 1: + raise ValueError("progress_rate and row_coverage must be scalar tensors.") + if not 0.0 < initial_probability <= final_probability < 1.0: + raise ValueError("Deployment probabilities must be ordered strictly inside (0, 1).") + if not 0.0 <= progress_start < progress_end <= 1.0: + raise ValueError("Deployment progress thresholds must be ordered inside [0, 1].") + if not 0.0 < coverage_target <= 1.0: + raise ValueError("coverage_target must lie inside (0, 1].") + if bool((progress_rate < 0.0) | (progress_rate > 1.0) | (row_coverage < 0.0) | (row_coverage > 1.0)): + raise ValueError("Rolling progress and row coverage must lie inside [0, 1].") + + progress_fraction = ((progress_rate - progress_start) / (progress_end - progress_start)).clamp(0.0, 1.0) + coverage_fraction = (row_coverage / coverage_target).clamp(0.0, 1.0) + readiness = progress_fraction * coverage_fraction + readiness = readiness.square() * (3.0 - 2.0 * readiness) + return initial_probability + (final_probability - initial_probability) * readiness + + +class ConveyorResetCurriculum(ManagerTermBase): + """Record row outcomes and sample the next physical reset states.""" + + def __init__(self, cfg: CurriculumTermCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + reset_term = env.event_manager.get_term_cfg("reset_from_state_table").func + if not hasattr(reset_term, "row_count"): + raise RuntimeError("ConveyorResetCurriculum requires ConveyorResetStateTable.") + self._reset_term = reset_term + self._attempts = torch.zeros(reset_term.row_count, dtype=torch.long, device=env.device) + self._progress_successes = torch.zeros_like(self._attempts) + self._final_successes = torch.zeros_like(self._attempts) + monitor_cfg = cfg.params.get("success_monitor") + if not isinstance(monitor_cfg, SuccessMonitorCfg): + raise TypeError("ConveyorResetCurriculum requires a SuccessMonitorCfg.") + self._progress_monitor = monitor_cfg.class_type( + monitor_cfg, + num_partitions=1, + partition_size=reset_term.row_count, + device=env.device, + ) + self._deployment_monitor = monitor_cfg.class_type( + monitor_cfg, num_partitions=1, partition_size=2 * CUBE_COUNT, device=env.device + ) + variant_counts = reset_variant_counts() + self._diagnostic_variant_rows = tuple( + ( + recipe.name.lower(), + variant_id, + (reset_term.recipe_ids == int(recipe)) & (reset_term.variant_ids == variant_id), + ) + for recipe in (ConveyorResetRecipe.PREGRASP, ConveyorResetRecipe.BELT) + for variant_id in range(variant_counts[int(recipe)]) + ) + + def __call__( + self, + env: ManagerBasedRLEnv, + env_ids: Sequence[int], + success_monitor: SuccessMonitorCfg, + command_name: str = "transfer", + deployment_probability_initial: float = 0.35, + deployment_probability_final: float = 0.90, + deployment_progress_start: float = 0.45, + deployment_progress_end: float = 0.80, + deployment_coverage_target: float = 0.50, + fixed_source_side_id: int | None = None, + ) -> dict[str, torch.Tensor]: + """Update adaptive evidence, sample rows, and expose diagnostics.""" + del success_monitor + ids = torch.as_tensor(env_ids, dtype=torch.long, device=env.device).flatten() + command = env.command_manager.get_term(command_name) + batch_progress = torch.zeros((), dtype=torch.float32, device=env.device) + batch_success = torch.zeros((), dtype=torch.float32, device=env.device) + completed = (command.command_counter[ids] > 0) & (env.episode_length_buf[ids] > 0) + completed_ids = ids[completed] + if completed_ids.numel(): + progressed = command.progress_ever_success[completed_ids] + succeeded = command.ever_success[completed_ids] + rows = self._reset_term.row_ids[completed_ids] + self._progress_monitor.success_update(rows, progressed) + deployment = (self._reset_term.recipe_ids[rows] == int(ConveyorResetRecipe.BELT)) & ( + self._reset_term.variant_ids[rows] == BELT_DEPLOYMENT_VARIANT + ) + deployment_slots = 2 * self._reset_term.target_cube_ids[rows] + self._reset_term.source_side_ids[rows] + self._deployment_monitor.success_update(deployment_slots, succeeded, valid=deployment) + self._attempts.scatter_add_(0, rows, torch.ones_like(rows)) + self._progress_successes.scatter_add_(0, rows, progressed.long()) + self._final_successes.scatter_add_(0, rows, succeeded.long()) + batch_progress = progressed.float().mean() + batch_success = succeeded.float().mean() + + history_size = self._progress_monitor.success_size + history_success_count = self._progress_monitor.success_buf.sum(dim=1) + attempted_rows = history_size > 0 + row_coverage = attempted_rows.float().mean() + total_progress = history_success_count.sum() / history_size.sum().clamp_min(1) + deployment_success = self._deployment_monitor.success_buf.sum() / ( + self._deployment_monitor.success_size.sum().clamp_min(1) + ) + deployment_probability = deployment_probability_from_progress( + deployment_success, + row_coverage, + initial_probability=deployment_probability_initial, + final_probability=deployment_probability_final, + progress_start=deployment_progress_start, + progress_end=deployment_progress_end, + coverage_target=deployment_coverage_target, + ) + probabilities = reset_sampling_probabilities( + self._reset_term.recipe_ids, + self._reset_term.variant_ids, + self._reset_term.target_cube_ids, + self._reset_term.source_side_ids, + self._progress_monitor.target_weights(), + deployment_probability, + ) + if fixed_source_side_id is not None: + if fixed_source_side_id not in (0, 1): + raise ValueError("fixed_source_side_id must be 0 (left) or 1 (right).") + probabilities *= self._reset_term.source_side_ids == fixed_source_side_id + probabilities /= probabilities.sum() + if ids.numel(): + self._reset_term.row_ids[ids] = torch.multinomial(probabilities, ids.numel(), replacement=True) + + cumulative_progress = self._progress_successes.sum().float() / self._attempts.sum().clamp_min(1) + total_success = self._final_successes.sum().float() / self._attempts.sum().clamp_min(1) + entropy = -(probabilities * probabilities.clamp_min(torch.finfo(probabilities.dtype).tiny).log()).sum() + entropy /= math.log(probabilities.numel()) + metrics: dict[str, torch.Tensor] = { + "batch_progress_rate": batch_progress, + "batch_success_rate": batch_success, + "batch_transfer_count": ( + command.transfer_counts[completed_ids].float().mean() + if completed_ids.numel() + else torch.zeros((), dtype=torch.float32, device=env.device) + ), + "batch_left_to_right_transfers": ( + command.direction_transfer_counts[completed_ids, 0].float().mean() + if completed_ids.numel() + else torch.zeros((), dtype=torch.float32, device=env.device) + ), + "batch_right_to_left_transfers": ( + command.direction_transfer_counts[completed_ids, 1].float().mean() + if completed_ids.numel() + else torch.zeros((), dtype=torch.float32, device=env.device) + ), + "deployment_probability": deployment_probability, + "deployment_transfer_success_rate": deployment_success, + "row_coverage": row_coverage, + "overall_progress_rate": total_progress, + "cumulative_progress_rate": cumulative_progress, + "overall_success_rate": total_success, + "sampling_entropy": entropy, + } + for recipe in ConveyorResetRecipe: + mask = self._reset_term.recipe_ids == int(recipe) + recipe_attempts = self._attempts[mask].sum() + metrics[f"recipe_{recipe.name.lower()}_probability"] = probabilities[mask].sum() + recipe_history_size = history_size[mask].sum() + metrics[f"recipe_{recipe.name.lower()}_progress_rate"] = history_success_count[ + mask + ].sum() / recipe_history_size.clamp_min(1) + metrics[f"recipe_{recipe.name.lower()}_success_rate"] = self._final_successes[ + mask + ].sum().float() / recipe_attempts.clamp_min(1) + for side_id, side_name in ((0, "left_to_right"), (1, "right_to_left")): + mask = self._reset_term.source_side_ids == side_id + attempts = self._attempts[mask].sum() + direction_history_size = history_size[mask].sum() + metrics[f"direction_{side_name}_probability"] = probabilities[mask].sum() + metrics[f"direction_{side_name}_progress_rate"] = history_success_count[ + mask + ].sum() / direction_history_size.clamp_min(1) + metrics[f"direction_{side_name}_success_rate"] = self._final_successes[ + mask + ].sum().float() / attempts.clamp_min(1) + for recipe_name, variant_id, mask in self._diagnostic_variant_rows: + attempts = self._attempts[mask].sum() + variant_history_size = history_size[mask].sum() + prefix = f"recipe_{recipe_name}_variant_{variant_id}" + metrics[f"{prefix}_probability"] = probabilities[mask].sum() + metrics[f"{prefix}_progress_rate"] = history_success_count[mask].sum() / variant_history_size.clamp_min(1) + metrics[f"{prefix}_success_rate"] = self._final_successes[mask].sum().float() / attempts.clamp_min(1) + return metrics + + def get_state(self) -> dict[str, torch.Tensor]: + """Return curriculum evidence for checkpointing.""" + history_success_count = self._progress_monitor.success_buf.sum(dim=1).long() + return { + "attempts": self._attempts.clone(), + "progress_successes": self._progress_successes.clone(), + "final_successes": self._final_successes.clone(), + "progress_history": self._progress_monitor.success_buf.bool().clone(), + "history_pointer": self._progress_monitor.success_pointer.clone(), + "history_size": self._progress_monitor.success_size.clone(), + "history_success_count": history_success_count, + "rolling_progress_rates": self._progress_monitor.success_rate.clone(), + "deployment_history": self._deployment_monitor.success_buf.clone(), + "deployment_history_pointer": self._deployment_monitor.success_pointer.clone(), + "deployment_history_size": self._deployment_monitor.success_size.clone(), + "deployment_success_rates": self._deployment_monitor.success_rate.clone(), + } + + def set_state(self, state: dict[str, torch.Tensor]) -> None: + """Restore curriculum evidence from a checkpoint.""" + targets = { + "attempts": self._attempts, + "progress_successes": self._progress_successes, + "final_successes": self._final_successes, + "progress_history": self._progress_monitor.success_buf, + "history_pointer": self._progress_monitor.success_pointer, + "history_size": self._progress_monitor.success_size, + "rolling_progress_rates": self._progress_monitor.success_rate, + "deployment_history": self._deployment_monitor.success_buf, + "deployment_history_pointer": self._deployment_monitor.success_pointer, + "deployment_history_size": self._deployment_monitor.success_size, + "deployment_success_rates": self._deployment_monitor.success_rate, + } + for name, target in targets.items(): + if name not in state or state[name].shape != target.shape: + raise ValueError(f"Conveyor curriculum checkpoint has invalid '{name}'.") + if "history_success_count" not in state or state["history_success_count"].shape != self._attempts.shape: + raise ValueError("Conveyor curriculum checkpoint has invalid 'history_success_count'.") + history_len = self._progress_monitor.success_buf.shape[1] + if bool(torch.any((state["history_pointer"] < 0) | (state["history_pointer"] >= history_len))): + raise ValueError("Conveyor curriculum checkpoint has invalid history pointers.") + if bool(torch.any((state["history_size"] < 0) | (state["history_size"] > history_len))): + raise ValueError("Conveyor curriculum checkpoint has invalid history sizes.") + if bool( + torch.any((state["deployment_history_pointer"] < 0) | (state["deployment_history_pointer"] >= history_len)) + ): + raise ValueError("Conveyor curriculum checkpoint has invalid deployment history pointers.") + if bool(torch.any((state["deployment_history_size"] < 0) | (state["deployment_history_size"] > history_len))): + raise ValueError("Conveyor curriculum checkpoint has invalid deployment history sizes.") + if bool( + torch.any((state["history_success_count"] < 0) | (state["history_success_count"] > state["history_size"])) + ): + raise ValueError("Conveyor curriculum checkpoint has invalid rolling success counts.") + for name, target in targets.items(): + target.copy_(state[name].to(device=target.device, dtype=target.dtype)) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py new file mode 100644 index 000000000000..29ab17c9ce6f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py @@ -0,0 +1,71 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Backend-independent Franka tool-state helpers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils import math as math_utils + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + +def _end_effector_cache_entry( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntityCfg, + body_name: str, + body_offset: tuple[float, float, float], +) -> tuple[Articulation, int, torch.Tensor]: + """Resolve and cache the Franka hand body and tool-frame offset.""" + robot: Articulation = env.scene[robot_cfg.name] + cache = getattr(env, "_conveyor_end_effector_cache", None) + if cache is None: + cache = {} + env._conveyor_end_effector_cache = cache + key = (robot_cfg.name, body_name, body_offset) + entry = cache.get(key) + if entry is None: + body_ids, _ = robot.find_bodies(body_name) + if len(body_ids) != 1: + raise ValueError(f"Expected one end-effector body matching '{body_name}', found {len(body_ids)}.") + entry = (body_ids[0], torch.tensor(body_offset, dtype=torch.float32, device=env.device)) + cache[key] = entry + return robot, entry[0], entry[1] + + +def end_effector_pose( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + body_name: str = "panda_hand", + body_offset: tuple[float, float, float] = (0.0, 0.0, 0.1034), +) -> tuple[torch.Tensor, torch.Tensor]: + """Return the Franka tool-center position [m] and orientation.""" + robot, body_id, offset = _end_effector_cache_entry(env, robot_cfg, body_name, body_offset) + orientation = robot.data.body_quat_w.torch[:, body_id] + position = robot.data.body_pos_w.torch[:, body_id] + position = position + math_utils.quat_apply(orientation, offset.expand(env.num_envs, -1)) + return position, orientation + + +def tool_velocity( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), + body_name: str = "panda_hand", + body_offset: tuple[float, float, float] = (0.0, 0.0, 0.1034), +) -> torch.Tensor: + """Return tool-center linear and angular velocity [m/s, rad/s].""" + robot, body_id, offset = _end_effector_cache_entry(env, robot_cfg, body_name, body_offset) + orientation = robot.data.body_quat_w.torch[:, body_id] + body_velocity = robot.data.body_vel_w.torch[:, body_id] + offset_world = math_utils.quat_apply(orientation, offset.expand(env.num_envs, -1)) + linear_velocity = body_velocity[:, :3] + torch.linalg.cross(body_velocity[:, 3:], offset_world) + return torch.cat((linear_velocity, body_velocity[:, 3:]), dim=1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/observations.py new file mode 100644 index 000000000000..9e690ff58ecf --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/observations.py @@ -0,0 +1,133 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Task-conditioned observations for conveyor transfer.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import SceneEntityCfg +from isaaclab.utils import math as math_utils + +from .cube_state import cube_values +from .kinematics import end_effector_pose, tool_velocity +from .reset_events import CUBE_COUNT, TRANSFER_X, side_inner_y + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + +def _cube_state(env: ManagerBasedRLEnv) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Stack cube world positions, orientations, and spatial velocities.""" + state = ( + cube_values(env, "root_pos_w"), + cube_values(env, "root_quat_w"), + cube_values(env, "root_vel_w"), + ) + return state + + +def target_cube_one_hot(env: ManagerBasedRLEnv, command_name: str = "transfer") -> torch.Tensor: + """Encode which numbered cube the policy must transfer.""" + command = env.command_manager.get_term(command_name) + return torch.nn.functional.one_hot(command.target_cube_ids.long(), num_classes=CUBE_COUNT).float() + + +def target_side_one_hot(env: ManagerBasedRLEnv, command_name: str = "transfer") -> torch.Tensor: + """Encode the destination conveyor, opposite the reset source side.""" + command = env.command_manager.get_term(command_name) + return torch.nn.functional.one_hot(1 - command.source_side_ids.long(), num_classes=2).float() + + +def classify_cube_conveyors(local_positions: torch.Tensor, transit_half_width: float = 0.14) -> torch.Tensor: + """Classify positions as left conveyor, in transit, or right conveyor.""" + if local_positions.shape[-1] != 3: + raise ValueError("Cube positions must end in xyz coordinates.") + side_ids = torch.full(local_positions.shape[:-1], 1, dtype=torch.long, device=local_positions.device) + side_ids[local_positions[..., 1] > transit_half_width] = 0 + side_ids[local_positions[..., 1] < -transit_half_width] = 2 + return torch.nn.functional.one_hot(side_ids, num_classes=3).float() + + +def cube_conveyor_state(env: ManagerBasedRLEnv) -> torch.Tensor: + """Return left/transit/right one-hot state for every numbered cube.""" + positions, _, _ = _cube_state(env) + local_positions = positions - env.scene.env_origins.unsqueeze(1) + return classify_cube_conveyors(local_positions).flatten(start_dim=1) + + +def transfer_object_observation(env: ManagerBasedRLEnv) -> torch.Tensor: + """Describe all four cubes in stable identity slots. + + The observation contains local positions, tool-relative positions, local + up axes, and linear/angular velocities in four fixed identity slots. + :func:`target_cube_one_hot` selects the active slot. + """ + positions, quaternions, velocities = _cube_state(env) + local_positions = positions - env.scene.env_origins.unsqueeze(1) + tool_position, _ = end_effector_pose(env) + tool_relative = positions - tool_position.unsqueeze(1) + rotations = math_utils.matrix_from_quat(quaternions.flatten(end_dim=1)).view(env.num_envs, CUBE_COUNT, 3, 3) + up_axes = rotations[..., 2] + return torch.cat( + ( + local_positions.flatten(start_dim=1), + tool_relative.flatten(start_dim=1), + up_axes.flatten(start_dim=1), + velocities.flatten(start_dim=1), + ), + dim=1, + ) + + +def active_transfer_features(env: ManagerBasedRLEnv, command_name: str = "transfer") -> torch.Tensor: + """Return active-cube and destination-relative position features [m].""" + command = env.command_manager.get_term(command_name) + positions, _, _ = _cube_state(env) + active_position = positions[torch.arange(env.num_envs, device=env.device), command.target_cube_ids.long()] + local_active_position = active_position - env.scene.env_origins + tool_position, _ = end_effector_pose(env) + target_side_ids = 1 - command.source_side_ids.long() + target_position = torch.stack( + ( + torch.full_like(target_side_ids, TRANSFER_X, dtype=active_position.dtype), + side_inner_y(target_side_ids), + torch.full_like(target_side_ids, 0.06, dtype=active_position.dtype), + ), + dim=1, + ) + return torch.cat( + ( + local_active_position, + active_position - tool_position, + target_position - local_active_position, + ), + dim=1, + ) + + +def gripper_joint_positions( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot", joint_names=["panda_finger_joint[1-2]"]), +) -> torch.Tensor: + """Return the two Franka finger positions [m].""" + robot: Articulation = env.scene[robot_cfg.name] + return robot.data.joint_pos.torch[:, robot_cfg.joint_ids] + + +def end_effector_axes(env: ManagerBasedRLEnv) -> torch.Tensor: + """Return continuous tool-frame x and z axes.""" + _, orientation = end_effector_pose(env) + rotation = math_utils.matrix_from_quat(orientation) + return torch.cat((rotation[:, :, 0], rotation[:, :, 2]), dim=1) + + +def end_effector_velocity(env: ManagerBasedRLEnv) -> torch.Tensor: + """Return tool-center linear and angular velocity [m/s, rad/s].""" + return tool_velocity(env) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py new file mode 100644 index 000000000000..5f6ff0dd9017 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py @@ -0,0 +1,545 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Reset-state sampling for conveyor transfer.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass +from enum import IntEnum +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import EventTermCfg, ManagerTermBase + +from ..conveyor_geometry import ( + BELT_CENTER_X, + BELT_INNER_STRAIGHT_Y, + BELT_OUTER_STRAIGHT_Y, + BELT_TOP_Z, + CUBE_INNER_SLOT_X, + CUBE_OUTER_SLOT_X, +) + +if TYPE_CHECKING: + from isaaclab.assets import Articulation, RigidObject + from isaaclab.envs import ManagerBasedRLEnv + + +CUBE_COUNT = 4 +CUBE_SIZE = 0.04 +CUBE_REST_Z = BELT_TOP_Z + 0.5 * CUBE_SIZE +TRANSFER_X = 0.52 +LEFT_SIDE = 0 +RIGHT_SIDE = 1 +_BELT_CURRICULUM_FRACTIONS = (0.0, 0.15, 0.30, 0.45, 0.60, 0.80, 1.0) +_GRASP_CLOSURE_FRACTIONS = (0.25, 0.50, 0.75, 0.90, 1.0) +_OPEN_FINGER_POSITION = 0.040 +_CLOSED_FINGER_POSITION = 0.019 +BELT_DEPLOYMENT_VARIANT = len(_BELT_CURRICULUM_FRACTIONS) - 1 + + +class ConveyorResetRecipe(IntEnum): + """Reset phases ordered from easiest to complete task start.""" + + GOAL = 0 + PLACE = 1 + CARRY = 2 + LIFT = 3 + GRASP = 4 + PREGRASP = 5 + BELT = 6 + + +@dataclass(frozen=True) +class ConveyorResetRow: + """One physical reset state and transfer command.""" + + recipe: ConveyorResetRecipe + variant_id: int + target_cube_id: int + source_side_id: int + arm_positions: tuple[float, ...] + finger_position: float + held: bool + belt_range_fraction: float + + +_HOME_ARM = (0.0, -0.35, 0.0, -2.35, 0.0, 2.0, 0.78) + +# Constrained IK anchors target x=0.52 m with a downward-facing tool. Side 0 +# is the positive-y conveyor and side 1 is the negative-y conveyor. +_SOURCE_GRASP_ARM = ( + (0.5144946, 0.5427007, -0.0340481, -2.0731085, 0.0350549, 2.6153000, 1.2350098), + (-0.3400387, 0.5475255, -0.1330232, -2.0714802, 0.1368175, 2.6109921, 0.2080209), +) +_SOURCE_PREGRASP_ARM = ( + (0.5190744, 0.3742786, -0.0406565, -2.0872751, 0.0236238, 2.4611441, 1.2428656), + (-0.3220402, 0.3787346, -0.1588824, -2.0865274, 0.0928583, 2.4588233, 0.2380420), +) +_SOURCE_LIFT_ARM = ( + (0.5212608, 0.2372272, -0.0439391, -2.0514939, 0.0137043, 2.2884652, 1.2495379), + (-0.3132103, 0.2404478, -0.1720021, -2.0512362, 0.0540920, 2.2875542, 0.2640870), +) +_TARGET_PLACE_ARM = ( + # Source left, target right. + (-0.2921792, 0.4521663, -0.1853937, -2.0854943, 0.1398595, 2.5262992, 0.2062777), + # Source right, target left. + (0.4780173, 0.4442472, 0.0008774, -2.0873070, -0.0005178, 2.5315716, 1.2591928), +) +_CARRY_ARM = (0.0, 0.0137827, 0.0, -2.2661237, 0.0, 2.2798989, 0.78) + + +def _interpolate_arm( + start: tuple[float, ...], + end: tuple[float, ...], + fractions: tuple[float, ...], +) -> tuple[tuple[float, ...], ...]: + """Linearly interpolate validated joint-space anchors.""" + return tuple( + tuple( + start_value + fraction * (end_value - start_value) + for start_value, end_value in zip(start, end, strict=True) + ) + for fraction in fractions + ) + + +def _arm_position_variants( + recipe: ConveyorResetRecipe, + source_side_id: int, +) -> tuple[tuple[float, ...], ...]: + """Return dense reset states along the nominal transfer trajectory.""" + source_grasp = _SOURCE_GRASP_ARM[source_side_id] + source_pregrasp = _SOURCE_PREGRASP_ARM[source_side_id] + source_lift = _SOURCE_LIFT_ARM[source_side_id] + target_lift = _SOURCE_LIFT_ARM[1 - source_side_id] + if recipe == ConveyorResetRecipe.GOAL: + return (_SOURCE_PREGRASP_ARM[1 - source_side_id],) + if recipe == ConveyorResetRecipe.PLACE: + return _interpolate_arm(target_lift, _TARGET_PLACE_ARM[source_side_id], (0.25, 0.50, 0.75, 1.0)) + if recipe == ConveyorResetRecipe.CARRY: + return ( + *_interpolate_arm(source_lift, _CARRY_ARM, (0.33, 0.66, 1.0)), + *_interpolate_arm(_CARRY_ARM, target_lift, (0.33, 0.66, 1.0)), + ) + if recipe == ConveyorResetRecipe.LIFT: + return _interpolate_arm(source_grasp, source_lift, (0.25, 0.50, 0.75, 1.0)) + if recipe == ConveyorResetRecipe.GRASP: + return (source_grasp,) * len(_GRASP_CLOSURE_FRACTIONS) + if recipe == ConveyorResetRecipe.PREGRASP: + # Include the exact open-gripper acquisition pose. The previous last + # row stopped at 88% of the approach, leaving an approximately 1 cm + # gap between reset-driven approach learning and the already-held + # GRASP row. Dense near-contact rows make the physical close-and-lift + # transition learnable from the same sparse delivery objective. + return _interpolate_arm(source_pregrasp, source_grasp, (0.0, 0.50, 0.75, 0.92, 1.0)) + if recipe == ConveyorResetRecipe.BELT: + return _interpolate_arm(source_pregrasp, _HOME_ARM, _BELT_CURRICULUM_FRACTIONS) + raise ValueError(f"Unsupported reset recipe: {recipe}.") + + +def _finger_position_variants(recipe: ConveyorResetRecipe) -> tuple[float, ...]: + """Return finger positions paired with a recipe's arm variants [m].""" + variant_count = len(_arm_position_variants(recipe, LEFT_SIDE)) + if recipe == ConveyorResetRecipe.GRASP: + return tuple( + _OPEN_FINGER_POSITION + fraction * (_CLOSED_FINGER_POSITION - _OPEN_FINGER_POSITION) + for fraction in _GRASP_CLOSURE_FRACTIONS + ) + if recipe in (ConveyorResetRecipe.LIFT, ConveyorResetRecipe.CARRY, ConveyorResetRecipe.PLACE): + return (_CLOSED_FINGER_POSITION,) * variant_count + return (_OPEN_FINGER_POSITION,) * variant_count + + +def reset_variant_counts() -> tuple[int, ...]: + """Return the number of trajectory variants in each reset recipe.""" + return tuple(len(_arm_position_variants(recipe, LEFT_SIDE)) for recipe in ConveyorResetRecipe) + + +def build_reset_rows() -> tuple[ConveyorResetRow, ...]: + """Build the complete identity, direction, and dense-trajectory cross product.""" + return tuple( + ConveyorResetRow( + recipe=recipe, + variant_id=variant_id, + target_cube_id=cube_id, + source_side_id=source_side, + arm_positions=arm_positions, + finger_position=finger_position, + held=( + recipe in (ConveyorResetRecipe.LIFT, ConveyorResetRecipe.CARRY, ConveyorResetRecipe.PLACE) + or (recipe == ConveyorResetRecipe.GRASP and variant_id == len(_GRASP_CLOSURE_FRACTIONS) - 1) + ), + belt_range_fraction=_BELT_CURRICULUM_FRACTIONS[variant_id] if recipe == ConveyorResetRecipe.BELT else 0.0, + ) + for recipe in ConveyorResetRecipe + for cube_id in range(CUBE_COUNT) + for source_side in (LEFT_SIDE, RIGHT_SIDE) + for variant_id, (arm_positions, finger_position) in enumerate( + zip(_arm_position_variants(recipe, source_side), _finger_position_variants(recipe), strict=True) + ) + ) + + +_FRANKA_JOINT_ORIGINS = ( + ((0.0, 0.0, 0.333), (0.0, 0.0, 0.0)), + ((0.0, 0.0, 0.0), (-math.pi / 2.0, 0.0, 0.0)), + ((0.0, -0.316, 0.0), (math.pi / 2.0, 0.0, 0.0)), + ((0.0825, 0.0, 0.0), (math.pi / 2.0, 0.0, 0.0)), + ((-0.0825, 0.384, 0.0), (-math.pi / 2.0, 0.0, 0.0)), + ((0.0, 0.0, 0.0), (math.pi / 2.0, 0.0, 0.0)), + ((0.088, 0.0, 0.0), (math.pi / 2.0, 0.0, 0.0)), +) + + +def _rotation_matrix_from_rpy(roll: float, pitch: float, yaw: float, reference: torch.Tensor) -> torch.Tensor: + """Return a fixed XYZ roll-pitch-yaw rotation matrix.""" + cr, sr = math.cos(roll), math.sin(roll) + cp, sp = math.cos(pitch), math.sin(pitch) + cy, sy = math.cos(yaw), math.sin(yaw) + return reference.new_tensor( + ( + (cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr), + (sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr), + (-sp, cp * sr, cp * cr), + ) + ) + + +def franka_tool_position(joint_positions: torch.Tensor) -> torch.Tensor: + """Compute Panda tool-center positions [m] from seven joints [rad].""" + if joint_positions.shape[-1] != 7: + raise ValueError("Franka reset forward kinematics expects seven joint positions.") + shape = joint_positions.shape[:-1] + joints = joint_positions.reshape(-1, 7) + count = joints.shape[0] + rotation = torch.eye(3, dtype=joints.dtype, device=joints.device).expand(count, -1, -1).clone() + position = torch.zeros((count, 3), dtype=joints.dtype, device=joints.device) + reference = joints[0] if count else joint_positions.new_zeros(7) + for joint_id, (origin_position, origin_rpy) in enumerate(_FRANKA_JOINT_ORIGINS): + origin = joint_positions.new_tensor(origin_position).expand(count, -1) + position += torch.bmm(rotation, origin.unsqueeze(-1)).squeeze(-1) + rotation = torch.matmul(rotation, _rotation_matrix_from_rpy(*origin_rpy, reference=reference)) + angle = joints[:, joint_id] + cosine, sine = torch.cos(angle), torch.sin(angle) + zeros, ones = torch.zeros_like(angle), torch.ones_like(angle) + joint_rotation = torch.stack( + ( + torch.stack((cosine, -sine, zeros), dim=1), + torch.stack((sine, cosine, zeros), dim=1), + torch.stack((zeros, zeros, ones), dim=1), + ), + dim=1, + ) + rotation = torch.bmm(rotation, joint_rotation) + tool_offset = joint_positions.new_tensor((0.0, 0.0, 0.2104)).expand(count, -1) + position += torch.bmm(rotation, tool_offset.unsqueeze(-1)).squeeze(-1) + return position.reshape(*shape, 3) + + +def side_inner_y(side_ids: torch.Tensor) -> torch.Tensor: + """Return the reachable inner-straight y coordinate [m] for each side.""" + return torch.where(side_ids == LEFT_SIDE, BELT_INNER_STRAIGHT_Y, -BELT_INNER_STRAIGHT_Y) + + +def _balanced_cube_slots( + target_cube_ids: torch.Tensor, + source_side_ids: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Assign one cube to every inner/outer racetrack run. + + Slots are ordered ``left inner``, ``left outer``, ``right inner``, and + ``right outer``. The commanded cube swaps with the canonical occupant of + its source-side inner slot, keeping it reachable without duplicating or + emptying any deployment slot. + """ + if target_cube_ids.ndim != 1 or source_side_ids.shape != target_cube_ids.shape: + raise ValueError("Target cube and source-side ids must be matching vectors.") + if bool(torch.any((target_cube_ids < 0) | (target_cube_ids >= CUBE_COUNT))): + raise ValueError("Target cube ids are out of range.") + if bool(torch.any((source_side_ids != LEFT_SIDE) & (source_side_ids != RIGHT_SIDE))): + raise ValueError("Source-side ids must be 0 (left) or 1 (right).") + + count = target_cube_ids.numel() + cube_slots = torch.arange(CUBE_COUNT, device=target_cube_ids.device).expand(count, -1).clone() + source_inner_slots = 2 * source_side_ids + displaced_cube_ids = source_inner_slots + target_original_slots = target_cube_ids + cube_slots.scatter_(1, target_cube_ids.unsqueeze(1), source_inner_slots.unsqueeze(1)) + cube_slots.scatter_(1, displaced_cube_ids.unsqueeze(1), target_original_slots.unsqueeze(1)) + + cube_sides = torch.div(cube_slots, 2, rounding_mode="floor") + on_outer_run = torch.remainder(cube_slots, 2).bool() + cube_x = torch.where(on_outer_run, CUBE_OUTER_SLOT_X, CUBE_INNER_SLOT_X) + y_magnitude = torch.where(on_outer_run, BELT_OUTER_STRAIGHT_Y, BELT_INNER_STRAIGHT_Y) + cube_y = torch.where(cube_sides == LEFT_SIDE, y_magnitude, -y_magnitude) + return cube_slots, cube_sides, cube_x, cube_y + + +def _sample_collision_free_active_x( + base_x: torch.Tensor, + cube_sides: torch.Tensor, + target_cube_ids: torch.Tensor, + source_side_ids: torch.Tensor, + lower: torch.Tensor, + upper: torch.Tensor, + minimum_separation: float = 0.055, + attempts: int = 16, +) -> torch.Tensor: + """Sample active-cube positions without overlapping an inactive cube.""" + if base_x.ndim != 2 or base_x.shape[1] != CUBE_COUNT or cube_sides.shape != base_x.shape: + raise ValueError("base_x and cube_sides must have shape (N, CUBE_COUNT).") + count = base_x.shape[0] + expected_vector_shape = (count,) + if any(value.shape != expected_vector_shape for value in (target_cube_ids, source_side_ids, lower, upper)): + raise ValueError("Active-cube sampling controls must have shape (N,).") + if minimum_separation <= 0.0 or attempts < 1: + raise ValueError("Invalid collision-free active-cube sampling parameters.") + + cube_ids = torch.arange(CUBE_COUNT, device=base_x.device).expand(count, -1) + inactive_on_source = (cube_sides == source_side_ids.unsqueeze(1)) & (cube_ids != target_cube_ids.unsqueeze(1)) + sampled = lower + torch.rand_like(lower) * (upper - lower) + for _ in range(attempts): + conflicts = torch.any( + (torch.abs(sampled.unsqueeze(1) - base_x) < minimum_separation) & inactive_on_source, dim=1 + ) + replacement = lower + torch.rand_like(lower) * (upper - lower) + sampled = torch.where(conflicts, replacement, sampled) + + conflicts = torch.any((torch.abs(sampled.unsqueeze(1) - base_x) < minimum_separation) & inactive_on_source, dim=1) + fallback = torch.full_like(sampled, TRANSFER_X) + return torch.where(conflicts, fallback, sampled) + + +class ConveyorResetStateTable(ManagerTermBase): + """Restore validated transfer states spanning released goal to moving start.""" + + def __init__(self, cfg: EventTermCfg, env: ManagerBasedRLEnv): + super().__init__(cfg, env) + self._rows = build_reset_rows() + self.recipe_ids = torch.tensor([row.recipe for row in self._rows], dtype=torch.long, device=env.device) + self.target_cube_ids = torch.tensor( + [row.target_cube_id for row in self._rows], dtype=torch.long, device=env.device + ) + self.variant_ids = torch.tensor([row.variant_id for row in self._rows], dtype=torch.long, device=env.device) + self.source_side_ids = torch.tensor( + [row.source_side_id for row in self._rows], dtype=torch.long, device=env.device + ) + self._arm_positions = torch.tensor( + [row.arm_positions for row in self._rows], dtype=torch.float32, device=env.device + ) + self._finger_positions = torch.tensor( + [row.finger_position for row in self._rows], dtype=torch.float32, device=env.device + ) + self.held_rows = torch.tensor([row.held for row in self._rows], dtype=torch.bool, device=env.device) + self._belt_range_fractions = torch.tensor( + [row.belt_range_fraction for row in self._rows], dtype=torch.float32, device=env.device + ) + self._robot: Articulation = env.scene["robot"] + self._cubes: tuple[RigidObject, ...] = tuple(env.scene[f"cube_{cube_id}"] for cube_id in range(CUBE_COUNT)) + self._arm_joint_ids = self._robot.find_joints("panda_joint[1-7]", preserve_order=True)[0] + self._finger_joint_ids = self._robot.find_joints("panda_finger_joint[1-2]", preserve_order=True)[0] + if len(self._arm_joint_ids) != 7 or len(self._finger_joint_ids) != 2: + raise ValueError("Conveyor transfer requires seven Panda arm joints and two finger joints.") + self.row_ids = torch.randint(self.row_count, (env.num_envs,), dtype=torch.long, device=env.device) + + @property + def row_count(self) -> int: + """Number of immutable physical reset rows.""" + return len(self._rows) + + @property + def recipe_names(self) -> tuple[str, ...]: + """Stable reset recipe labels.""" + return tuple(recipe.name.lower() for recipe in ConveyorResetRecipe) + + def _filtered_rows( + self, + fixed_recipe: int | None, + fixed_variant_id: int | None, + fixed_target_cube_id: int | None, + fixed_source_side_id: int | None, + ) -> torch.Tensor: + """Return rows matching optional deterministic evaluation controls.""" + mask = torch.ones(self.row_count, dtype=torch.bool, device=self.device) + if fixed_recipe is not None: + if not 0 <= fixed_recipe < len(ConveyorResetRecipe): + raise ValueError(f"fixed_recipe must lie in [0, {len(ConveyorResetRecipe) - 1}].") + mask &= self.recipe_ids == fixed_recipe + if fixed_variant_id is not None: + if fixed_variant_id < 0: + raise ValueError("fixed_variant_id must be non-negative.") + mask &= self.variant_ids == fixed_variant_id + if fixed_target_cube_id is not None: + if not 0 <= fixed_target_cube_id < CUBE_COUNT: + raise ValueError(f"fixed_target_cube_id must lie in [0, {CUBE_COUNT - 1}].") + mask &= self.target_cube_ids == fixed_target_cube_id + if fixed_source_side_id is not None: + if fixed_source_side_id not in (LEFT_SIDE, RIGHT_SIDE): + raise ValueError("fixed_source_side_id must be 0 (left) or 1 (right).") + mask &= self.source_side_ids == fixed_source_side_id + return torch.nonzero(mask, as_tuple=False).flatten() + + def __call__( + self, + env: ManagerBasedRLEnv, + env_ids: torch.Tensor, + fixed_recipe: int | None = None, + fixed_variant_id: int | None = None, + fixed_target_cube_id: int | None = None, + fixed_source_side_id: int | None = None, + belt_start_x_range: tuple[float, float] = (0.30, 0.82), + cube_position_noise: float = 0.015, + arm_joint_noise: float = 0.015, + ) -> None: + """Write sampled robot and four-cube states directly into simulation.""" + if env_ids is None or env_ids.numel() == 0: + return + if belt_start_x_range[0] >= belt_start_x_range[1]: + raise ValueError("belt_start_x_range must be strictly increasing.") + if cube_position_noise < 0.0 or arm_joint_noise < 0.0: + raise ValueError("Reset randomization ranges must be non-negative.") + + if ( + fixed_recipe is None + and fixed_variant_id is None + and fixed_target_cube_id is None + and fixed_source_side_id is None + ): + row_ids = self.row_ids[env_ids] + else: + candidates = self._filtered_rows( + fixed_recipe, + fixed_variant_id, + fixed_target_cube_id, + fixed_source_side_id, + ) + if candidates.numel() == 0: + raise RuntimeError("No conveyor reset rows match the fixed reset controls.") + row_ids = candidates[torch.randint(candidates.numel(), (env_ids.numel(),), device=self.device)] + self.row_ids[env_ids] = row_ids + + recipes = self.recipe_ids[row_ids] + target_cube_ids = self.target_cube_ids[row_ids] + source_side_ids = self.source_side_ids[row_ids] + held_rows = self.held_rows[row_ids] + + arm_positions = self._arm_positions[row_ids].clone() + if arm_joint_noise > 0.0: + noise = (2.0 * torch.rand_like(arm_positions) - 1.0) * arm_joint_noise + # Preserve the exact approach, closure, and held-object manifold. + # Only deployment-like BELT starts receive robot randomization. + noise[recipes != int(ConveyorResetRecipe.BELT)] = 0.0 + arm_positions += noise + joint_positions = self._robot.data.default_joint_pos.torch[env_ids].clone() + joint_velocities = torch.zeros_like(joint_positions) + joint_positions[:, self._arm_joint_ids] = arm_positions + finger_positions = self._finger_positions[row_ids].to(dtype=joint_positions.dtype).unsqueeze(1).expand(-1, 2) + joint_positions[:, self._finger_joint_ids] = finger_positions + self._robot.set_joint_position_target_index(target=joint_positions, env_ids=env_ids) + self._robot.set_joint_velocity_target_index(target=joint_velocities, env_ids=env_ids) + self._robot.write_joint_position_to_sim_index(position=joint_positions, env_ids=env_ids) + self._robot.write_joint_velocity_to_sim_index(velocity=joint_velocities, env_ids=env_ids) + + count = env_ids.numel() + cube_slots, cube_sides, base_x, cube_y = _balanced_cube_slots(target_cube_ids, source_side_ids) + base_x = base_x.to(dtype=arm_positions.dtype) + cube_y = cube_y.to(dtype=arm_positions.dtype) + if cube_position_noise > 0.0: + base_x += (2.0 * torch.rand_like(base_x) - 1.0) * cube_position_noise + + active_lower = torch.full((count,), TRANSFER_X, dtype=arm_positions.dtype, device=self.device) + active_upper = active_lower.clone() + belt_rows = recipes == int(ConveyorResetRecipe.BELT) + if bool(torch.any(belt_rows)): + range_fraction = self._belt_range_fractions[row_ids] + range_lower = TRANSFER_X + range_fraction * (belt_start_x_range[0] - TRANSFER_X) + range_upper = TRANSFER_X + range_fraction * (belt_start_x_range[1] - TRANSFER_X) + active_lower[belt_rows] = range_lower[belt_rows] + active_upper[belt_rows] = range_upper[belt_rows] + active_x = _sample_collision_free_active_x( + base_x, + cube_sides, + target_cube_ids, + source_side_ids, + active_lower, + active_upper, + ) + + # On a full deployment reset, mirror the other cube on the source + # conveyor across the racetrack center. Opposite straight runs then + # differ by exactly half a lap even when the active start is sampled. + source_outer_slots = 2 * source_side_ids + 1 + source_outer_cube = cube_slots == source_outer_slots.unsqueeze(1) + mirrored_outer_x = (2.0 * BELT_CENTER_X - active_x).unsqueeze(1) + base_x = torch.where(belt_rows.unsqueeze(1) & source_outer_cube, mirrored_outer_x, base_x) + cube_positions = torch.stack( + (base_x, cube_y, torch.full_like(base_x, CUBE_REST_Z)), + dim=2, + ) + + active_positions = torch.stack( + (active_x, side_inner_y(source_side_ids), torch.full_like(active_x, CUBE_REST_Z)), + dim=1, + ) + goal_rows = recipes == int(ConveyorResetRecipe.GOAL) + active_positions[goal_rows, 1] = side_inner_y(1 - source_side_ids[goal_rows]) + if bool(torch.any(held_rows)): + active_positions[held_rows] = franka_tool_position(arm_positions[held_rows]) + cube_positions.scatter_( + 1, + target_cube_ids.view(-1, 1, 1).expand(-1, 1, 3), + active_positions.unsqueeze(1), + ) + + identity_quaternion = arm_positions.new_tensor((0.0, 0.0, 0.0, 1.0)).expand(count, -1) + for cube_id, cube in enumerate(self._cubes): + root_pose = cube.data.default_root_pose.torch[env_ids].clone() + root_pose[:, :3] = cube_positions[:, cube_id] + env.scene.env_origins[env_ids] + root_pose[:, 3:7] = identity_quaternion + root_velocity = torch.zeros((count, 6), dtype=root_pose.dtype, device=self.device) + cube.write_root_pose_to_sim_index(root_pose=root_pose, env_ids=env_ids) + cube.write_root_velocity_to_sim_index(root_velocity=root_velocity, env_ids=env_ids) + + def reset(self, env_ids: Sequence[int] | None = None) -> None: + """Keep the immutable reset table across environment resets.""" + + +def select_next_transfer_cube( + cube_positions: torch.Tensor, + current_cube_ids: torch.Tensor, + source_side_ids: torch.Tensor, + transit_half_width: float = 0.14, +) -> torch.Tensor: + """Sample the next numbered cube already located on each source belt. + + A different eligible cube is sampled uniformly. The just-placed cube is + the fallback, so a valid goal remains available even when it is temporarily + the only parcel on that conveyor. + """ + if cube_positions.ndim != 3 or cube_positions.shape[1:] != (CUBE_COUNT, 3): + raise ValueError(f"cube_positions must have shape (N, {CUBE_COUNT}, 3).") + count = cube_positions.shape[0] + if current_cube_ids.shape != (count,) or source_side_ids.shape != (count,): + raise ValueError("Current cube and source-side ids must match the position batch.") + if transit_half_width <= 0.0: + raise ValueError("transit_half_width must be positive.") + + cube_ids = torch.arange(CUBE_COUNT, device=cube_positions.device).expand(count, -1) + on_left = cube_positions[:, :, 1] > transit_half_width + on_right = cube_positions[:, :, 1] < -transit_half_width + candidates = torch.where(source_side_ids.unsqueeze(1) == LEFT_SIDE, on_left, on_right) + alternatives = candidates & (cube_ids != current_cube_ids.unsqueeze(1)) + candidates = torch.where(torch.any(alternatives, dim=1, keepdim=True), alternatives, candidates) + + has_candidates = torch.any(candidates, dim=1) + fallback = torch.zeros_like(candidates) + fallback.scatter_(1, current_cube_ids.unsqueeze(1), True) + candidates = torch.where(has_candidates.unsqueeze(1), candidates, fallback) + return torch.multinomial(candidates.float(), 1).squeeze(1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py new file mode 100644 index 000000000000..ee7b50c88d23 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py @@ -0,0 +1,156 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Progress utilities and sparse completion rewards for conveyor transfer.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import SceneEntityCfg + +from .cube_state import cube_values +from .kinematics import end_effector_pose +from .reset_events import CUBE_REST_Z, TRANSFER_X, side_inner_y + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + from .commands import ConveyorTransferCommand + + +def transfer_potential( + cube_positions: torch.Tensor, + tool_positions: torch.Tensor, + finger_positions: torch.Tensor, + source_side_ids: torch.Tensor, +) -> torch.Tensor: + """Return a monotonic pickup-to-release shaping potential.""" + source_y = side_inner_y(source_side_ids) + target_y = side_inner_y(1 - source_side_ids) + target_position = torch.stack( + ( + torch.full_like(source_y, TRANSFER_X), + target_y, + torch.full_like(source_y, CUBE_REST_Z), + ), + dim=1, + ) + tool_distance = torch.linalg.vector_norm(tool_positions - cube_positions, dim=1) + reach = torch.exp(-12.0 * tool_distance) + gripper_closure = torch.clamp((0.04 - torch.amin(finger_positions, dim=1)) / 0.021, min=0.0, max=1.0) + grasp = gripper_closure * torch.exp(-25.0 * tool_distance) + lift = torch.clamp((cube_positions[:, 2] - CUBE_REST_Z) / 0.14, min=0.0, max=1.0) + direction_denominator = (target_y - source_y).clamp(min=-1.0, max=1.0) + crossing = (cube_positions[:, 1] - source_y) / direction_denominator + crossing = torch.clamp(crossing, min=0.0, max=1.0) + target_distance = torch.linalg.vector_norm(cube_positions - target_position, dim=1) + target = torch.exp(-14.0 * target_distance) + transport = crossing * torch.maximum(torch.clamp(2.0 * lift, max=1.0), target) + released = (torch.amin(finger_positions, dim=1) > 0.027).float() * target + return 0.5 * reach + 0.75 * grasp + 1.25 * lift + 2.0 * transport + 2.0 * target + released + + +def current_transfer_potential( + env: ManagerBasedRLEnv, + command_name: str = "transfer", + command: ConveyorTransferCommand | None = None, +) -> torch.Tensor: + """Gather current task state and evaluate the shaping potential.""" + if command is None: + command = env.command_manager.get_term(command_name) + positions = cube_values(env, "root_pos_w") + index = command.target_cube_ids.view(env.num_envs, 1, 1).expand(-1, 1, 3) + active_position = torch.gather(positions, 1, index).squeeze(1) - env.scene.env_origins + tool_position, _ = end_effector_pose(env) + tool_position = tool_position - env.scene.env_origins + robot: Articulation = env.scene["robot"] + finger_ids, _ = robot.find_joints("panda_finger_joint[1-2]", preserve_order=True) + finger_positions = robot.data.joint_pos.torch[:, finger_ids] + return transfer_potential(active_position, tool_position, finger_positions, command.source_side_ids) + + +def transfer_success_reward( + env: ManagerBasedRLEnv, + command_name: str = "transfer", +) -> torch.Tensor: + """Return one on each stable transfer-completion transition.""" + command = env.command_manager.get_term(command_name) + command.evaluate() + return command.new_success.float() + + +def terminal_failure( + env: ManagerBasedRLEnv, + command_name: str = "transfer", +) -> torch.Tensor: + """Return one for non-timeout terminal failures.""" + command = env.command_manager.get_term(command_name) + command.evaluate() + return (env.reset_terminated & ~command.pending_success).float() + + +def action_term_l2(env: ManagerBasedRLEnv, action_name: str) -> torch.Tensor: + """Penalize one named raw action term.""" + action = env.action_manager.get_term(action_name).raw_actions + return torch.sum(torch.square(action), dim=1) + + +def finite_action_rate_l2( + env: ManagerBasedRLEnv, + action_names: tuple[str, ...] = ("arm_action", "gripper_action"), +) -> torch.Tensor: + """Penalize changes between the finite policy commands accepted by action terms.""" + if not action_names: + raise ValueError("At least one action term is required for the action-rate reward.") + terms = tuple(env.action_manager.get_term(name) for name in action_names) + action = torch.cat(tuple(term.raw_actions for term in terms), dim=1) + previous_action = torch.cat(tuple(term.previous_actions for term in terms), dim=1) + return torch.sum(torch.square(action - previous_action), dim=1) + + +def physical_cube_acquisition_mask( + env: ManagerBasedRLEnv, + command_name: str = "transfer", + command: ConveyorTransferCommand | None = None, + minimum_lift: float = 0.025, + maximum_tool_distance: float = 0.075, + maximum_finger_position: float = 0.030, +) -> torch.Tensor: + """Return physically closed, lifted, tool-local commanded-cube grasps.""" + if minimum_lift <= 0.0 or maximum_tool_distance <= 0.0 or maximum_finger_position <= 0.0: + raise ValueError("Physical acquisition thresholds must be positive.") + if command is None: + command = env.command_manager.get_term(command_name) + positions = cube_values(env, "root_pos_w") + index = command.target_cube_ids.view(env.num_envs, 1, 1).expand(-1, 1, 3) + active_position = torch.gather(positions, 1, index).squeeze(1) + tool_position, _ = end_effector_pose(env) + robot: Articulation = env.scene["robot"] + finger_ids, _ = robot.find_joints("panda_finger_joint[1-2]", preserve_order=True) + finger_positions = robot.data.joint_pos.torch[:, finger_ids] + local_cube_z = active_position[:, 2] - env.scene.env_origins[:, 2] + return ( + (local_cube_z >= CUBE_REST_Z + minimum_lift) + & (torch.linalg.vector_norm(active_position - tool_position, dim=1) <= maximum_tool_distance) + & (torch.amax(finger_positions, dim=1) <= maximum_finger_position) + ) + + +def finite_joint_velocity_l2( + env: ManagerBasedRLEnv, + asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", joint_names=["panda_joint[1-7]"]), + maximum_velocity: float = 3.0, +) -> torch.Tensor: + """Penalize bounded arm velocity while sanitizing divergent states.""" + if maximum_velocity <= 0.0: + raise ValueError("maximum_velocity must be positive.") + robot: Articulation = env.scene[asset_cfg.name] + velocity = robot.data.joint_vel.torch[:, asset_cfg.joint_ids] + velocity = torch.nan_to_num(velocity, nan=0.0, posinf=maximum_velocity, neginf=-maximum_velocity) + return torch.sum(torch.square(torch.clamp(velocity, -maximum_velocity, maximum_velocity)), dim=1) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/terminations.py new file mode 100644 index 000000000000..f63cfefc7c5a --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/terminations.py @@ -0,0 +1,113 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Actual episode termination and truncation terms for conveyor transfer.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.managers import SceneEntityCfg + +from ..conveyor_geometry import ( + BELT_CENTER_X, + BELT_HALF_STRAIGHT, + BELT_TURN_RADIUS, + BELT_WIDTH, + GUARD_THICKNESS, +) +from .cube_state import cube_values +from .reset_events import CUBE_SIZE + +if TYPE_CHECKING: + from isaaclab.assets import Articulation + from isaaclab.envs import ManagerBasedRLEnv + + +_TRACK_X_CLEARANCE = BELT_TURN_RADIUS + 0.5 * BELT_WIDTH + GUARD_THICKNESS + CUBE_SIZE + + +def invalid_action( + env: ManagerBasedRLEnv, + action_names: Sequence[str] = ("arm_action", "gripper_action"), +) -> torch.Tensor: + """Terminate environments whose latest policy action contained a non-finite value. + + Args: + env: Manager-based conveyor environment. + action_names: Action terms exposing an ``invalid_actions`` Boolean tensor. + + Returns: + Per-environment invalid-action mask. + """ + if not action_names: + raise ValueError("At least one action term is required for invalid-action termination.") + masks = tuple(env.action_manager.get_term(name).invalid_actions for name in action_names) + return torch.stack(masks, dim=0).any(dim=0) + + +def subgoal_time_out( + env: ManagerBasedRLEnv, + timeout_s: float = 20.0, + command_name: str = "transfer", +) -> torch.Tensor: + """Truncate environments that make no transfer within one subgoal timeout [s].""" + if timeout_s <= 0.0: + raise ValueError("timeout_s must be positive.") + command = env.command_manager.get_term(command_name) + command.evaluate() + timeout_steps = math.ceil(timeout_s / env.step_dt) + elapsed = env.episode_length_buf - command.subgoal_start_steps + return (elapsed >= timeout_steps) & ~command.pending_success + + +def transfer_sequence_time_out( + env: ManagerBasedRLEnv, + maximum_transfers: int = 8, + command_name: str = "transfer", +) -> torch.Tensor: + """Truncate completed sequences so reset-state coverage remains fresh.""" + if maximum_transfers < 1: + raise ValueError("maximum_transfers must be positive.") + command = env.command_manager.get_term(command_name) + command.evaluate() + return command.transfer_counts >= maximum_transfers + + +def cube_out_of_workspace( + env: ManagerBasedRLEnv, + minimum: tuple[float, float, float] = ( + BELT_CENTER_X - BELT_HALF_STRAIGHT - _TRACK_X_CLEARANCE, + -1.05, + -0.05, + ), + maximum: tuple[float, float, float] = ( + BELT_CENTER_X + BELT_HALF_STRAIGHT + _TRACK_X_CLEARANCE, + 1.05, + 0.80, + ), +) -> torch.Tensor: + """Terminate when any cube leaves the complete guarded racetrack workspace.""" + positions = cube_values(env, "root_pos_w", all_cubes=True) + positions -= env.scene.env_origins.unsqueeze(1) + lower = positions.new_tensor(minimum) + upper = positions.new_tensor(maximum) + return torch.any((positions < lower) | (positions > upper), dim=(1, 2)) + + +def nonfinite_scene_state( + env: ManagerBasedRLEnv, + robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), +) -> torch.Tensor: + """Terminate environments containing nonfinite robot or cube state.""" + robot: Articulation = env.scene[robot_cfg.name] + invalid = ~torch.all(torch.isfinite(robot.data.joint_pos.torch), dim=1) + invalid |= ~torch.all(torch.isfinite(robot.data.joint_vel.torch), dim=1) + invalid |= ~torch.isfinite(cube_values(env, "root_state_w", all_cubes=True)).all(dim=(1, 2)) + return invalid diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index f3853a6c9a13..38028284b683 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -20,11 +20,17 @@ requires-python = ">=3.12" dependencies = [ "isaaclab", "isaaclab_assets", + "isaaclab_contrib", + "isaaclab_newton", + "isaaclab_physx", ] [tool.uv.sources] isaaclab = { path = "../isaaclab", editable = true } isaaclab_assets = { path = "../isaaclab_assets", editable = true } +isaaclab_contrib = { path = "../isaaclab_contrib", editable = true } +isaaclab_newton = { path = "../isaaclab_newton", editable = true } +isaaclab_physx = { path = "../isaaclab_physx", editable = true } [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" diff --git a/source/isaaclab_tasks/test/contrib/test_contrib_environments_kit.py b/source/isaaclab_tasks/test/contrib/test_contrib_environments_kit.py index 6372b740647b..ee50f506f538 100644 --- a/source/isaaclab_tasks/test/contrib/test_contrib_environments_kit.py +++ b/source/isaaclab_tasks/test/contrib/test_contrib_environments_kit.py @@ -30,4 +30,4 @@ @pytest.mark.parametrize("task_name", contrib_environment_params("kit")) def test_contrib_environments_kit(task_name): - _run_environments(task_name, device="cuda", num_envs=num_envs(task_name)) + _run_environments(task_name, device=None, num_envs=num_envs(task_name)) diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py new file mode 100644 index 000000000000..3ad79475f00d --- /dev/null +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py @@ -0,0 +1,55 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Durable geometry checks for the contributed conveyor Franka task.""" + +from collections import Counter + +from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_env_cfg import _collision_properties, _cube +from isaaclab_tasks.contrib.conveyor_franka.conveyor_geometry import ( + MeshSpec, + belt_mesh_spec, + guard_mesh_specs, +) + + +def _edge_use_counts(spec: MeshSpec) -> Counter[tuple[int, int]]: + """Count triangle uses of every undirected mesh edge.""" + edges: Counter[tuple[int, int]] = Counter() + for triangle in spec.faces: + for start, end in zip(triangle, triangle[1:] + triangle[:1], strict=True): + edges[tuple(sorted((start, end)))] += 1 + return edges + + +def test_belt_top_faces_point_upward(): + """One-sided triangle-mesh surfaces support parcels from above.""" + for side in ("Left", "Right"): + specs = [belt_mesh_spec(side), *guard_mesh_specs(side)] + for spec in specs: + assert set(_edge_use_counts(spec).values()) == {2} + _assert_top_faces_point_upward(spec) + + +def _assert_top_faces_point_upward(spec): + top_z = max(vertex[2] for vertex in spec.vertices) + for face in spec.faces: + vertices = tuple(spec.vertices[index] for index in face) + if all(vertex[2] == top_z for vertex in vertices): + a, b, c = vertices + cross_z = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + assert cross_z > 0.0 + + +def test_contact_configuration_uses_one_mujoco_parameterization(): + """Raw MuJoCo solref must not be combined with shadowed Newton force-space gains.""" + mujoco_cfg = _collision_properties()[-1] + cube_material = _cube("TestCube", (1.0, 0.0, 0.0), (0.0, 0.0, 0.0)).spawn.physics_material + + assert mujoco_cfg.solref is not None + assert cube_material.contact_stiffness is None + assert cube_material.contact_damping is None + assert cube_material.torsional_friction is None + assert cube_material.rolling_friction is None diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py new file mode 100644 index 000000000000..cb75f41feea3 --- /dev/null +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py @@ -0,0 +1,356 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Racetrack policy contracts exercised through real CPU managers and assets.""" + +from contextlib import closing + +import gymnasium as gym +import pytest +import torch +from rsl_rl.runners import OnPolicyRunner + +from isaaclab.utils import to_dict + +from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper + +import isaaclab_tasks # noqa: F401 +from isaaclab_tasks.contrib.conveyor_franka import mdp +from isaaclab_tasks.contrib.conveyor_franka.agents.rsl_rl_ppo_cfg import ConveyorGaussianBernoulliDistribution +from isaaclab_tasks.contrib.conveyor_franka.conveyor_geometry import ( + BELT_CENTER_X, + BELT_INNER_STRAIGHT_Y, + BELT_OUTER_STRAIGHT_Y, +) +from isaaclab_tasks.contrib.conveyor_franka.mdp.commands import select_next_transfer_cube +from isaaclab_tasks.contrib.conveyor_franka.mdp.curriculums import ( + deployment_probability_from_progress, + reset_sampling_probabilities, +) +from isaaclab_tasks.contrib.conveyor_franka.mdp.reset_events import ( + CUBE_COUNT, + ConveyorResetRecipe, + _balanced_cube_slots, + _sample_collision_free_active_x, + build_reset_rows, + franka_tool_position, + reset_variant_counts, +) +from isaaclab_tasks.contrib.conveyor_franka.mdp.rewards import transfer_potential +from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry, parse_env_cfg + +_BASE = "IsaacContrib-Conveyor-Racetrack-Transfer-v0" + + +def _environment(task): + cfg = parse_env_cfg(task, device="cpu", num_envs=2) + cfg.sim.physics.use_cuda_graph = False + cfg.sim.visualizer_cfgs = [] + cfg.sim.use_fabric = False + cfg.sim.save_logs_to_file = False + cfg.seed = 41 + return gym.make(task, cfg=cfg).unwrapped + + +def _parcels(env): + return tuple(env.scene[f"cube_{i}"] for i in range(env.cfg.conveyor_force.transported_body_count_per_env)) + + +def _waiting_positions(env): + positions = torch.tensor([[[2.5, 0.8, 0.5]] * len(_parcels(env))] * 2) + ids = torch.arange(positions.shape[1]) + positions[:, :, 0] = 2.4 + 0.06 * (ids % 6) + positions[:, :, 1] += 0.06 * (ids // 6) + return positions + + +def _place(env, positions): + for i, parcel in enumerate(_parcels(env)): + pose = parcel.data.default_root_pose.torch.clone() + pose[:, :3] = positions[:, i] + env.scene.env_origins + parcel.write_root_pose_to_sim_index(root_pose=pose, skip_forward=True) + parcel.write_root_velocity_to_sim_index(root_velocity=torch.zeros(2, 6), skip_forward=True) + env.sim.forward() + env.scene.update(env.step_dt) + + +def test_checkpoint_interface_and_invalid_action_recovery(): + """The real racetrack preserves learned feature order, finite rewards and independent resets.""" + with closing(_environment(_BASE)) as env: + obs, _ = env.reset() + assert obs["policy"].shape == (2, 123) + assert env.action_manager.total_action_dim == 8 + assert env.physics_dt == 1 / 120 and env.step_dt == 1 / 60 + result = env.step(torch.zeros(2, 8)) + assert torch.isfinite(result[0]["policy"]).all() and torch.isfinite(result[1]).all() + positions = _waiting_positions(env) + positions[:, :4] = torch.tensor([[0.4, 0.27, 0.06], [0.6, -0.27, 0.06], [0.8, 0, 0.2], [0.9, -0.27, 0.06]]) + positions[1, 2] = torch.tensor([0.8, -0.27, 0.2]) + _place(env, positions) + command = env.command_manager.get_term("transfer") + env.episode_length_buf[:] = torch.tensor([11, 19]) + command.set_goal(2) + policy = env.observation_manager.compute()["policy"] + expected = positions[:, :4] + torch.testing.assert_close(policy[:, 16:28], expected.flatten(1)) + torch.testing.assert_close(policy[:, 76:79], positions[:, 2]) + torch.testing.assert_close(policy[:, 85:89], torch.tensor([[0, 0, 1, 0]] * 2).float()) + torch.testing.assert_close(policy[:, 101:103], torch.tensor([[0, 1], [1, 0]]).float()) + torch.testing.assert_close( + policy[:, 89:101], + torch.tensor([[1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1]]).float(), + ) + assert command.held_cube_ids.tolist() == [-1, -1] + assert command.subgoal_start_steps.tolist() == [11, 19] + command.subgoal_start_steps[:] = env.episode_length_buf - torch.tensor([1199, 1200]) + assert mdp.subgoal_time_out(env, timeout_s=20).tolist() == [False, True] + command.pending_success[1] = True + assert not mdp.subgoal_time_out(env, timeout_s=20).any() + command.transfer_counts[:] = torch.tensor([7, 8]) + assert mdp.transfer_sequence_time_out(env, maximum_transfers=8).tolist() == [False, True] + command.pending_success.zero_() + arm = env.action_manager.get_term("arm_action") + gripper = env.action_manager.get_term("gripper_action") + previous = torch.tensor([[0.1] * 7 + [-1.0], [-0.2] * 7 + [1.0]]) + current = torch.tensor([[-0.3] * 7 + [1.0], [0.4] * 7 + [-1.0]]) + env.action_manager.process_action(previous) + env.action_manager.process_action(current) + torch.testing.assert_close(mdp.finite_action_rate_l2(env), (current - previous).square().sum(1)) + bad = torch.tensor( + [[float("nan"), float("inf"), -float("inf"), 5, -5, 0.5, -0.5, float("nan")], [0.5] * 7 + [float("inf")]] + ) + robot = env.scene["robot"] + joint_ids, _ = robot.find_joints("panda_joint[1-7]", preserve_order=True) + joints = robot.data.joint_pos.torch.clone() + initial = torch.tensor([0.05, 0.2, -0.1, -2.25, 0, 2.45, 0.775]).repeat(2, 1) + joints[:, joint_ids] = initial + robot.write_joint_position_to_sim_index(position=joints) + env.sim.forward() + env.scene.update(env.step_dt) + env.action_manager.process_action(bad) + torch.testing.assert_close( + arm.processed_actions, initial + torch.tensor([[0, 0.12, -0.12, 0.12, -0.12, 0.06, -0.06], [0.06] * 7]) + ) + torch.testing.assert_close(arm.raw_actions, torch.tensor([[0, 1, -1, 1, -1, 0.5, -0.5], [0.5] * 7])) + torch.testing.assert_close(gripper.raw_actions, torch.tensor([[-1.0], [1.0]])) + torch.testing.assert_close(gripper.processed_actions, torch.tensor([[0.0, 0.0], [0.04, 0.04]])) + assert torch.isfinite(arm.processed_actions).all() and torch.isfinite(gripper.processed_actions).all() + assert torch.isfinite(mdp.finite_action_rate_l2(env)).all() + bad[0, 7] = -1 + env.action_manager.process_action(bad) + assert mdp.invalid_action(env).tolist() == [True, True] + env.action_manager.reset(env_ids=[0]) + assert mdp.invalid_action(env).tolist() == [False, True] + env.action_manager.reset() + assert not mdp.invalid_action(env).any() + terminated = env.step(bad) + assert terminated[2].tolist() == [True, True] + assert torch.isfinite(terminated[0]["policy"]).all() and torch.isfinite(terminated[1]).all() + cfg = env.cfg.copy() + cfg.actions.arm_action.preserve_order = False + with pytest.raises(ValueError, match="preserve"): + cfg.validate() + + +def test_curriculum_checkpoint_restores_progress_and_deployment_outcomes(tmp_path): + """Actual RSL-RL saves preserve reset evidence; phase progress alone cannot advance deployment.""" + with closing(_environment(_BASE)) as env: + reset_cfg = env.event_manager.get_term_cfg("reset_from_state_table") + reset_cfg.params.update( + fixed_recipe=int(ConveyorResetRecipe.BELT), fixed_variant_id=mdp.BELT_DEPLOYMENT_VARIANT + ) + env.reset() + command = env.command_manager.get_term("transfer") + # Supply partial-progress outcomes at the curriculum's episode boundary. + env.episode_length_buf.fill_(5) + command.progress_ever_success.fill_(True) + env.reset() + log = env.extras["log"] + assert log["Curriculum/reset_sampling/overall_progress_rate"] == 1 + assert log["Curriculum/reset_sampling/deployment_transfer_success_rate"] == 0 + torch.testing.assert_close(log["Curriculum/reset_sampling/deployment_probability"], torch.tensor(0.35)) + env.episode_length_buf.fill_(5) + command.ever_success[0] = True + env.reset() + + agent = load_cfg_from_registry(_BASE, "rsl_rl_cfg_entry_point") + agent.device = "cpu" + agent.actor.hidden_dims = agent.critic.hidden_dims = [16] + runner = OnPolicyRunner(RslRlVecEnvWrapper(env), to_dict(agent), log_dir=str(tmp_path), device=agent.device) + curriculum = env.curriculum_manager.cfg.reset_sampling.func + expected = curriculum.get_state() + checkpoint = tmp_path / "model.pt" + runner.save(str(checkpoint), infos={"purpose": "resume"}) + assert "conveyor_reset_curriculum" in torch.load(checkpoint, weights_only=False) + env.episode_length_buf.fill_(5) + env.reset() + assert curriculum.get_state()["attempts"].sum() > expected["attempts"].sum() + assert runner.load(str(checkpoint), map_location="cpu") == {"purpose": "resume"} + for name, value in expected.items(): + torch.testing.assert_close(curriculum.get_state()[name], value) + # A policy-only load must not replace a fine-tuning run's curriculum. + env.episode_length_buf.fill_(5) + env.reset() + attempts = curriculum.get_state()["attempts"].sum() + runner.load(str(checkpoint), load_cfg={"actor": True}, map_location="cpu") + assert curriculum.get_state()["attempts"].sum() == attempts + + +def test_reset_bank_is_complete_and_physically_calibrated(): + """Every command/phase is represented once; tool anchors and held metadata agree with physical poses.""" + rows = build_reset_rows() + counts = reset_variant_counts() + keys = {(r.recipe, r.variant_id, r.target_cube_id, r.source_side_id) for r in rows} + assert len(keys) == len(rows) == sum(counts) * 8 + assert all( + recipe in ConveyorResetRecipe and 0 <= variant < counts[recipe] and 0 <= cube < 4 and side in (0, 1) + for recipe, variant, cube, side in keys + ) + for row in rows: + held = row.recipe in (ConveyorResetRecipe.LIFT, ConveyorResetRecipe.CARRY, ConveyorResetRecipe.PLACE) + held |= row.recipe == ConveyorResetRecipe.GRASP and row.variant_id == counts[ConveyorResetRecipe.GRASP] - 1 + assert row.held == held + closure = [ + r.finger_position + for r in rows + if r.recipe == ConveyorResetRecipe.GRASP and r.target_cube_id == 0 and r.source_side_id == 0 + ] + assert all(a > b for a, b in zip(closure, closure[1:])) + # Independently calibrated approach, lift, transit and release tool positions. + anchors = { + ConveyorResetRecipe.GOAL: (0, 0.14), + ConveyorResetRecipe.PLACE: (3, 0.105), + ConveyorResetRecipe.CARRY: (2, 0.25), + ConveyorResetRecipe.LIFT: (3, 0.22), + ConveyorResetRecipe.GRASP: (0, 0.06), + ConveyorResetRecipe.PREGRASP: (0, 0.14), + } + for row in rows: + if row.target_cube_id or row.recipe not in anchors or row.variant_id != anchors[row.recipe][0]: + continue + y = 0.27 * (1 - 2 * row.source_side_id) + if row.recipe in (ConveyorResetRecipe.GOAL, ConveyorResetRecipe.PLACE): + y = -y + elif row.recipe == ConveyorResetRecipe.CARRY: + y = 0 + actual = franka_tool_position(torch.tensor([row.arm_positions], dtype=torch.float64))[0] + torch.testing.assert_close( + actual, torch.tensor([0.52, y, anchors[row.recipe][1]], dtype=actual.dtype), atol=3e-4, rtol=0 + ) + + +def test_transfer_shaping_and_binary_policy_likelihoods(): + """Release improves progress; remote closing earns no grasp credit; sampled gripper likelihoods remain finite.""" + cubes = torch.tensor( + [[0.52, 0.27, 0.06], [0.52, 0.27, 0.20], [0.52, 0, 0.20], [0.52, -0.27, 0.105], [0.52, -0.27, 0.06]] + ) + tool = cubes.clone() + tool[-1, 2] += 0.1 + fingers = torch.full((5, 2), 0.019) + fingers[-1] = 0.04 + potential = transfer_potential(cubes, tool, fingers, torch.zeros(5, dtype=torch.long)) + assert (potential[1:] > potential[:-1]).all() + cubes = torch.tensor([[0.52, 0.27, 0.06]] * 2) + tool = torch.tensor([[0.52, 0.27, 0.07], [0.52, 0.27, 0.20]]) + credit = transfer_potential(cubes, tool, torch.full((2, 2), 0.019), torch.zeros(2, dtype=torch.long)) + credit -= transfer_potential(cubes, tool, torch.full((2, 2), 0.04), torch.zeros(2, dtype=torch.long)) + assert credit[0] > 0.5 and credit[1] < 0.03 + distribution = ConveyorGaussianBernoulliDistribution(output_dim=8) + distribution.update(torch.zeros(4096, 8)) + samples, previous = distribution.sample(), tuple(p.clone() for p in distribution.params) + distribution.update(torch.full((4096, 8), 0.2)) + divergence = distribution.kl_divergence(previous, distribution.params) + assert set(samples[:, -1].unique().tolist()) == {-1, 1} + assert torch.isfinite(distribution.log_prob(samples)).all() + assert torch.isfinite(divergence).all() and (divergence >= 0).all() + + +def test_curriculum_reserves_deployment_mass_and_balances_commands(): + """Adaptive weights cannot starve any recipe/cube/direction, or the moving-belt deployment starts.""" + rows = build_reset_rows() + recipe, variant, cube, side = torch.tensor( + [[r.recipe, r.variant_id, r.target_cube_id, r.source_side_id] for r in rows] + ).T + deployment = (recipe == ConveyorResetRecipe.BELT) & ( + variant == reset_variant_counts()[ConveyorResetRecipe.BELT] - 1 + ) + place = torch.where((recipe == ConveyorResetRecipe.PLACE) & (cube == 0) & (side == 0))[0] + weights = torch.ones(len(rows)) + weights[place[:2]] = torch.tensor([0.2, 0.8]) + probabilities = reset_sampling_probabilities(recipe, variant, cube, side, weights, deployment_probability=0.35) + torch.testing.assert_close(probabilities.sum(), torch.tensor(1.0)) + assert probabilities[place[0]] < probabilities[place[1]] + for r, c, s in {(r.recipe, r.target_cube_id, r.source_side_id) for r in rows}: + mask = (recipe == r) & (cube == c) & (side == s) & ~deployment + torch.testing.assert_close(probabilities[mask].sum(), torch.tensor(0.65 / (len(ConveyorResetRecipe) * 8))) + for s in (0, 1): + torch.testing.assert_close(probabilities[deployment & (side == s)].sum(), torch.tensor(0.35 / 2)) + for progress, coverage, expected in ((0.30, 1.0, 0.35), (0.625, 0.50, 0.625), (0.90, 1.0, 0.90)): + actual = deployment_probability_from_progress(torch.tensor(progress), torch.tensor(coverage)) + torch.testing.assert_close(actual, torch.tensor(expected)) + + +def test_next_transfer_cube_is_random_among_eligible_alternatives(): + """Continuing commands use the one-hot target instead of a cyclic identity shortcut.""" + count = 4096 + positions = torch.zeros((count, CUBE_COUNT, 3)) + positions[:, :, 1] = torch.tensor((-0.27, -0.27, -0.27, 0.27)) + current_cube_ids = torch.zeros(count, dtype=torch.long) + source_side_ids = torch.ones(count, dtype=torch.long) + torch.manual_seed(7) + + selected = select_next_transfer_cube(positions, current_cube_ids, source_side_ids) + + assert set(selected.tolist()) == {1, 2} + frequencies = torch.bincount(selected, minlength=CUBE_COUNT).float() / count + assert torch.all(torch.abs(frequencies[1:3] - 0.5) < 0.05) + + +def test_active_cube_sampling_avoids_inactive_source_lane_cubes(): + """Random deployment starts cannot begin with interpenetrating parcels.""" + count = 2048 + base_x = torch.tensor((0.26, 0.42, 0.72, 0.88)).expand(count, -1).clone() + cube_sides = torch.tensor((0, 0, 1, 1)).expand(count, -1).clone() + target_cube_ids = torch.arange(count) % CUBE_COUNT + source_side_ids = torch.arange(count) % 2 + cube_sides.scatter_(1, target_cube_ids.unsqueeze(1), source_side_ids.unsqueeze(1)) + + sampled = _sample_collision_free_active_x( + base_x, + cube_sides, + target_cube_ids, + source_side_ids, + torch.full((count,), 0.30), + torch.full((count,), 0.82), + ) + + cube_ids = torch.arange(CUBE_COUNT).expand(count, -1) + inactive_on_source = (cube_sides == source_side_ids.unsqueeze(1)) & (cube_ids != target_cube_ids.unsqueeze(1)) + separation = torch.abs(sampled.unsqueeze(1) - base_x) + assert torch.all(separation[inactive_on_source] >= 0.055) + + +def test_deployment_layout_uses_each_racetrack_straight_run_once(): + """Every command keeps its target reachable and distributes all four cubes evenly.""" + target_cube_ids = torch.arange(CUBE_COUNT).repeat_interleave(2) + source_side_ids = torch.arange(2).repeat(CUBE_COUNT) + + slots, cube_sides, cube_x, cube_y = _balanced_cube_slots(target_cube_ids, source_side_ids) + + expected_slots = torch.arange(CUBE_COUNT).expand(target_cube_ids.numel(), -1) + torch.testing.assert_close(torch.sort(slots, dim=1).values, expected_slots) + active_slots = slots.gather(1, target_cube_ids.unsqueeze(1)).squeeze(1) + torch.testing.assert_close(active_slots, 2 * source_side_ids) + assert torch.all(torch.sum(cube_sides == 0, dim=1) == 2) + assert torch.all(torch.sum(cube_sides == 1, dim=1) == 2) + + expected_y = torch.tensor( + (-BELT_OUTER_STRAIGHT_Y, -BELT_INNER_STRAIGHT_Y, BELT_INNER_STRAIGHT_Y, BELT_OUTER_STRAIGHT_Y) + ) + torch.testing.assert_close(torch.sort(cube_y, dim=1).values, expected_y.expand_as(cube_y)) + for side_id in (0, 1): + side_x = torch.where(cube_sides == side_id, cube_x, torch.nan) + expected_sum = torch.full((source_side_ids.numel(),), 2 * BELT_CENTER_X, dtype=cube_x.dtype) + torch.testing.assert_close(torch.nansum(side_x, dim=1), expected_sum) diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py new file mode 100644 index 000000000000..e9ba21acc88b --- /dev/null +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py @@ -0,0 +1,42 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Native PhysX conveyor device and authored geometry contracts.""" + +import pytest + +from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_env_cfg import ConveyorFrankaEnvCfg +from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_physx_env_cfg import physx_belt_section_specs +from isaaclab_tasks.contrib.conveyor_franka.conveyor_geometry import BELT_TURN_RADIUS +from isaaclab_tasks.utils.parse_cfg import parse_env_cfg + + +def test_physx_preserves_cpu_policy_and_pivot_conventions(): + """Unspecified parser devices retain CPU; native twists/materials preserve the trained interface.""" + cfg = parse_env_cfg("IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0", device=None, num_envs=2) + cfg.validate() + base = ConveyorFrankaEnvCfg() + assert cfg.sim.device == "cpu" and cfg.scene.num_envs == 2 + assert cfg.actions == base.actions and cfg.observations == base.observations + assert cfg.sim.dt == base.sim.dt == 1 / 120 and cfg.decimation == base.decimation == 2 + cfg.scene.configure_conveyor(friction_coefficient=0.42) + specs = cfg.scene.build_conveyor_belt_specs(velocity=0.21, friction_coefficient=0.42, contact_threshold=0.95) + assert len(specs) == 8 + assert all(s.velocity == 0.21 and s.friction_coefficient == 0.42 and s.contact_threshold == 0.95 for s in specs) + for side in ("Left", "Right"): + for section, root in physx_belt_section_specs(side)[2:]: + assert section.belt.curved + assert section.belt.pivot_point == (0, 0, 0) and section.belt.radius == BELT_TURN_RADIUS + assert root[2] == 0 + for key in ("top_straight", "bottom_straight", "right_turn", "left_turn"): + spawn = getattr(cfg.scene, f"conveyor_{side.lower()}_{key}_collision").spawn + assert spawn.physics_material.static_friction == spawn.physics_material.dynamic_friction == 0.42 + assert spawn.rigid_props.kinematic_enabled + assert ( + getattr(cfg.scene, f"conveyor_{side.lower()}_right_turn_collision").spawn.collision_approximation == "sdf" + ) + cfg.sim.device = "cuda:0" + with pytest.raises(ValueError, match="CPU-only.*--device cpu"): + cfg.validate() diff --git a/source/isaaclab_tasks/test/env_test_utils.py b/source/isaaclab_tasks/test/env_test_utils.py index 63fad921d720..f2bf3c31f2c6 100644 --- a/source/isaaclab_tasks/test/env_test_utils.py +++ b/source/isaaclab_tasks/test/env_test_utils.py @@ -231,7 +231,7 @@ def _configure_osc_smoke_actions(env, actions: torch.Tensor) -> None: def _run_environments( task_name, - device, + device: str | None, num_envs, num_steps=20, multi_agent=False, @@ -243,7 +243,7 @@ def _run_environments( Args: task_name: Name of the environment. - device: Device to use (e.g., 'cuda'). + device: Device override (e.g., 'cuda'), or None to preserve the task default. num_envs: Number of environments. num_steps: Number of simulation steps. multi_agent: Whether the environment is multi-agent. @@ -284,7 +284,7 @@ def _run_environments( def _check_random_actions( task_name: str, - device: str, + device: str | None, num_envs: int, num_steps: int = 20, multi_agent: bool = False, @@ -296,7 +296,7 @@ def _check_random_actions( Args: task_name: Name of the environment. - device: Device to use (e.g., 'cuda'). + device: Device override (e.g., 'cuda'), or None to preserve the task default. num_envs: Number of environments. num_steps: Number of simulation steps. multi_agent: Whether the environment is multi-agent. diff --git a/uv.lock b/uv.lock index b29e7408f333..28dee8f0ac09 100644 --- a/uv.lock +++ b/uv.lock @@ -2166,12 +2166,18 @@ source = { editable = "source/isaaclab_tasks" } dependencies = [ { name = "isaaclab" }, { name = "isaaclab-assets" }, + { name = "isaaclab-contrib" }, + { name = "isaaclab-newton" }, + { name = "isaaclab-physx" }, ] [package.metadata] requires-dist = [ { name = "isaaclab", editable = "source/isaaclab" }, { name = "isaaclab-assets", editable = "source/isaaclab_assets" }, + { name = "isaaclab-contrib", editable = "source/isaaclab_contrib" }, + { name = "isaaclab-newton", editable = "source/isaaclab_newton" }, + { name = "isaaclab-physx", editable = "source/isaaclab_physx" }, ] [[package]] From 8e36eb7c5ac0380ec846f6c6afe99695b075070f Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Tue, 6 Oct 2026 15:46:28 +0200 Subject: [PATCH 04/45] Add warehouse sorting with the shared conveyor policy --- .gitattributes | 2 + .gitignore | 1 + .../source/_static/css/environment-browser.js | 1 + .../tasks/conveyor/warehouse_sorting.jpg | Bin 0 -> 253589 bytes .../maximiliank-conveyor-warehouse.added.rst | 1 + .../maximiliank-conveyor-warehouse.minor | 0 .../contrib/conveyor_franka/README.md | 163 ++++++- .../contrib/conveyor_franka/__init__.py | 12 +- .../assets/conveyor_quarter_supported.usda | 21 + .../assets/conveyor_routes.usda | 3 + .../assets/conveyor_straight_supported.usda | 21 + .../conveyor_franka/assets/parcel.usda | 23 + .../conveyor_franka/assets/parcel_blue.usda | 60 +++ .../conveyor_franka/assets/parcel_green.usda | 60 +++ .../conveyor_franka/assets/parcel_orange.usda | 60 +++ .../conveyor_franka/assets/parcel_purple.usda | 60 +++ .../conveyor_franka/assets/warehouse.usda | 3 + .../conveyor_franka/conveyor_cube_pool.py | 86 ++++ .../conveyor_franka_asset_env_cfg.py | 445 ++++++++++++++++++ .../conveyor_franka_asset_terrain.py | 30 ++ .../conveyor_franka_warehouse_env.py | 155 ++++++ .../conveyor_warehouse_geometry.py | 226 +++++++++ .../contrib/conveyor_franka/mdp/commands.py | 3 + .../contrib/conveyor_franka/mdp/cube_state.py | 16 +- .../conveyor_franka/mdp/observations.py | 6 +- .../contrib/conveyor_franka/mdp/sorting.py | 128 +++++ source/isaaclab_tasks/pyproject.toml | 1 + .../contrib/test_conveyor_franka_asset_cfg.py | 223 +++++++++ .../contrib/test_conveyor_franka_geometry.py | 38 +- .../test/contrib/test_conveyor_franka_mdp.py | 218 ++++++++- 30 files changed, 2039 insertions(+), 27 deletions(-) create mode 100644 docs/source/_static/tasks/conveyor/warehouse_sorting.jpg create mode 100644 source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.added.rst create mode 100644 source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.minor create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_quarter_supported.usda create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_routes.usda create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_straight_supported.usda create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel.usda create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_blue.usda create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_green.usda create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_orange.usda create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_purple.usda create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/warehouse.usda create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_cube_pool.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_terrain.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_warehouse_geometry.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py create mode 100644 source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py diff --git a/.gitattributes b/.gitattributes index 5c27e0d2094a..3ae9fd3d23a7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,7 @@ *.usd filter=lfs diff=lfs merge=lfs -text *.usda filter=lfs diff=lfs merge=lfs -text +source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_*_supported.usda -filter -diff -merge text +source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel*.usda -filter -diff -merge text *.psd filter=lfs diff=lfs merge=lfs -text *.hdr filter=lfs diff=lfs merge=lfs -text *.dae filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 4bd3675e9df8..43f38ae35962 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ # No USD files allowed in the repo **/*.usd **/*.usda +!source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/*.usda **/*.usdc **/*.usdz diff --git a/docs/source/_static/css/environment-browser.js b/docs/source/_static/css/environment-browser.js index 76769b871605..f42b5b4bde7c 100644 --- a/docs/source/_static/css/environment-browser.js +++ b/docs/source/_static/css/environment-browser.js @@ -63,6 +63,7 @@ ["IsaacContrib-Cartpole-Showcase-Direct", "skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "box_box,box_discrete,box_multidiscrete,dict_box,dict_discrete,dict_multidiscrete,discrete_box,discrete_discrete,discrete_multidiscrete,multidiscrete_box,multidiscrete_discrete,multidiscrete_multidiscrete,tuple_box,tuple_discrete,tuple_multidiscrete"], ["IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0", "rsl_rl", "", "", "", "./tasks/conveyor/racetrack_transfer.jpg"], ["IsaacContrib-Conveyor-Racetrack-Transfer-v0", "rsl_rl", "", "", "", "./tasks/conveyor/racetrack_transfer.jpg"], + ["IsaacContrib-Conveyor-Warehouse-Sorting-v0", "rsl_rl", "", "", "", "./tasks/conveyor/warehouse_sorting.jpg"], ["IsaacContrib-Deploy-GearAssembly-Rizon4s-Grav", "rsl_rl", "", "", ""], ["IsaacContrib-Deploy-GearAssembly-Rizon4s-Grav-ROS-Inference", "rsl_rl", "", "", ""], ["IsaacContrib-Deploy-GearAssembly-UR10e-2F140", "rsl_rl", "", "", ""], diff --git a/docs/source/_static/tasks/conveyor/warehouse_sorting.jpg b/docs/source/_static/tasks/conveyor/warehouse_sorting.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0345c19ea3686c2c8be0dc1bc72f88b843d879ee GIT binary patch literal 253589 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 z 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 HpTYkFxh+Zp literal 0 HcmV?d00001 diff --git a/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.added.rst b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.added.rst new file mode 100644 index 000000000000..ddd7e5c24658 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.added.rst @@ -0,0 +1 @@ +* Added warehouse parcel sorting with four dynamically assigned policy slots, colored batches, circulating conveyors, and referenced USD presentation assets. diff --git a/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.minor b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.minor new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md index 4d9d5451d739..25e72627ab93 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md @@ -1,14 +1,28 @@ -# Conveyor racetrack transfer + -![Racetrack transfer](../../../../../docs/source/_static/tasks/conveyor/racetrack_transfer.jpg) +# Conveyor tasks + +Choose between two tasks using the same pretrained Franka policy: + +| Task | Layout and behavior | Newton task ID | +| --- | --- | --- | +| Racetrack transfer | Original two closed racetracks and four numbered cubes; continuous alternating transfers | `IsaacContrib-Conveyor-Racetrack-Transfer-v0` | +| Warehouse sorting | Current extended conveyors and 24 colored parcels; two colors per circulating conveyor | `IsaacContrib-Conveyor-Warehouse-Sorting-v0` | + +The sorter inherits the racetrack environment and configuration. Both reuse the same action, +observation, reward, and placement logic and the same RSL-RL agent configuration. Sorting adds +class dispatch and a four-slot parcel adapter for playback with the shared checkpoint. The original +task keeps its compact geometry and four fixed cube identities. Both use 123 observations, +eight actions, 120 Hz physics, and a 60 Hz policy rate. ## Backend support -The racetrack task runs on Newton GPU. `IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0` provides a CPU-only +Both tasks run on Newton GPU. `IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0` provides a CPU-only native PhysX reference for the original four-cube racetrack task. The PhysX task rejects CUDA during configuration validation. In the supported Isaac Sim runtime, @@ -40,7 +54,7 @@ DISPLAY=:1 uv run isaaclab play --rl_library rsl_rl \ ``` The `pretrained` selector downloads the RSL-RL policy published specifically for the Newton MJWarp -backend. The Newton checkpoint is published on Isaac Dev. While the public mirror is syncing, +backend. Both task keys are published on Isaac Dev. While the public mirror is syncing, use a local checkpoint path if the download is unavailable. The PhysX task resolves a different backend-specific artifact name, so transferring this Newton policy to PhysX currently requires the explicit local checkpoint path shown below. @@ -78,6 +92,141 @@ The report counts completed transfers in both directions, safety failures, non-f and transfers per simulated environment minute. Repeat with different seeds when comparing policies; intermediate-reset training reward alone does not establish deployment reliability. +## Warehouse sorting task + +Run the warehouse sorting task with the same checkpoint. The two parallel manipulation +straights and their adjoining 90-degree bends retain their original positions, widths, radii, +and 0.35 m/s surface speed. Beyond these fixed sections, two short rising feeds climb 0.10 m at less than 10 degrees and +join a shared elevated deck. Guides keep the two return lanes assigned through the upper split, +and separate descending conveyors deliver the parcels back to the original workcell approaches. +The new incline panels use slope-aligned traction and a 0.95 friction coefficient; the original +manipulation sections retain their trained 0.5 setting. + +Blue conveyor frames reach the floor, and the scanner faces along the background main belt after +a 90-degree counterclockwise rotation. The cubes wear SimReady cardboard meshes normalized to +**40 × 40 × 40 mm**, centered on their original colliders; mass remains 50 g. Actions and observation +ordering stay checkpoint-compatible. The sorting task widens its travel bounds and resets all parcels as a randomized mixed batch on the +raised supply belts. +The base training and PhysX tasks retain the original compact layout. + +The workcell contains **24 physical parcels**, exposed to the unchanged checkpoint through +four policy slots. The sorting command fills remote slots with misplaced arrivals; local assignments and an +active grasp stay pinned. Reassignment runs in the command manager, before the next policy observation, +and changes only identity mapping, never a physical pose. Commands, rewards, and placement checks +use the same mapping. All 24 parcels receive belt forces and participate in safety checks. +During playback, the arm parks when there is no active sorting transfer. The policy sees remote +inventory in canonical waiting slots; +all local parcel poses and velocities, physical transport, rewards, and transfer checks remain +actual simulated states. This adapter is necessary because the checkpoint was trained on compact, +flat returns. Tensor ordering stays 123 observations to eight actions, but remote observation values +are intentionally adapted. Invalid actions still reach the original sanitization and termination path. +The checkpoint can still miss grasps and reset before a batch finishes. The larger randomized +inventory preserves the policy interface, but reliable complete-batch sorting is not established. + +The near loop has a rounded return; the far loop has a shorter squared return with rounded corners +and a shorter supply belt. Both retain the exact original manipulation geometry. Oversized yellow +drive blocks are omitted from the workcell. + +The batch contains six cartons in each of four clearly marked colors: **blue, orange, green, +and purple**. Colored paper bands wrap the textured cardboard without changing its 40 mm bounds. +Reset shuffles physical identities across 24 supply positions, independently +for each environment, using the simulation's seeded random generator. Counts remain balanced while +arrival order and initial conveyor assignments vary. Colors stay fixed throughout each batch. +The 0.043–0.052 m/s feeds release cartons through **12 cm gravity drops** onto the 0.35 m/s loops. +Blue and green belong on the positive-Y loop; orange and purple belong on the negative-Y loop. +The dispatcher requests only wrong-lane transfers and retains ownership through grasp and stable +release. Missed arrivals recirculate for another opportunity. Once the batch is sorted, the arm +parks and both loops continue running. No parcels are recolored, teleported, or replaced during sorting. + +Class assignment is explicit supervisory metadata, not a claim that the unchanged state-based +checkpoint recognizes color. `commands.transfer.parcel_colors` assigns appearances and +`commands.transfer.parcel_destinations` assigns loop IDs; all parcels of a color must share a +single destination. `commands.transfer.randomize_arrivals=False` uses the authored ordering. +`sorted_parcels` counts settled, correctly routed inventory; `batch_complete` indicates that all +24 parcels are settled on their assigned loops. + +The surrounding warehouse includes loaded rack aisles, packing shelves, pallet staging, scan and +outbound signs, safety markings, overhead beams, and warm/cool industrial lighting. Its additional +23.11 m parcel loop includes A29 elevated runs and A38 ramps, with twelve animated cartons traveling +along the main transport line. These background cartons remain render-only inventory and do not +add contacts or policy observations. The 24 small workcell parcels have real collision and +dynamics. A Y-divert dresses the outbound bay. + +Use **Kit/RTX** to see the authored MDL textures, USD lights, and background animation: + +```bash +DISPLAY=:1 uv run --extra isaacsim isaaclab play --rl_library rsl_rl \ + --task IsaacContrib-Conveyor-Warehouse-Sorting-v0 \ + --checkpoint pretrained --num_envs 1 --device cuda:0 --viz kit --real-time \ + --kit_args=--/UJITSO/geometry=false +``` + +Kit renders the USD directly, so the Play configuration excludes visual-only meshes from the +Newton model (`sim.physics.load_visual_shapes=False`). This avoids importing warehouse dressing +into the physics model. For a static approximation in `--viz newton_gl`, explicitly pass +`env.sim.physics.load_visual_shapes=True`; materials and lighting are simplified in that viewer. +The launch override disables experimental geometry streaming to prevent disappearing meshes with +Fabric transforms. Presentation defaults to one environment. Asset references and textures are downloaded and cached +on first use, so the first launch takes longer. + +### Fine-tuning + +Warehouse training starts the robot at home and parcels on the actual supply feeds. It disables +the racetrack reset curriculum and passes every sampled policy action through, including idle +steps. Parking is enabled only by the playback configuration so PPO trains on its own actions. +Use the shared racetrack checkpoint to warm-start warehouse fine-tuning: + +```bash +uv run isaaclab train --rl_library rsl_rl \ + --task IsaacContrib-Conveyor-Warehouse-Sorting-v0 \ + --checkpoint /path/to/model.pt --num_envs 256 --device cuda:0 +``` + +This provides a consistent fine-tuning path; reliable full-batch sorting still requires evaluation. + +### Editing the presentation + +* `assets/warehouse.usda` owns the warehouse layout, referenced props, materials, lights, cameras, + and looping parcel transform samples. It can be opened in a USD authoring application with an + Omniverse-compatible asset resolver. `Cameras/Workcell` and `Cameras/Overview` provide two views. +* `assets/parcel_{blue,orange,green,purple}.usda` reference the carton, tint its cardboard, and + add a colored paper band within the original bounds. +* `assets/parcel.usda` normalizes the measured SimReady `cardbox_a1` visual bounds to a centered + 40 mm cube. Keep the shell bounds aligned with the task's original collider when editing it. +* `assets/conveyor_*_supported.usda` reference the source conveyors and describe their support + extension with two scalar attributes. The visual spawner stretches only the lower frame on load; + mesh vertices and normals are not bundled in the package. +* `assets/conveyor_routes.usda` owns the elevated network's centerlines, frame meshes, material + references, ramp traction, feed speeds, and mixed-batch spawn positions. Physics reads these same paths after application startup; + USD is not imported during task discovery. Incline normals follow each panel's slope. All 24 + physical parcel spawn positions are authored in `conveyor:parcelSpawnPositions`. +* The Python adapter resolves remote references into the normal local asset cache, removes all + imported physics/action-graph ownership, and samples background animation using simulation time. + `conveyor_warehouse_geometry.py` sweeps lightweight collision proxies along the authored paths. + `mdp/sorting.py` extends the shared transfer command with class dispatch and batch metrics; + the action, observation, reward, and stable-placement contracts remain shared with the trained task. +* `env.conveyor_cube_pool.slot_ids` maps each environment's four policy slots to physical parcel IDs. + `transfer_counts` records placements per physical parcel; assignments never duplicate a parcel + within an environment, and resetting one environment does not change another's slots. + +### Asset and visual references + +The asset survey covered SimReady Central (`simready-central.nvidia.com`), +`omniverse://ov-isaac-dev.nvidia.com/Isaac/SimReady/Industrial/Warehouse`, +`Isaac/Environments/{Digital_Twin_Warehouse,Modular_Warehouse}`, `Isaac/Props/Conveyors`, and +`NVIDIA/Assets/DigitalTwin/Assets/Warehouse`. The composition references publicly accessible +Omniverse counterparts so playback does not require internal Nucleus credentials. Public catalog +usage is described in the [SimReady Explorer documentation](https://docs.omniverse.nvidia.com/extensions/latest/ext_core/ext_browser-extensions/simready-explorer.html). + +Selected assets are the Omniverse A03/A09/A12/A24/A29/A38 conveyors, `RackLarge_A1`, SimReady +`bulkstoragerack_a01` and `cardbox_a1`, the Isaac packing table, and loaded pallets. NVIDIA assets +remain under their original licenses; this package contains authored scene composition and support parameters; NVIDIA geometry and textures remain external references. +The layout draws on [Dematic's modular conveyor examples](https://www.dematic.com/content/dam/dematic/downloads/brochures/NA_BR_1039_MCS.pdf) +and [KION's warehouse installation photographs](https://www.kiongroup.com/en/News-Stories/Stories/Innovation/Warehousing-is-being-transformed.html): +parallel transport, elevation changes, rack storage, packing zones, and clear marked aisles. +The induction and recirculation layout also follows the concepts in +[Dematic’s sortation overview](https://www.dematic.com/content/dam/dematic/downloads/whitepapers/NA_WP-1015_Sorting-Out-Sortation.pdf). + ## PhysX CPU playback The native PhysX variant requires an Isaac Sim-enabled launch and an explicit CPU device. One diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py index bcb810f13ff1..24f7b95a3cc0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Four-cube racetrack transfer with Newton and native CPU PhysX backends.""" +"""Four-cube racetrack transfer and warehouse sorting tasks sharing a Franka policy.""" import gymnasium as gym @@ -19,6 +19,16 @@ }, ) +gym.register( + id="IsaacContrib-Conveyor-Warehouse-Sorting-v0", + entry_point=f"{__name__}.conveyor_franka_warehouse_env:ConveyorFrankaWarehouseEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.conveyor_franka_asset_env_cfg:ConveyorFrankaA09A12EnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:ConveyorFrankaPPORunnerCfg", + }, +) + gym.register( # The native PhysxSurfaceVelocityAPI path is intentionally CPU-only. Keep # that execution contract visible in the public task ID so a CUDA launch is diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_quarter_supported.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_quarter_supported.usda new file mode 100644 index 000000000000..628d67830e98 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_quarter_supported.usda @@ -0,0 +1,21 @@ +#usda 1.0 +( + defaultPrim = "Conveyor" +) + +def Xform "Conveyor" ( + prepend references = @https://omniverse-content-production.s3-us-west-2.amazonaws.com/Assets/Isaac/6.1/Isaac/Props/Conveyors/ConveyorBelt_A03.usd@ +) +{ + over "SM_ConveyorBelt_A03" + { + custom double conveyor:supportAnchor = 24.452552917378103 + custom double conveyor:supportScale = 3.448973206624863 + } + + over "SM_ConveyorBelt_A03_Decal" + { + custom double conveyor:supportAnchor = 140.0000003367605 + custom double conveyor:supportScale = 3.448973193849789 + } +} diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_routes.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_routes.usda new file mode 100644 index 000000000000..c35390b0218f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_routes.usda @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ffa5fa3f772265e0d6e563b5ffdf3ea315602dd45ce4a2205a30477aee6e572 +size 1045928 diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_straight_supported.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_straight_supported.usda new file mode 100644 index 000000000000..f99552edf67e --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_straight_supported.usda @@ -0,0 +1,21 @@ +#usda 1.0 +( + defaultPrim = "Conveyor" +) + +def Xform "Conveyor" ( + prepend references = @https://omniverse-content-production.s3-us-west-2.amazonaws.com/Assets/Isaac/6.1/Isaac/Props/Conveyors/ConveyorBelt_A09.usd@ +) +{ + over "SM_ConveyorBelt_A09_02" + { + custom double conveyor:supportAnchor = 24.45254509724512 + custom double conveyor:supportScale = 3.448973216496531 + } + + over "SM_ConveyorBelt_A09_Decal_02" + { + custom double conveyor:supportAnchor = -28.255675946768882 + custom double conveyor:supportScale = 3.448973111935675 + } +} diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel.usda new file mode 100644 index 000000000000..cdd38bc17061 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel.usda @@ -0,0 +1,23 @@ +#usda 1.0 +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +( + defaultPrim = "Parcel" + doc = "Conveyor Franka presentation. NVIDIA assets remain external references; this layer owns only layout and appearance. SPDX-License-Identifier: BSD-3-Clause" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Parcel" +{ + def Xform "Cardboard" ( + prepend references = @https://omniverse-content-production.s3-us-west-2.amazonaws.com/Assets/simready_content/common_assets/props/cardbox_a1/cardbox_a1.usd@ + ) + { + float3 xformOp:scale = (0.057189006, 0.07675307, 0.07844259) + double3 xformOp:translate = (-0, 8.463453207854634e-8, -0.02) + uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"] + } +} diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_blue.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_blue.usda new file mode 100644 index 000000000000..684b6cbe54aa --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_blue.usda @@ -0,0 +1,60 @@ +#usda 1.0 +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +( + defaultPrim = "Parcel" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Parcel" ( + prepend references = @parcel.usda@ +) +{ + over "Cardboard" + { + over "cardbox_a1_inst" + { + over "Looks" + { + over "opaque__cardboard__cardbox" + { + over "Shader" + { + custom color3f inputs:diffuse_tint = (0.22, 0.5, 1) + } + } + } + } + } + + def "Looks" + { + def Material "ClassBand" + { + token outputs:surface.connect = + + def Shader "Shader" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.035, 0.25, 0.85) + float inputs:roughness = 0.58 + token outputs:surface + } + } + } + + def Mesh "ClassBand" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform bool doubleSided = 1 + int[] faceVertexCounts = [4, 4, 4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + rel material:binding = + point3f[] points = [(-0.009, -0.02, 0.02), (0.009, -0.02, 0.02), (0.009, 0.02, 0.02), (-0.009, 0.02, 0.02), (-0.009, -0.02, -0.02), (-0.009, 0.02, -0.02), (0.009, 0.02, -0.02), (0.009, -0.02, -0.02), (-0.009, -0.02, -0.02), (0.009, -0.02, -0.02), (0.009, -0.02, 0.02), (-0.009, -0.02, 0.02), (-0.009, 0.02, -0.02), (-0.009, 0.02, 0.02), (0.009, 0.02, 0.02), (0.009, 0.02, -0.02)] + uniform token subdivisionScheme = "none" + } +} diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_green.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_green.usda new file mode 100644 index 000000000000..2360ff252987 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_green.usda @@ -0,0 +1,60 @@ +#usda 1.0 +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +( + defaultPrim = "Parcel" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Parcel" ( + prepend references = @parcel.usda@ +) +{ + over "Cardboard" + { + over "cardbox_a1_inst" + { + over "Looks" + { + over "opaque__cardboard__cardbox" + { + over "Shader" + { + custom color3f inputs:diffuse_tint = (0.32, 0.9, 0.36) + } + } + } + } + } + + def "Looks" + { + def Material "ClassBand" + { + token outputs:surface.connect = + + def Shader "Shader" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.04, 0.65, 0.18) + float inputs:roughness = 0.58 + token outputs:surface + } + } + } + + def Mesh "ClassBand" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform bool doubleSided = 1 + int[] faceVertexCounts = [4, 4, 4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + rel material:binding = + point3f[] points = [(-0.009, -0.02, 0.02), (0.009, -0.02, 0.02), (0.009, 0.02, 0.02), (-0.009, 0.02, 0.02), (-0.009, -0.02, -0.02), (-0.009, 0.02, -0.02), (0.009, 0.02, -0.02), (0.009, -0.02, -0.02), (-0.009, -0.02, -0.02), (0.009, -0.02, -0.02), (0.009, -0.02, 0.02), (-0.009, -0.02, 0.02), (-0.009, 0.02, -0.02), (-0.009, 0.02, 0.02), (0.009, 0.02, 0.02), (0.009, 0.02, -0.02)] + uniform token subdivisionScheme = "none" + } +} diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_orange.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_orange.usda new file mode 100644 index 000000000000..8bb032846167 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_orange.usda @@ -0,0 +1,60 @@ +#usda 1.0 +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +( + defaultPrim = "Parcel" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Parcel" ( + prepend references = @parcel.usda@ +) +{ + over "Cardboard" + { + over "cardbox_a1_inst" + { + over "Looks" + { + over "opaque__cardboard__cardbox" + { + over "Shader" + { + custom color3f inputs:diffuse_tint = (1, 0.55, 0.22) + } + } + } + } + } + + def "Looks" + { + def Material "ClassBand" + { + token outputs:surface.connect = + + def Shader "Shader" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.95, 0.24, 0.025) + float inputs:roughness = 0.58 + token outputs:surface + } + } + } + + def Mesh "ClassBand" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform bool doubleSided = 1 + int[] faceVertexCounts = [4, 4, 4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + rel material:binding = + point3f[] points = [(-0.009, -0.02, 0.02), (0.009, -0.02, 0.02), (0.009, 0.02, 0.02), (-0.009, 0.02, 0.02), (-0.009, -0.02, -0.02), (-0.009, 0.02, -0.02), (0.009, 0.02, -0.02), (0.009, -0.02, -0.02), (-0.009, -0.02, -0.02), (0.009, -0.02, -0.02), (0.009, -0.02, 0.02), (-0.009, -0.02, 0.02), (-0.009, 0.02, -0.02), (-0.009, 0.02, 0.02), (0.009, 0.02, 0.02), (0.009, 0.02, -0.02)] + uniform token subdivisionScheme = "none" + } +} diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_purple.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_purple.usda new file mode 100644 index 000000000000..b57791c3d559 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel_purple.usda @@ -0,0 +1,60 @@ +#usda 1.0 +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +( + defaultPrim = "Parcel" + metersPerUnit = 1 + upAxis = "Z" +) + +def Xform "Parcel" ( + prepend references = @parcel.usda@ +) +{ + over "Cardboard" + { + over "cardbox_a1_inst" + { + over "Looks" + { + over "opaque__cardboard__cardbox" + { + over "Shader" + { + custom color3f inputs:diffuse_tint = (0.8, 0.27, 1) + } + } + } + } + } + + def "Looks" + { + def Material "ClassBand" + { + token outputs:surface.connect = + + def Shader "Shader" + { + uniform token info:id = "UsdPreviewSurface" + color3f inputs:diffuseColor = (0.5, 0.065, 0.8) + float inputs:roughness = 0.58 + token outputs:surface + } + } + } + + def Mesh "ClassBand" ( + prepend apiSchemas = ["MaterialBindingAPI"] + ) + { + uniform bool doubleSided = 1 + int[] faceVertexCounts = [4, 4, 4, 4] + int[] faceVertexIndices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + rel material:binding = + point3f[] points = [(-0.009, -0.02, 0.02), (0.009, -0.02, 0.02), (0.009, 0.02, 0.02), (-0.009, 0.02, 0.02), (-0.009, -0.02, -0.02), (-0.009, 0.02, -0.02), (0.009, 0.02, -0.02), (0.009, -0.02, -0.02), (-0.009, -0.02, -0.02), (0.009, -0.02, -0.02), (0.009, -0.02, 0.02), (-0.009, -0.02, 0.02), (-0.009, 0.02, -0.02), (-0.009, 0.02, 0.02), (0.009, 0.02, 0.02), (0.009, 0.02, -0.02)] + uniform token subdivisionScheme = "none" + } +} diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/warehouse.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/warehouse.usda new file mode 100644 index 000000000000..b1761c6a78e7 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/warehouse.usda @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83be26fa2e0473b4d0d1da2978e3f8ca6e566fc64cea1fb19ede9b81fc444274 +size 458282 diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_cube_pool.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_cube_pool.py new file mode 100644 index 000000000000..ce59b18507ea --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_cube_pool.py @@ -0,0 +1,86 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Stable physical parcel identities behind the checkpoint's four observation slots.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from .mdp.reset_events import CUBE_COUNT + +if TYPE_CHECKING: + from isaaclab.assets import RigidObject + + +class ConveyorCubePool: + """Bind four policy slots to a larger pool without moving or copying physical bodies. + + Assignments are independent per environment. Local parcels keep their slots; + the active grasp is additionally pinned even if it moves outside the workcell. + """ + + def __init__(self, assets: tuple[RigidObject, ...], num_envs: int, device: str) -> None: + if len(assets) < CUBE_COUNT: + raise ValueError("The conveyor policy requires at least four physical parcels.") + self.assets = assets + self.slot_ids = torch.arange(CUBE_COUNT, device=device).repeat(num_envs, 1) + self.assignment_counts = torch.zeros((num_envs, len(assets)), device=device, dtype=torch.long) + self.assignment_counts[:, :CUBE_COUNT] = 1 + self.transfer_counts = torch.zeros_like(self.assignment_counts) + + def reset(self, env_ids: Sequence[int] | torch.Tensor) -> None: + """Restore slot identities and fresh batch counters for selected environments.""" + self.slot_ids[env_ids] = torch.arange(CUBE_COUNT, device=self.slot_ids.device) + self.assignment_counts[env_ids] = 0 + self.assignment_counts[env_ids, :CUBE_COUNT] = 1 + self.transfer_counts[env_ids] = 0 + + def refresh( + self, + positions: torch.Tensor, + local: torch.Tensor, + candidates: torch.Tensor, + target_slots: torch.Tensor, + pinned: torch.Tensor, + ) -> torch.Tensor: + """Assign arriving parcels to remote slots, preserving all local and pinned identities. + + Args: + positions: Physical parcel positions in the workspace [m], shape ``(N, P, 3)``. + local: Parcels still in the manipulation region, shape ``(N, P)``. + candidates: Parcels eligible for pickup, shape ``(N, P)``. + target_slots: Current command's policy slot, shape ``(N,)``. + pinned: Whether the active parcel must retain its slot, shape ``(N,)``. + + Returns: + Environments whose slot assignments changed, shape ``(N,)``. + """ + mapped = torch.zeros_like(candidates).scatter_(1, self.slot_ids, True) + available = candidates & ~mapped + reusable = ~local.gather(1, self.slot_ids) + reusable.scatter_(1, target_slots[:, None], ~pinned[:, None] & reusable.gather(1, target_slots[:, None])) + changed = torch.zeros_like(pinned) + # Prefer parcels assigned less often, then the arrival with more belt travel remaining. + priority = positions[..., 0] - 10.0 * self.assignment_counts + for slot in range(CUBE_COUNT): + eligible = reusable[:, slot] & available.any(dim=1) + rows = eligible.nonzero(as_tuple=False).flatten() + if not rows.numel(): + continue + choices = torch.where(available, priority, -torch.inf).argmax(dim=1)[rows] + self.slot_ids[rows, slot] = choices + self.assignment_counts[rows, choices] += 1 + available[rows, choices] = False + changed[rows] = True + return changed + + def record_transfers(self, env_ids: torch.Tensor, target_slots: torch.Tensor) -> None: + """Credit stable placements to physical parcels before the command selects its next slot.""" + physical_ids = self.slot_ids[env_ids, target_slots] + self.transfer_counts[env_ids, physical_ids] += 1 diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py new file mode 100644 index 000000000000..1c7f375b4a2f --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py @@ -0,0 +1,445 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Digital Twin warehouse visuals for conveyor-Franka playback.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import cache +from pathlib import Path +from typing import TYPE_CHECKING + +import isaaclab.sim as sim_utils +from isaaclab.assets import AssetBaseCfg +from isaaclab.terrains import TerrainImporterCfg +from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, retrieve_file_path +from isaaclab.utils.configclass import configclass + +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec + +if TYPE_CHECKING: + from pxr import Sdf, Usd + + from isaaclab.terrains import TerrainImporter + +from . import mdp +from .conveyor_franka_env_cfg import ( + ConveyorFrankaEnvCfg, + ConveyorFrankaSceneCfg, + _hidden_collision_geometry, + _spawn_shape_with_display_color, +) +from .conveyor_geometry import ( + BELT_CENTER_X, + BELT_CENTER_Y, + BELT_HALF_STRAIGHT, + BELT_TOP_Z, + BELT_TURN_RADIUS, +) +from .conveyor_warehouse_geometry import warehouse_belt_sections, warehouse_guard_meshes + +_THOR_TABLE_ASSET_PATH = f"{ISAAC_NUCLEUS_DIR}/Props/Mounts/thor_table.usd" + +# The A12 endpoints are 2.9922 m apart, its belt crown is 1.78053 m above the +# asset origin. USD point overrides extend the legs to the floor while keeping +# the upper frames unchanged; the policy workspace follows the deck elevation. +_A12_ENDPOINT_SEPARATION = 2.9922 +_ASSET_BELT_TOP_Z = 1.78053 +_ASSET_XY_SCALE = 2.0 * BELT_TURN_RADIUS / _A12_ENDPOINT_SEPARATION +_CONVEYOR_SUPPORT_Z = 0.55 +# Preserve the upper frames' proportions. The scaled belt crown determines +# how far to elevate the policy workspace. +_ASSET_Z_SCALE = _ASSET_XY_SCALE +_ASSET_ROOT_Z = _CONVEYOR_SUPPORT_Z +_ASSET_BELT_WORLD_Z = _ASSET_ROOT_Z + _ASSET_BELT_TOP_Z * _ASSET_Z_SCALE +_WORKSPACE_ELEVATION = _ASSET_BELT_WORLD_Z - BELT_TOP_Z + +# A09 is a 4 m straight. Its travel-axis scale is independent of the common +# lateral scale so one asset spans the task's complete 0.88 m straight run. +_A09_LENGTH = 4.0 +_A09_X_SCALE = 2.0 * BELT_HALF_STRAIGHT / _A09_LENGTH + +# The Thor table authors its mounting surface at local z=0 and its lowest foot +# at z=-0.795 m. Uniformly scaling that distance to the elevated robot base +# puts every foot on the global ground without distorting the table. +_THOR_TABLE_LOWEST_Z = -0.795 +_THOR_TABLE_SCALE = _WORKSPACE_ELEVATION / -_THOR_TABLE_LOWEST_Z + + +_PHYSICS_SCHEMA_PREFIXES = ("Physics", "Physx", "Newton", "Mujoco") +_PHYSICS_SCHEMA_NAMES = frozenset(("IsaacConveyorAPI",)) +_PRESENTATION_ASSETS = Path(__file__).parent / "assets" + + +@cache +def _presentation_layer(usd_path: str) -> Sdf.Layer: + """Resolve a local composition's remote assets without modifying its source layer.""" + from pxr import Sdf, UsdUtils + + source = Sdf.Layer.FindOrOpen(usd_path) + layer = Sdf.Layer.CreateAnonymous(Path(usd_path).name) + layer.TransferContent(source) + resolved = {} + for path in source.GetExternalReferences(): + if path.startswith("https://"): + resolved[path] = retrieve_file_path(path) + else: + local_path = Sdf.ComputeAssetPathRelativeToLayer(source, path) + resolved[path] = _presentation_layer(local_path).identifier + UsdUtils.ModifyAssetPaths( + layer, + lambda path: resolved[path] if path in resolved else Sdf.ComputeAssetPathRelativeToLayer(source, path), + ) + return layer + + +@sim_utils.clone +def _spawn_authored_visual( + prim_path: str, + cfg: sim_utils.UsdFileCfg, + translation: tuple[float, float, float] | None = None, + orientation: tuple[float, float, float, float] | None = None, + **kwargs, +) -> Usd.Prim: + """Compose USD scenery with cached dependencies and no simulation ownership.""" + layer = _presentation_layer(cfg.usd_path) + prim = sim_utils.create_prim( + prim_path, + translation=translation, + orientation=orientation, + scale=cfg.scale, + ) + prim.GetReferences().AddReference(layer.identifier) + sim_utils.make_uninstanceable(prim_path) + _make_usd_subtree_visual_only(prim) + _extend_conveyor_supports(prim) + return prim + + +def _extend_conveyor_supports(root_prim) -> None: + """Apply authored support parameters to referenced meshes without bundling geometry.""" + import numpy as np + + from pxr import Usd, UsdGeom, Vt + + for prim in Usd.PrimRange(root_prim): + anchor_attr = prim.GetAttribute("conveyor:supportAnchor") + if not anchor_attr or not anchor_attr.HasAuthoredValueOpinion(): + continue + anchor = anchor_attr.Get() + scale = prim.GetAttribute("conveyor:supportScale").Get() + mesh = UsdGeom.Mesh(prim) + points = np.asarray(mesh.GetPointsAttr().Get()).copy() + lower = points[:, 2] < anchor + points[lower, 2] = anchor + scale * (points[lower, 2] - anchor) + mesh.GetPointsAttr().Set(Vt.Vec3fArray.FromNumpy(points)) + mesh.GetExtentAttr().Set(Vt.Vec3fArray.FromNumpy(np.stack((points.min(axis=0), points.max(axis=0))))) + # Face-varying normals follow the deformed surface, including faces crossing the anchor. + counts = np.asarray(mesh.GetFaceVertexCountsAttr().Get()) + indices = np.asarray(mesh.GetFaceVertexIndicesAttr().Get()) + starts = np.concatenate(([0], np.cumsum(counts)[:-1])) + normal = np.cross( + points[indices[starts + 1]] - points[indices[starts]], + points[indices[starts + 2]] - points[indices[starts]], + ) + normal /= np.maximum(np.linalg.norm(normal, axis=1, keepdims=True), 1e-12) + mesh.GetNormalsAttr().Set(Vt.Vec3fArray.FromNumpy(np.repeat(normal, counts, axis=0))) + mesh.SetNormalsInterpolation(UsdGeom.Tokens.faceVarying) + + +@sim_utils.clone +def _spawn_carton_cube( + prim_path: str, + cfg: _ParcelCuboidCfg, + translation: tuple[float, float, float] | None = None, + orientation: tuple[float, float, float, float] | None = None, + **kwargs, +) -> Usd.Prim: + """Dress the original 40 mm collider with a centered, equally sized SimReady carton.""" + from pxr import UsdGeom + + prim = _spawn_shape_with_display_color(prim_path, cfg, translation, orientation, **kwargs) + UsdGeom.Imageable(prim.GetStage().GetPrimAtPath(f"{prim_path}/geometry/mesh")).MakeInvisible() + visual_cfg = sim_utils.UsdFileCfg(usd_path=cfg.parcel_usd_path) + _spawn_authored_visual(f"{prim_path}/CartonVisual", visual_cfg) + return prim + + +@configclass +class _ParcelCuboidCfg(sim_utils.CuboidCfg): + """Original task collider with a separately authored carton appearance.""" + + parcel_usd_path: str = str(_PRESENTATION_ASSETS / "parcel.usda") + """USD visual, normalized to the task's 40 mm cube.""" + + +def _is_physics_schema(schema_name: str) -> bool: + """Return whether an applied schema can add physics ownership to a visual asset.""" + return schema_name in _PHYSICS_SCHEMA_NAMES or schema_name.startswith(_PHYSICS_SCHEMA_PREFIXES) + + +def _make_usd_subtree_visual_only(root_prim) -> None: + """Author a render-only override for a referenced USD subtree. + + The source asset remains untouched. All edits are stronger opinions in the + task stage and fail closed if a physics schema cannot be removed. + """ + # Import USD only when a stage exists. Besides respecting Kit startup + # ordering, this keeps import-light task discovery kitless. + from pxr import Sdf, Usd, UsdPhysics + + children = tuple(Usd.PrimRange(root_prim, Usd.TraverseInstanceProxies())) + instance_proxies = tuple(str(child.GetPath()) for child in children if child.IsInstanceProxy()) + if instance_proxies: + raise RuntimeError( + "Visual-only USD overrides require editable descendants; set make_uninstanceable=True. " + f"Found instance proxies below {root_prim.GetPath()}: {instance_proxies[:3]}" + ) + + with Sdf.ChangeBlock(): + for child in children: + if child.GetTypeName().startswith("OmniGraph") or child.IsA(UsdPhysics.Scene): + child.SetActive(False) + + for child in children: + if not child.IsValid() or not child.IsActive(): + continue + if child.IsA(UsdPhysics.Joint): + child.SetActive(False) + continue + for schema_name in tuple(child.GetAppliedSchemas()): + if _is_physics_schema(schema_name) and not child.RemoveAppliedSchema(schema_name): + raise RuntimeError(f"Failed to remove physics schema {schema_name!r} from {child.GetPath()}.") + + remaining = { + str(child.GetPath()): tuple(schema for schema in child.GetAppliedSchemas() if _is_physics_schema(schema)) + for child in children + if child.IsValid() and child.IsActive() + } + remaining = {path: schemas for path, schemas in remaining.items() if schemas} + if remaining: + raise RuntimeError(f"Visual-only USD subtree still contains physics schemas: {remaining}") + + +@sim_utils.clone +def _spawn_visual_only_usd( + prim_path: str, + cfg: sim_utils.UsdFileCfg, + translation: tuple[float, float, float] | None = None, + orientation: tuple[float, float, float, float] | None = None, + **kwargs, +): + """Spawn a USD asset and strip its authored physics metadata.""" + prim = sim_utils.spawn_from_usd(prim_path, cfg, translation, orientation, **kwargs) + + # Presentation assets may contain nested dynamic props (the packing table, + # for example, carries an authored container rigid body). Merely disabling + # collision still exposes those bodies and joints to a backend parser. + # Author local API deletions so the entire referenced hierarchy is a pure + # render layer and cannot alter either Newton's model or PhysX's scene. + _make_usd_subtree_visual_only(prim) + return prim + + +@configclass +class _VisualOnlyUsdFileCfg(sim_utils.UsdFileCfg): + """USD reference whose composed subtree is guaranteed to remain render-only.""" + + func: Callable = _spawn_visual_only_usd + # Recursive schema overrides cannot be authored on USD instance proxies. + make_uninstanceable: bool = True + + +@configclass +class _ElevatedGroundPlaneCfg(TerrainImporterCfg): + """Ground plane that reports the elevated workspace as its environment origin.""" + + class_type: type[TerrainImporter] | str = ( + "{DIR}.conveyor_franka_asset_terrain:ConveyorFrankaGroundPlaneTerrainImporter" + ) + workspace_origin_offset: tuple[float, float, float] = (0.0, 0.0, _WORKSPACE_ELEVATION) + """Translation from clone-grid origins to policy workspaces [m].""" + + +def _visual_usd_asset( + prim_path: str, + usd_path: str, + position: tuple[float, float, float], + scale: tuple[float, float, float], + rotation: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), +) -> AssetBaseCfg: + """Build one visual asset whose authored colliders are explicitly disabled.""" + spawn = _VisualOnlyUsdFileCfg( + usd_path=usd_path, + scale=scale, + ) + return AssetBaseCfg( + prim_path=prim_path, + init_state=AssetBaseCfg.InitialStateCfg(pos=position, rot=rotation), + # Physics remains owned by the task's lightweight, hidden belt and rail + # proxies. The spawn callback strips authored physics metadata to keep + # the visual geometry entirely non-authoritative. + spawn=spawn, + ) + + +@configclass +class ConveyorFrankaA09A12SceneCfg(ConveyorFrankaSceneCfg): + """Checkpoint-compatible Digital Twin scene with visual warehouse dressing.""" + + def __post_init__(self) -> None: + """Dress the workcell and extend its returns around the fixed manipulation sections.""" + super().__post_init__() + + # Raise every inherited env-scoped task component as one rigid + # workspace. The environment reports the same offset as its origin, so + # observations, resets, rewards, and the pretrained policy retain their + # original local coordinates. + for asset in vars(self).values(): + if isinstance(asset, AssetBaseCfg) and asset.prim_path.startswith("{ENV_REGEX_NS}/"): + x, y, z = asset.init_state.pos + asset.init_state.pos = (x, y, z + _WORKSPACE_ELEVATION) + + # The ground is global rather than env-scoped and remains at the USD + # convention's default elevation. + self.ground = _ElevatedGroundPlaneCfg( + prim_path="/World/GroundPlane", + terrain_type="plane", + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.055, 0.065, 0.082), roughness=0.82), + ) + self.dome_light.spawn.color = (0.70, 0.78, 0.92) + self.dome_light.spawn.intensity = 700.0 + + right_x = BELT_CENTER_X + BELT_HALF_STRAIGHT + straight_scale = (_A09_X_SCALE, _ASSET_XY_SCALE, _ASSET_Z_SCALE) + + # The Franka is fixed at the elevated workspace origin and does not need + # support collision. Replace the temporary plinth with the purpose-built + # Thor table: its mount stays at the robot base and its feet land on z=0. + self.tabletop = _visual_usd_asset( + prim_path="{ENV_REGEX_NS}/RobotThorTableVisual", + usd_path=_THOR_TABLE_ASSET_PATH, + position=(0.0, 0.0, _WORKSPACE_ELEVATION), + scale=(_THOR_TABLE_SCALE,) * 3, + ) + self.table_pedestal = None + + for side in ("Left", "Right"): + side_key = side.lower() + center_y = BELT_CENTER_Y if side == "Left" else -BELT_CENTER_Y + + # The Digital Twin pieces already render their belt, frame, and + # guides, so remove only the procedural render geometry. Hidden + # belt and guide collision assets created above stay authoritative. + setattr(self, f"conveyor_{side_key}_belt_visual", None) + setattr(self, f"guard_{side_key}_inner_visual", None) + setattr(self, f"guard_{side_key}_outer_visual", None) + + run = "bottom" if side == "Left" else "top" + y_position = center_y + (-BELT_TURN_RADIUS if side == "Left" else BELT_TURN_RADIUS) + setattr( + self, + f"conveyor_{side_key}_{run}_a09_visual", + _visual_usd_asset( + prim_path=f"{{ENV_REGEX_NS}}/Conveyor{side}{run.title()}A09Visual", + usd_path=str(_PRESENTATION_ASSETS / "conveyor_straight_supported.usda"), + position=(right_x, y_position, _ASSET_ROOT_Z), + scale=straight_scale, + ), + ) + getattr(self, f"conveyor_{side_key}_{run}_a09_visual").spawn.func = _spawn_authored_visual + + self.warehouse_visual = _visual_usd_asset( + prim_path="{ENV_REGEX_NS}/WarehouseVisual", + usd_path=str(_PRESENTATION_ASSETS / "warehouse.usda"), + position=(0.0, 0.0, 0.0), + scale=(1.0, 1.0, 1.0), + ) + self.warehouse_visual.spawn.func = _spawn_authored_visual + for cube_id in range(4): + cube = getattr(self, f"cube_{cube_id}") + cube.spawn = _ParcelCuboidCfg(**vars(cube.spawn)) + cube.spawn.func = _spawn_carton_cube + + def _configure_route_assets( + self, parcel_colors: tuple[str, ...] = ("blue", "orange", "green", "purple") * 6 + ) -> None: + """Read USD route geometry after the application selects its USD runtime.""" + from .conveyor_warehouse_geometry import warehouse_parcel_positions + + positions = warehouse_parcel_positions() + if len(parcel_colors) != len(positions) or not set(parcel_colors) <= {"blue", "orange", "green", "purple"}: + raise ValueError("Each authored parcel requires a color: blue, orange, green, or purple.") + for cube_id, (position, color) in enumerate(zip(positions, parcel_colors, strict=True)): + cube = self.cube_0.copy() + cube.spawn.parcel_usd_path = str(_PRESENTATION_ASSETS / f"parcel_{color}.usda") + cube.prim_path = f"{{ENV_REGEX_NS}}/Cube{cube_id}" + cube.init_state.pos = (position[0], position[1], position[2] + _WORKSPACE_ELEVATION) + setattr(self, f"cube_{cube_id}", cube) + for side in ("Left", "Right"): + for key in ("top_straight", "bottom_straight", "right_turn", "left_turn"): + setattr(self, f"conveyor_{side.lower()}_{key}_collision", None) + for index, section in enumerate(warehouse_belt_sections(side)): + asset = _hidden_collision_geometry(section.belt.prim_path, section.geometry, 1.1e-5, 1) + x, y, z = asset.init_state.pos + asset.init_state.pos = (x, y, z + _WORKSPACE_ELEVATION) + setattr(self, f"warehouse_{side.lower()}_section_{index}", asset) + for boundary in ("inner", "outer"): + setattr(self, f"guard_{side.lower()}_{boundary}_collision", None) + for guard in warehouse_guard_meshes(side): + asset = _hidden_collision_geometry(f"{{ENV_REGEX_NS}}/{guard.name}Collision", guard, 1.1e-5, 1) + asset.init_state.pos = (0.0, 0.0, _WORKSPACE_ELEVATION) + setattr(self, f"warehouse_{guard.name}_collision", asset) + + def build_conveyor_belt_specs(self, **kwargs: float | bool) -> tuple[SurfaceVelocitySpec, ...]: + """Describe the linked warehouse surfaces to the existing Newton conveyor driver.""" + return tuple(section.belt for side in ("Left", "Right") for section in warehouse_belt_sections(side, **kwargs)) + + +@configclass +class ConveyorFrankaA09A12EnvCfg(ConveyorFrankaEnvCfg): + """Newton presentation variant with Digital Twin visuals and extended return routes.""" + + class_type: type | str = "{DIR}.conveyor_franka_warehouse_env:ConveyorFrankaWarehouseEnv" + park_when_idle: bool = False + """Replace idle actions with home-position control during pretrained-policy playback only.""" + scene: ConveyorFrankaA09A12SceneCfg = ConveyorFrankaA09A12SceneCfg( + num_envs=1, + env_spacing=24.0, + replicate_physics=True, + ) + + def __post_init__(self) -> None: + """Frame the presentation scene and allow packages to travel along its extended returns.""" + super().__post_init__() + from .mdp.sorting import ConveyorSortCommandCfg + + self.commands.transfer = ConveyorSortCommandCfg() + # Warehouse arrivals replace the phase reset bank; fine-tuning starts from home. + self.curriculum = None + self.events.reset_from_state_table.params.update( + fixed_recipe=int(mdp.ConveyorResetRecipe.BELT), fixed_variant_id=mdp.BELT_DEPLOYMENT_VARIANT + ) + # Kit renders the authored USD directly; Newton needs only the physical geometry. + self.sim.physics.load_visual_shapes = False + # Adjacent static belt sections must not consume the parcel contact budget. + self.sim.physics.collision_cfg.include_static_kinematic_pairs = False + self.conveyor_force.transported_body_count_per_env = len(self.commands.transfer.parcel_destinations) + self.sim.physics.solver_cfg.nconmax = 400 + self.sim.physics.solver_cfg.njmax = 600 + self.conveyor_force.transported_body_pattern = r"(?:^|/)Cube_?[0-9]+(?:/|$)" + self.terminations.cube_out_of_workspace.params = { + "minimum": (-0.4, -1.2, -0.05), + "maximum": (2.85, 1.2, 0.8), + } + self.sim.default_visualizer_cfg.eye = (4.8, -5.2, 3.0) + self.sim.default_visualizer_cfg.lookat = (0.9, 0.6, 0.95) + self.sim.default_visualizer_cfg.focal_length = 24.0 + + def play_mode(self) -> None: + """Enable the idle controller only for pretrained-policy playback.""" + super().play_mode() + self.park_when_idle = True diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_terrain.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_terrain.py new file mode 100644 index 000000000000..645b58511d27 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_terrain.py @@ -0,0 +1,30 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Terrain-origin support for the asset-rich conveyor playback variant.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from isaaclab.terrains import TerrainImporter + +if TYPE_CHECKING: + from .conveyor_franka_asset_env_cfg import _ElevatedGroundPlaneCfg + + +class ConveyorFrankaGroundPlaneTerrainImporter(TerrainImporter): + """Plane terrain whose environment origins follow an elevated workspace.""" + + def __init__(self, cfg: _ElevatedGroundPlaneCfg): + """Create the ground plane and translate its policy-facing origins.""" + super().__init__(cfg) + self.env_origins.add_(self.env_origins.new_tensor(cfg.workspace_origin_offset)) + # The authored warehouse floor replaces the default calibration-grid visual. + from pxr import UsdGeom + + from isaaclab.sim.utils.stage import get_current_stage + + UsdGeom.Imageable(get_current_stage().GetPrimAtPath(cfg.prim_path)).MakeInvisible() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py new file mode 100644 index 000000000000..5f76eaafdca1 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py @@ -0,0 +1,155 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Four-slot policy control over a physical parcel pool and USD-authored warehouse.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from isaaclab.envs.common import VecEnvStepReturn + +from .conveyor_cube_pool import ConveyorCubePool +from .conveyor_franka_env import ConveyorFrankaEnv + +if TYPE_CHECKING: + from pxr import Usd + + from .conveyor_franka_asset_env_cfg import ConveyorFrankaA09A12EnvCfg + + +class ConveyorFrankaWarehouseEnv(ConveyorFrankaEnv): + """Use the four-cube policy contract over an individually tracked workcell parcel pool. + + Kit renders the authored USD materials, lights, and animation. Lightweight + Newton viewers show a static approximation of the warehouse dressing. + """ + + def __init__(self, cfg: ConveyorFrankaA09A12EnvCfg, render_mode: str | None = None, **kwargs): + self._warehouse_animation: list[tuple[Usd.Attribute, Usd.Attribute]] = [] + self._warehouse_arm_joint_ids: list[int] | None = None + cfg.scene._configure_route_assets(cfg.commands.transfer.parcel_colors) + super().__init__(cfg, render_mode=render_mode, **kwargs) + if not any(viz.cfg.visualizer_type == "kit" for viz in self.sim.visualizers): + return + self.sim.set_setting("/app/viewport/grid/enabled", False) + + from pxr import Usd + + from .conveyor_franka_asset_env_cfg import _presentation_layer + + camera = self.sim.stage.GetPrimAtPath("/OmniverseKit_Persp") + if camera: + # Kit owns this camera in its session layer; weaker root-layer edits are ignored. + viewer = cfg.sim.default_visualizer_cfg + with Usd.EditContext(self.sim.stage, self.sim.stage.GetSessionLayer()): + self.sim.set_camera_view(viewer.eye, viewer.lookat) + camera.GetAttribute("focalLength").Set(viewer.focal_length) + self._warehouse_source = Usd.Stage.Open(_presentation_layer(cfg.scene.warehouse_visual.spawn.usd_path)) + self._warehouse_period = self._warehouse_source.GetEndTimeCode() + self._warehouse_fps = self._warehouse_source.GetTimeCodesPerSecond() + for env_path in self.scene.env_prim_paths: + group = self._warehouse_source.GetPrimAtPath("/Warehouse/Parcels") + for source in group.GetChildren(): + target = self.sim.stage.GetPrimAtPath(f"{env_path}/WarehouseVisual/Parcels/{source.GetName()}") + for name in ("xformOp:translate", "xformOp:rotateXYZ"): + self._warehouse_animation.append((source.GetAttribute(name), target.GetAttribute(name))) + self.sim.add_render_callback("conveyor_warehouse_animation", self._animate_warehouse) + + def load_managers(self) -> None: + """Create slot identities before command, reward, and observation terms inspect cubes.""" + assets = tuple(self.scene[f"cube_{i}"] for i in range(self.cfg.conveyor_force.transported_body_count_per_env)) + self.conveyor_cube_pool = ConveyorCubePool(assets, self.num_envs, self.device) + super().load_managers() + + @staticmethod + def _in_workcell(positions: torch.Tensor) -> torch.Tensor: + """Identify parcels within the trained controller's local manipulation region [m].""" + return ( + (positions[..., 0] > -0.25) + & (positions[..., 0] < 1.5) + & (positions[..., 1].abs() < 0.6) + & (positions[..., 2] < 0.4) + # The low merge passes beside the placement bend but is still remote transport. + & ((positions[..., 2] < 0.12) | (positions[..., 0] < 1.05)) + ) + + def _adapt_policy_cube_state(self, positions, quaternions, velocities): + """Represent remote inventory as waiting slots while keeping local manipulation states exact.""" + origins = self.scene.env_origins[:, None, :] + local = positions - origins + remote = ~self._in_workcell(local) + waiting = local.clone() + waiting[..., 0] = 0.14 + 0.88 * torch.arange(1, 5, device=self.device)[None, :] / 5 + waiting[..., 1] = torch.where(local[..., 1] >= 0, 0.75, -0.75) + waiting[..., 2] = 0.06 + upright = torch.zeros_like(quaternions) + upright[..., 3] = 1.0 + transport_velocity = torch.zeros_like(velocities) + transport_velocity[..., 0] = self.cfg.conveyor_force.speed + return ( + torch.where(remote[..., None], waiting + origins, positions), + torch.where(remote[..., None], upright, quaternions), + torch.where(remote[..., None], transport_velocity, velocities), + ) + + def step(self, action: torch.Tensor) -> VecEnvStepReturn: + """Pass training actions through; optionally park during idle policy playback.""" + if not self.cfg.park_when_idle: + return super().step(action) + command = self.command_manager.get_term("transfer") + robot = self.scene["robot"] + if self._warehouse_arm_joint_ids is None: + self._warehouse_arm_joint_ids = robot.find_joints( + self.cfg.actions.arm_action.joint_names, preserve_order=True + )[0] + joint_ids = self._warehouse_arm_joint_ids + parked = torch.zeros_like(action) + parked[:, :7] = ( + (robot.data.default_joint_pos.torch[:, joint_ids] - robot.data.joint_pos.torch[:, joint_ids]) + / self.cfg.actions.arm_action.scale + ).clamp(-0.25, 0.25) + # Keep invalid actions visible to the existing sanitization and termination terms. + use_policy = command.has_target | ~torch.isfinite(action).all(dim=1) + return super().step(torch.where(use_policy[:, None], action, parked)) + + def _animate_warehouse(self, _event) -> None: + """Sample USD motion using policy time, independently of render frame rate.""" + from pxr import Sdf + + time_code = (self.common_step_counter * self.step_dt * self._warehouse_fps) % self._warehouse_period + with Sdf.ChangeBlock(): + for source, target in self._warehouse_animation: + target.Set(source.Get(time_code)) + + def _reset_idx(self, env_ids) -> None: + """Shuffle a mixed batch across the authored feeds in selected environments.""" + from .conveyor_warehouse_geometry import warehouse_parcel_positions + + if isinstance(env_ids, slice): + env_ids = self.scene._ALL_INDICES[env_ids] + pool = self.conveyor_cube_pool + pool.reset(env_ids) + super()._reset_idx(env_ids) + positions = torch.tensor(warehouse_parcel_positions(), device=self.device) + if self.cfg.commands.transfer.randomize_arrivals: + assignments = torch.rand((len(env_ids), len(pool.assets)), device=self.device).argsort(dim=1) + else: + assignments = torch.arange(len(pool.assets), device=self.device).expand(len(env_ids), -1) + for cube_id, cube in enumerate(pool.assets): + pose = cube.data.root_pose_w.torch[env_ids].clone() + pose[:, :3] = positions[assignments[:, cube_id]] + self.scene.env_origins[env_ids] + pose[:, 3:] = pose.new_tensor((0.0, 0.0, 0.0, 1.0)) + cube.write_root_pose_to_sim_index(root_pose=pose, env_ids=env_ids) + cube.write_root_velocity_to_sim_index(root_velocity=pose.new_zeros((len(env_ids), 6)), env_ids=env_ids) + + def close(self) -> None: + """Remove the presentation callback before releasing the shared task resources.""" + if getattr(self, "sim", None) is not None: + self.sim.remove_render_callback("conveyor_warehouse_animation") + self._warehouse_animation.clear() + super().close() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_warehouse_geometry.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_warehouse_geometry.py new file mode 100644 index 000000000000..607f055a2795 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_warehouse_geometry.py @@ -0,0 +1,226 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Physical surfaces for the USD-authored elevated conveyor network.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, replace +from functools import lru_cache +from pathlib import Path + +import numpy as np + +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec + +from .conveyor_geometry import ( + BELT_CENTER_X, + BELT_CENTER_Y, + BELT_COLLISION_OVERHANG, + BELT_COLLISION_SEAM_OVERLAP, + BELT_HALF_STRAIGHT, + BELT_THICKNESS, + GUARD_BASE_OVERLAP, + GUARD_HEIGHT, + GUARD_THICKNESS, + ConveyorSectionSpec, + MeshSpec, + _turn_collision_mesh, + belt_collision_section_specs, + belt_direction, +) + +_ROUTE_ASSET = Path(__file__).parent / "assets" / "conveyor_routes.usda" + + +@lru_cache(maxsize=1) +def _route_layer(): + # Import Usd to register file formats in kitless mode, without composing asset dependencies. + from pxr import Sdf, Usd # noqa: F401 + + return Sdf.Layer.FindOrOpen(str(_ROUTE_ASSET)) + + +@dataclass(frozen=True) +class _RouteSegment: + name: str + points: tuple[tuple[float, float, float], ...] + direction: tuple[float, float, float] + width: float + pivot: tuple[float, float, float] | None + radius: float | None + friction: float | None + velocity: float | None + path: str + + +@lru_cache(maxsize=2) +def _route_segments(side: str) -> tuple[_RouteSegment, ...]: + """Read the same centerlines that author the visible conveyor modules.""" + from pxr import Sdf + + belt_direction(side) + layer = _route_layer() + root = layer.GetPrimAtPath(Sdf.Path(f"/ConveyorRoutes/{side}")) + segments = [] + for name in root.attributes["conveyor:order"].default: + prim = layer.GetPrimAtPath(root.path.AppendChild(name)) + points = layer.GetPrimAtPath(prim.path.AppendChild("Centerline")).attributes["points"].default + curved = "conveyor:pivot" in prim.attributes + segments.append( + _RouteSegment( + name=name, + velocity=prim.attributes["conveyor:velocity"].default + if "conveyor:velocity" in prim.attributes + else None, + path=prim.attributes["conveyor:path"].default if "conveyor:path" in prim.attributes else "main", + points=tuple(tuple(point) for point in points), + direction=tuple(prim.attributes["conveyor:direction"].default), + width=prim.attributes["conveyor:width"].default, + pivot=tuple(prim.attributes["conveyor:pivot"].default) if curved else None, + radius=prim.attributes["conveyor:radius"].default if curved else None, + friction=prim.attributes["conveyor:friction"].default + if "conveyor:friction" in prim.attributes + else None, + ) + ) + return tuple(segments) + + +def warehouse_parcel_positions() -> tuple[tuple[float, float, float], ...]: + """Return physical parcel infeed spawn positions in workspace coordinates [m].""" + root = _route_layer().GetPrimAtPath("/ConveyorRoutes") + return tuple(tuple(position) for position in root.attributes["conveyor:parcelSpawnPositions"].default) + + +def _normals(segment: _RouteSegment) -> np.ndarray: + if segment.pivot is None: + tangent = np.asarray(segment.direction) + normal = np.array((-tangent[1], tangent[0], 0.0)) + return np.tile(normal / np.linalg.norm(normal), (len(segment.points), 1)) + radial = np.asarray(segment.points) - segment.pivot + radial[:, 2] = 0 + return -math.copysign(1, segment.direction[2]) * radial / np.linalg.norm(radial, axis=1, keepdims=True) + + +def _prism( + name: str, + points: np.ndarray, + normals: np.ndarray, + offset: float | np.ndarray, + width: float, + bottom: float, + top: float, + *, + closed: bool = False, +) -> MeshSpec: + """Sweep a closed solid along a three-dimensional centerline [m].""" + offset = np.broadcast_to(offset, (len(points),))[:, None] + left = points + normals * (offset - width / 2) + right = points + normals * (offset + width / 2) + vertices = np.concatenate((left + (0, 0, top), right + (0, 0, top), left + (0, 0, bottom), right + (0, 0, bottom))) + count = len(points) + quads = [] + for i in range(count if closed else count - 1): + j = (i + 1) % count + quads.extend( + ( + (i, j, count + j, count + i), + (2 * count + i, 3 * count + i, 3 * count + j, 2 * count + j), + (i, 2 * count + i, 2 * count + j, j), + (count + i, count + j, 3 * count + j, 3 * count + i), + ) + ) + if not closed: + quads.extend(((0, count, 3 * count, 2 * count), (count - 1, 3 * count - 1, 4 * count - 1, 2 * count - 1))) + faces = tuple(triangle for a, b, c, d in quads for triangle in ((a, b, c), (a, c, d))) + return MeshSpec(name, tuple(tuple(vertex) for vertex in vertices), faces) + + +def warehouse_belt_sections(side: str, **kwargs: float | bool) -> tuple[ConveyorSectionSpec, ...]: + """Build linked belt surfaces from USD while preserving the original manipulation geometry.""" + sign = belt_direction(side) + sections = [] + for segment in _route_segments(side): + name = f"Conveyor{side}{segment.name}Collision" + pivot = segment.pivot + surface_kwargs = dict(kwargs) + if segment.velocity is not None: + surface_kwargs["velocity"] = segment.velocity + if segment.friction is not None: + surface_kwargs["friction_coefficient"] = segment.friction + if segment.name == "Working": + original = belt_collision_section_specs(side, **kwargs)[1 if sign > 0 else 0] + sections.append(original) + continue + if segment.name in {"PickupBend", "PlacementBend"}: + pickup = segment.name == "PickupBend" + name = f"Conveyor{side}{'Left' if pickup else 'Right'}InnerTurnCollision" + pivot = (BELT_CENTER_X + (-BELT_HALF_STRAIGHT if pickup else BELT_HALF_STRAIGHT), sign * BELT_CENTER_Y, 0.0) + geometry = _turn_collision_mesh( + name, pivot[0], BELT_CENTER_Y, math.pi if pickup else -math.pi / 2, math.pi / 2 + ) + if sign < 0: + geometry = replace( + geometry, + vertices=tuple((x, -y, z) for x, y, z in geometry.vertices), + faces=tuple((a, c, b) for a, b, c in geometry.faces), + ) + else: + points = np.array(segment.points) + # Overlap adjacent panels without exposing a vertical collision seam. + for index, neighbor, direction in ((0, 1, -1), (-1, -2, 1)): + tangent = points[neighbor] - points[index] if index == 0 else points[index] - points[neighbor] + points[index] += direction * BELT_COLLISION_SEAM_OVERLAP * tangent / np.linalg.norm(tangent) + geometry = _prism( + name, points, _normals(segment), 0, segment.width + 2 * BELT_COLLISION_OVERHANG, -BELT_THICKNESS, 0 + ) + sections.append( + ConveyorSectionSpec( + geometry, + SurfaceVelocitySpec( + prim_path=f"{{ENV_REGEX_NS}}/{geometry.name}", + direction=segment.direction, + surface_normal=(0.0, 0.0, 1.0) + if segment.pivot is not None + else tuple(np.cross(segment.direction, _normals(segment)[0])), + curved=segment.pivot is not None, + pivot_point=pivot or (0, 0, 0), + radius=segment.radius, + **surface_kwargs, + ), + ) + ) + return tuple(sections) + + +def warehouse_guard_meshes(side: str) -> tuple[MeshSpec, ...]: + """Build continuous guides for each closed circulation route and open supply belt.""" + segments = _route_segments(side) + guards = [] + for path in dict.fromkeys(segment.path for segment in segments): + points, normals, offsets = [], [], [] + route = [segment for segment in segments if segment.path == path] + for index, segment in enumerate(route): + stop = None if path != "main" and index == len(route) - 1 else -1 + for point, normal in zip(segment.points[:stop], _normals(segment)[:stop]): + points.append(point) + normals.append(normal) + offsets.append((segment.width + GUARD_THICKNESS) / 2) + for boundary, sign in (("Inner", -1), ("Outer", 1)): + guards.append( + _prism( + f"Guard{side}{path.title()}{boundary}", + np.asarray(points), + np.asarray(normals), + sign * np.asarray(offsets), + GUARD_THICKNESS, + -GUARD_BASE_OVERLAP, + GUARD_HEIGHT, + closed=path == "main", + ) + ) + return tuple(guards) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py index 8185334767e1..8fbdc7317ef2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py @@ -184,6 +184,9 @@ def evaluate(self) -> None: source_sides = self.source_side_ids[success_ids] self.transfer_counts[success_ids] += 1 self.direction_transfer_counts[success_ids, source_sides] += 1 + pool = getattr(self._env, "conveyor_cube_pool", None) + if pool is not None: + pool.record_transfers(success_ids, self.target_cube_ids[success_ids]) potential = current_transfer_potential(self._env, command=self) progressed = (potential >= self._target_potential) & (evaluation_steps >= self.cfg.minimum_progress_steps) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/cube_state.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/cube_state.py index 9ce81862d05b..c583f9c955f1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/cube_state.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/cube_state.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Rigid-object data in the policy's four fixed cube identities.""" +"""Rigid-object data in policy-slot or physical inventory order.""" from __future__ import annotations @@ -18,5 +18,15 @@ def cube_values(env: ManagerBasedRLEnv, attribute: str, *, all_cubes: bool = False) -> torch.Tensor: - """Gather cube data in identity order, with shape (num_envs, four, attribute_size).""" - return torch.stack([getattr(env.scene[f"cube_{i}"].data, attribute).torch for i in range(CUBE_COUNT)], dim=1) + """Gather a rigid-object data attribute in policy-slot or complete physical-inventory order. + + Values retain the attribute's units and world frame. The result has shape + ``(num_envs, num_slots_or_parcels, attribute_size)``. Tasks without a parcel pool + retain the original four fixed cube identities. + """ + pool = getattr(env, "conveyor_cube_pool", None) + assets = pool.assets if pool is not None else tuple(env.scene[f"cube_{i}"] for i in range(CUBE_COUNT)) + values = torch.stack([getattr(asset.data, attribute).torch for asset in assets], dim=1) + if pool is not None and not all_cubes: + values = values.gather(1, pool.slot_ids[..., None].expand(-1, -1, values.shape[-1])) + return values diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/observations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/observations.py index 9e690ff58ecf..ebc7e8679074 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/observations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/observations.py @@ -30,7 +30,8 @@ def _cube_state(env: ManagerBasedRLEnv) -> tuple[torch.Tensor, torch.Tensor, tor cube_values(env, "root_quat_w"), cube_values(env, "root_vel_w"), ) - return state + adapter = getattr(env, "_adapt_policy_cube_state", None) + return state if adapter is None else adapter(*state) def target_cube_one_hot(env: ManagerBasedRLEnv, command_name: str = "transfer") -> torch.Tensor: @@ -66,7 +67,8 @@ def transfer_object_observation(env: ManagerBasedRLEnv) -> torch.Tensor: """Describe all four cubes in stable identity slots. The observation contains local positions, tool-relative positions, local - up axes, and linear/angular velocities in four fixed identity slots. + up axes, and linear/angular velocities. The base task keeps fixed identities; + warehouse playback can refill remote slots from its physical parcel pool. :func:`target_cube_one_hot` selects the active slot. """ positions, quaternions, velocities = _cube_state(env) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py new file mode 100644 index 000000000000..2cc58460b3bf --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py @@ -0,0 +1,128 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Parcel-class dispatch for the warehouse's unchanged four-slot transfer policy.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +import torch + +from isaaclab.utils.configclass import configclass + +from .commands import ConveyorTransferCommand, ConveyorTransferCommandCfg +from .cube_state import cube_values +from .rewards import physical_cube_acquisition_mask + +if TYPE_CHECKING: + from ..conveyor_franka_warehouse_env import ConveyorFrankaWarehouseEnv + + +class ConveyorSortCommand(ConveyorTransferCommand): + """Transfer misplaced cartons and leave correctly sorted inventory circulating. + + Physical parcel IDs carry immutable destination classes. The dispatcher presents + one wrong-lane arrival through the pretrained policy's existing cube/side command; + color is a visual class label, not an additional policy observation. + """ + + cfg: ConveyorSortCommandCfg + + def __init__(self, cfg: ConveyorSortCommandCfg, env: ConveyorFrankaWarehouseEnv) -> None: + super().__init__(cfg, env) + if not 0.14 <= cfg.pickup_x_range[0] < cfg.pickup_x_range[1] <= 1.02: + raise ValueError("The sorting pickup window must lie within the original working straight [0.14, 1.02] m.") + if len(cfg.parcel_destinations) != len(env.conveyor_cube_pool.assets) or set(cfg.parcel_destinations) != {0, 1}: + raise ValueError("Parcel destinations must match the physical pool and include both conveyor IDs, 0 and 1.") + if len(cfg.parcel_colors) != len(cfg.parcel_destinations): + raise ValueError("Each physical parcel requires a color and a destination.") + for color in set(cfg.parcel_colors): + destinations = { + side for shade, side in zip(cfg.parcel_colors, cfg.parcel_destinations, strict=True) if shade == color + } + if len(destinations) != 1: + raise ValueError(f"All {color} parcels must share one destination conveyor.") + self.parcel_destinations = torch.tensor(cfg.parcel_destinations, device=self.device) + self.has_target = torch.zeros(self.num_envs, device=self.device, dtype=torch.bool) + self.metrics["sorted_parcels"] = torch.zeros(self.num_envs, device=self.device) + self.metrics["batch_complete"] = torch.zeros(self.num_envs, device=self.device) + + def evaluate(self) -> None: + """Credit stable placements only while the dispatcher owns an active transfer.""" + self.new_success[~self.has_target] = False + self.is_success[~self.has_target] = False + self._last_evaluation_steps[~self.has_target] = self._env.episode_length_buf[~self.has_target] + super().evaluate() + + def _resample_command(self, env_ids: Sequence[int]) -> None: + # Reset metadata remains compatible with the shared reward and observation terms. + if self._resampling_from_reset: + super()._resample_command(env_ids) + self.has_target[env_ids] = False + self.held_cube_ids[env_ids] = -1 + self.pending_success[env_ids] = False + + def _update_command(self) -> None: + """Publish slot assignments before the manager computes the next policy observation.""" + env = self._env + pool = env.conveyor_cube_pool + positions = cube_values(env, "root_pos_w", all_cubes=True) - env.scene.env_origins[:, None] + local = env._in_workcell(positions) + rows = torch.arange(self.num_envs, device=self.device) + physical_target = pool.slot_ids[rows, self.target_cube_ids] + held = physical_cube_acquisition_mask(env, command=self) & self.has_target + self.has_target &= ~self.pending_success & (local[rows, physical_target] | held) + self.pending_success.zero_() + wrong_lane = (positions[..., 1] < 0).long() != self.parcel_destinations + candidates = ( + wrong_lane + & (positions[..., 0] > self.cfg.pickup_x_range[0]) + & (positions[..., 0] < self.cfg.pickup_x_range[1]) + & (positions[..., 1].abs() > 0.20) + & (positions[..., 1].abs() < 0.36) + & (positions[..., 2] > 0.04) + & (positions[..., 2] < 0.10) + ) + pool.refresh(positions, local, candidates, self.target_cube_ids, self.has_target) + available = candidates.gather(1, pool.slot_ids) + eligible = available.any(dim=1) & ~self.has_target + choices = torch.where(available, positions[..., 0].gather(1, pool.slot_ids), -torch.inf).argmax(dim=1) + for slot in range(4): + env_ids = torch.where(eligible & (choices == slot))[0] + if env_ids.numel(): + self.set_goal(slot, env_ids) + self.has_target[env_ids] = True + self.command_counter[env_ids] += 1 + # A class is counted only after leaving the elevated supply and landing on a loop. + velocities = cube_values(env, "root_lin_vel_w", all_cubes=True) + on_loop = (positions[..., 2] > 0.04) & (positions[..., 2] < 0.20) & (velocities[..., 2].abs() < 0.15) + active = torch.zeros_like(on_loop).scatter_( + 1, pool.slot_ids[rows, self.target_cube_ids, None], self.has_target[:, None] + ) + on_loop &= ~active + sorted_parcels = (~wrong_lane & on_loop).sum(dim=1) + self.metrics["sorted_parcels"].copy_(sorted_parcels) + self.metrics["batch_complete"].copy_(sorted_parcels == len(pool.assets)) + + +@configclass +class ConveyorSortCommandCfg(ConveyorTransferCommandCfg): + """Batch sorting with fixed physical classes and checkpoint-compatible commands.""" + + class_type: type[ConveyorSortCommand] | str = "{DIR}.sorting:ConveyorSortCommand" + + parcel_destinations: tuple[int, ...] = (0, 1) * 12 + """Destination per physical parcel: 0 is the positive-Y loop, 1 the negative-Y loop.""" + + parcel_colors: tuple[str, ...] = ("blue", "orange", "green", "purple") * 6 + """Authored color per physical parcel; blue, orange, green, and purple are available.""" + + randomize_arrivals: bool = True + """Shuffle physical parcel identities across authored start positions at each reset.""" + + pickup_x_range: tuple[float, float] = (0.35, 1.02) + """Longitudinal arrival window in policy workspace coordinates [m].""" diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 38028284b683..074f1030856a 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -45,3 +45,4 @@ include = ["isaaclab_tasks", "isaaclab_tasks.*"] [tool.setuptools.package-data] "*" = ["*.pyi"] +"isaaclab_tasks.contrib.conveyor_franka" = ["assets/*.usda"] diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py new file mode 100644 index 000000000000..c9ef102772b6 --- /dev/null +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py @@ -0,0 +1,223 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Import-light checks for the Digital Twin conveyor playback scene.""" + +import math +import subprocess +import sys +from types import SimpleNamespace + +import pytest + +import isaaclab.sim as sim_utils + +from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_asset_env_cfg import ( + _PRESENTATION_ASSETS, + ConveyorFrankaA09A12EnvCfg, + _presentation_layer, +) +from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_env_cfg import ConveyorFrankaEnvCfg +from isaaclab_tasks.contrib.conveyor_franka.conveyor_geometry import ( + BELT_CENTER_X, + BELT_CENTER_Y, + BELT_HALF_STRAIGHT, + BELT_TOP_Z, + BELT_TURN_RADIUS, +) + + +def test_a09_a12_config_import_does_not_preload_usd() -> None: + """Task discovery must not import USD before a requested Kit application starts.""" + code = ( + "import sys; " + "import isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_asset_env_cfg; " + "raise SystemExit('pxr' in sys.modules)" + ) + result = subprocess.run([sys.executable, "-c", code], check=False) + assert result.returncode == 0 + + +def test_carton_visual_is_centered_on_the_original_40_mm_collider(tmp_path, monkeypatch) -> None: + """Asset normalization changes appearance without moving or resizing the grasp surface.""" + from pxr import Sdf, Usd, UsdGeom, UsdPhysics + + from isaaclab_tasks.contrib.conveyor_franka import conveyor_franka_asset_env_cfg as asset_cfg + + # Measured unscaled Cardbox_A1 bounds, used as an offline stand-in for the remote mesh. + lower = (-0.34971755743026733, -0.260576993227005, 0.0) + upper = (0.34971755743026733, 0.2605747878551483, 0.5099270939826965) + source = Usd.Stage.CreateNew(str(tmp_path / "carton.usda")) + root = UsdGeom.Xform.Define(source, "/Carton").GetPrim() + source.SetDefaultPrim(root) + UsdPhysics.RigidBodyAPI.Apply(root) + UsdPhysics.ArticulationRootAPI.Apply(root) + UsdPhysics.MassAPI.Apply(root) + UsdPhysics.FixedJoint.Define(source, "/Carton/Joint") + source.DefinePrim("/Carton/Graph", "OmniGraph") + UsdPhysics.Scene.Define(source, "/Carton/PhysicsScene") + mesh = UsdGeom.Cube.Define(source, "/Carton/Mesh") + mesh.CreateSizeAttr(1.0) + mesh.AddTranslateOp().Set(tuple((a + b) / 2 for a, b in zip(lower, upper))) + mesh.AddScaleOp().Set(tuple(b - a for a, b in zip(lower, upper))) + UsdPhysics.CollisionAPI.Apply(mesh.GetPrim()) + UsdPhysics.MeshCollisionAPI.Apply(mesh.GetPrim()) + UsdPhysics.FilteredPairsAPI.Apply(mesh.GetPrim()) + mesh.GetPrim().AddAppliedSchema("PhysxCollisionAPI") + support = UsdGeom.Mesh.Define(source, "/Carton/Support") + support.CreatePointsAttr([(0.1, 0, 0.2), (0.1, 0.1, 0.255), (0, 0.1, 0.3)]) + support.CreateFaceVertexCountsAttr([3]) + support.CreateFaceVertexIndicesAttr([0, 1, 2]) + support.GetPrim().CreateAttribute("conveyor:supportAnchor", Sdf.ValueTypeNames.Double).Set(0.255) + support.GetPrim().CreateAttribute("conveyor:supportScale", Sdf.ValueTypeNames.Double).Set(2.0) + source.GetRootLayer().Save() + monkeypatch.setattr(asset_cfg, "retrieve_file_path", lambda path: str(tmp_path / "carton.usda")) + _presentation_layer.cache_clear() + try: + stage = sim_utils.create_new_stage() + cfg = ConveyorFrankaA09A12EnvCfg().scene.cube_0.spawn + cfg.parcel_usd_path = str(_PRESENTATION_ASSETS / "parcel_blue.usda") + prim = cfg.func("/Cube", cfg) + visual = stage.GetPrimAtPath("/Cube/CartonVisual") + bounds = UsdGeom.BBoxCache(Usd.TimeCode.Default(), ["default", "render"]).ComputeWorldBound(visual) + assert tuple(bounds.ComputeAlignedRange().GetMin()) == pytest.approx((-0.02,) * 3, abs=1e-8) + assert tuple(bounds.ComputeAlignedRange().GetMax()) == pytest.approx((0.02,) * 3, abs=1e-8) + extended = UsdGeom.Mesh(next(p for p in Usd.PrimRange(visual) if p.GetName() == "Support")) + assert [p[2] for p in extended.GetPointsAttr().Get()] == pytest.approx((0.145, 0.255, 0.3)) + assert [p[2] for p in support.GetPointsAttr().Get()] == pytest.approx((0.2, 0.255, 0.3)) + assert prim.HasAPI(UsdPhysics.RigidBodyAPI) + collider = stage.GetPrimAtPath("/Cube/geometry/mesh") + assert collider.HasAPI(UsdPhysics.CollisionAPI) + assert UsdGeom.Imageable(collider).ComputeVisibility() == "invisible" + assert sum(p.HasAPI(UsdPhysics.RigidBodyAPI) for p in stage.Traverse()) == 1 + assert sum(p.HasAPI(UsdPhysics.CollisionAPI) for p in stage.Traverse()) == 1 + imported = tuple(Usd.PrimRange(visual, Usd.TraverseInstanceProxies())) + for api in ( + UsdPhysics.ArticulationRootAPI, + UsdPhysics.RigidBodyAPI, + UsdPhysics.MassAPI, + UsdPhysics.CollisionAPI, + UsdPhysics.MeshCollisionAPI, + UsdPhysics.FilteredPairsAPI, + ): + assert not any(p.HasAPI(api) for p in imported) + assert all("PhysxCollisionAPI" not in p.GetAppliedSchemas() for p in imported) + # Inactive imported graphs, joints and scenes must not survive composition as executable prims. + assert not any(p.GetTypeName() in {"PhysicsFixedJoint", "OmniGraph", "PhysicsScene"} for p in imported) + # Resolving cached assets must leave the authored layer portable. + assert all( + path.startswith("https://") + for path in Sdf.Layer.FindOrOpen(str(_PRESENTATION_ASSETS / "parcel.usda")).GetExternalReferences() + ) + finally: + _presentation_layer.cache_clear() + + +def test_asset_transforms_preserve_the_original_workcell() -> None: + """The working straights and adjoining quarter-turns keep their original locations and radii.""" + from pxr import Sdf + + from isaaclab_tasks.contrib.conveyor_franka.conveyor_geometry import belt_collision_section_specs + from isaaclab_tasks.contrib.conveyor_franka.conveyor_warehouse_geometry import ( + warehouse_belt_sections, + ) + + scene = ConveyorFrankaA09A12EnvCfg().scene + layer = Sdf.Layer.FindOrOpen(scene.warehouse_visual.spawn.usd_path) + scene._configure_route_assets() + workspace_z = scene.ground.workspace_origin_offset[2] + assert math.isclose(scene.robot.init_state.pos[2], workspace_z) + assert math.isclose(scene.cube_0.init_state.pos[2], 0.28 + workspace_z) + base_z = ConveyorFrankaEnvCfg().scene.conveyor_left_top_straight_collision.init_state.pos[2] + assert math.isclose(scene.warehouse_left_section_0.init_state.pos[2], base_z + workspace_z) + for side, sign, original_index in (("Left", 1, 1), ("Right", -1, 0)): + sections = warehouse_belt_sections(side, velocity=0.35) + original = belt_collision_section_specs(side)[original_index].geometry + inner = sections[0].geometry + assert inner.position == original.position + assert inner.size == original.size + visual = getattr(scene, f"conveyor_{side.lower()}_{'bottom' if sign > 0 else 'top'}_a09_visual") + assert visual.init_state.pos[:2] == ( + BELT_CENTER_X + BELT_HALF_STRAIGHT, + sign * (BELT_CENTER_Y - BELT_TURN_RADIUS), + ) + assert 4 * visual.spawn.scale[0] == pytest.approx(2 * BELT_HALF_STRAIGHT) + assert visual.init_state.pos[2] + 1.78053 * visual.spawn.scale[2] == pytest.approx( + BELT_TOP_Z + scene.ground.workspace_origin_offset[2] + ) + for name, x in ( + ("RightInner", BELT_CENTER_X + BELT_HALF_STRAIGHT), + ("LeftInner", BELT_CENTER_X - BELT_HALF_STRAIGHT), + ): + turn = next(section.belt for section in sections if f"{name}Turn" in section.geometry.name) + assert turn.pivot_point == (x, sign * BELT_CENTER_Y, 0) + assert turn.radius == BELT_TURN_RADIUS + authored = layer.GetPrimAtPath(f"/Warehouse/WorkcellConveyors/{side}{name}") + position = authored.attributes["xformOp:translate"].default + scale = authored.attributes["xformOp:scale"].default + angle = math.radians(authored.attributes["xformOp:rotateZ"].default) + # A03's original circular pivot is at (0, -1.4961); the referenced arc lands on the physical bend. + radius = 1.4961 * scale[0] + assert radius == pytest.approx(BELT_TURN_RADIUS) + assert position[0] + radius * math.sin(angle) == pytest.approx(x) + assert position[1] - radius * math.cos(angle) == pytest.approx(sign * BELT_CENTER_Y) + + +def test_warehouse_animation_uses_active_kit_viewer_and_policy_time(tmp_path, monkeypatch) -> None: + """A CLI-selected Kit viewer animates and loops authored parcels without changing task state.""" + from pxr import Usd, UsdGeom + + from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_env import ConveyorFrankaEnv + from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_warehouse_env import ConveyorFrankaWarehouseEnv + + path = str(tmp_path / "traffic.usda") + source = Usd.Stage.CreateNew(path) + root = UsdGeom.Xform.Define(source, "/Warehouse").GetPrim() + source.SetDefaultPrim(root) + source.SetTimeCodesPerSecond(60) + source.SetEndTimeCode(120) + parcel = UsdGeom.Xform.Define(source, "/Warehouse/Parcels/Parcel00") + translate = parcel.AddTranslateOp() + translate.Set((0, 0, 1), 0) + translate.Set((2, 0, 1), 120) + parcel.AddRotateXYZOp().Set((0, 0, 0)) + source.GetRootLayer().Save() + stage = Usd.Stage.CreateInMemory() + stage.DefinePrim("/World/envs/env_0/WarehouseVisual").GetReferences().AddReference(path) + callbacks = {} + + def initialize(env, cfg, **kwargs): + env.cfg = cfg + env.common_step_counter = 60 + env.scene = SimpleNamespace(env_prim_paths=["/World/envs/env_0"]) + env.sim = SimpleNamespace( + stage=stage, + visualizers=[SimpleNamespace(cfg=SimpleNamespace(visualizer_type="kit"))], + set_setting=lambda name, value: None, + add_render_callback=lambda name, callback: callbacks.update({name: callback}), + remove_render_callback=lambda name: callbacks.pop(name, None), + ) + + monkeypatch.setattr(ConveyorFrankaEnv, "__init__", initialize) + monkeypatch.setattr(ConveyorFrankaEnv, "close", lambda env: None) + cfg = ConveyorFrankaA09A12EnvCfg() + # CLI viewer selection is resolved by SimulationContext, not written back into this list. + cfg.sim.visualizer_cfgs = [] + cfg.scene.warehouse_visual.spawn.usd_path = path + env = ConveyorFrankaWarehouseEnv(cfg) + try: + callback = callbacks["conveyor_warehouse_animation"] + callback(None) + position = stage.GetPrimAtPath("/World/envs/env_0/WarehouseVisual/Parcels/Parcel00").GetAttribute( + "xformOp:translate" + ) + assert tuple(position.Get()) == pytest.approx((1, 0, 1)) + env.common_step_counter = 180 + callback(None) + assert tuple(position.Get()) == pytest.approx((1, 0, 1)) + finally: + env.close() + _presentation_layer.cache_clear() + assert not callbacks diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py index 3ad79475f00d..754b955e86f8 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py @@ -7,6 +7,8 @@ from collections import Counter +import pytest + from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_env_cfg import _collision_properties, _cube from isaaclab_tasks.contrib.conveyor_franka.conveyor_geometry import ( MeshSpec, @@ -24,10 +26,22 @@ def _edge_use_counts(spec: MeshSpec) -> Counter[tuple[int, int]]: return edges -def test_belt_top_faces_point_upward(): +@pytest.mark.parametrize("warehouse", [False, True]) +def test_belt_top_faces_point_upward(warehouse): """One-sided triangle-mesh surfaces support parcels from above.""" for side in ("Left", "Right"): - specs = [belt_mesh_spec(side), *guard_mesh_specs(side)] + if warehouse: + from isaaclab_tasks.contrib.conveyor_franka.conveyor_warehouse_geometry import ( + warehouse_belt_sections, + warehouse_guard_meshes, + ) + + specs = [ + section.geometry for section in warehouse_belt_sections(side) if isinstance(section.geometry, MeshSpec) + ] + specs.extend(warehouse_guard_meshes(side)) + else: + specs = [belt_mesh_spec(side), *guard_mesh_specs(side)] for spec in specs: assert set(_edge_use_counts(spec).values()) == {2} _assert_top_faces_point_upward(spec) @@ -53,3 +67,23 @@ def test_contact_configuration_uses_one_mujoco_parameterization(): assert cube_material.contact_damping is None assert cube_material.torsional_friction is None assert cube_material.rolling_friction is None + + +def test_elevated_belt_normals_accept_contacts_on_every_ramp_panel(): + """Inclined parcels receive traction instead of sliding into the bottom transition.""" + import numpy as np + + from isaaclab_tasks.contrib.conveyor_franka.conveyor_warehouse_geometry import warehouse_belt_sections + + for side in ("Left", "Right"): + for section in warehouse_belt_sections(side): + if section.belt.curved or abs(section.belt.direction[2]) < 1e-6: + continue + vertices = np.asarray(section.geometry.vertices) + triangles = vertices[np.asarray(section.geometry.faces)] + normals = np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]) + top = normals[normals[:, 2] > 1e-8] + top /= np.linalg.norm(top, axis=1, keepdims=True) + assert len(top) > 0 + assert np.all(top @ section.belt.surface_normal >= section.belt.contact_threshold) + assert abs(np.dot(section.belt.direction, section.belt.surface_normal)) < 1e-6 diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py index cb75f41feea3..69f071bbdcb8 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Racetrack policy contracts exercised through real CPU managers and assets.""" +"""Conveyor policy and sorting contracts exercised through real CPU managers and assets.""" from contextlib import closing @@ -29,6 +29,7 @@ deployment_probability_from_progress, reset_sampling_probabilities, ) +from isaaclab_tasks.contrib.conveyor_franka.mdp.kinematics import end_effector_pose from isaaclab_tasks.contrib.conveyor_franka.mdp.reset_events import ( CUBE_COUNT, ConveyorResetRecipe, @@ -42,6 +43,7 @@ from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry, parse_env_cfg _BASE = "IsaacContrib-Conveyor-Racetrack-Transfer-v0" +_SORTER = "IsaacContrib-Conveyor-Warehouse-Sorting-v0" def _environment(task): @@ -76,24 +78,63 @@ def _place(env, positions): env.scene.update(env.step_dt) -def test_checkpoint_interface_and_invalid_action_recovery(): - """The real racetrack preserves learned feature order, finite rewards and independent resets.""" - with closing(_environment(_BASE)) as env: +@pytest.mark.parametrize("task", [_BASE, _SORTER]) +def test_checkpoint_interface_and_invalid_action_recovery(task): + """Both real tasks preserve learned feature ordering, finite rewards and independent reset flags.""" + with closing(_environment(task)) as env: obs, _ = env.reset() assert obs["policy"].shape == (2, 123) assert env.action_manager.total_action_dim == 8 assert env.physics_dt == 1 / 120 and env.step_dt == 1 / 60 result = env.step(torch.zeros(2, 8)) assert torch.isfinite(result[0]["policy"]).all() and torch.isfinite(result[1]).all() + pool = getattr(env, "conveyor_cube_pool", None) + if pool is not None: + assert not env.curriculum_manager.active_terms, "Warehouse training must not use phase reset rows." + env.command_manager.get_term("transfer").has_target[:] = torch.tensor([False, True]) + env.step(torch.full((2, 8), 0.5)) + torch.testing.assert_close(env.action_manager.action, torch.full((2, 8), 0.5)) + env.command_manager.get_term("transfer").has_target[:] = torch.tensor([False, True]) + env.cfg.park_when_idle = True + env.step(torch.full((2, 8), 0.5)) + torch.testing.assert_close(env.action_manager.action[1], torch.full((8,), 0.5)) + assert env.action_manager.action[0, :7].abs().max() <= 0.25 + assert env.action_manager.action[0, 7] == 0 positions = _waiting_positions(env) positions[:, :4] = torch.tensor([[0.4, 0.27, 0.06], [0.6, -0.27, 0.06], [0.8, 0, 0.2], [0.9, -0.27, 0.06]]) positions[1, 2] = torch.tensor([0.8, -0.27, 0.2]) + if pool is not None: + pool.slot_ids[:] = torch.tensor([[4, 1, 2, 3], [0, 5, 2, 3]]) + positions[0, 4] = torch.tensor([0.52, 0.27, 0.06]) + positions[1, 5] = torch.tensor([0.72, -0.27, 0.06]) _place(env, positions) + if pool is not None: + env.sim.step(render=False) + model = env.sim.physics_manager.get_model() + contacts = env.sim.physics_manager.get_contacts() + count = int(contacts.rigid_contact_count.numpy()[0]) + assert count > 0 + bodies = model.shape_body.numpy() + first = bodies[contacts.rigid_contact_shape0.numpy()[:count]] + second = bodies[contacts.rigid_contact_shape1.numpy()[:count]] + assert ((first >= 0) | (second >= 0)).all() + _place(env, positions) command = env.command_manager.get_term("transfer") env.episode_length_buf[:] = torch.tensor([11, 19]) command.set_goal(2) + if pool is not None: + command.has_target.fill_(True) + if pool is not None: + pose = pool.assets[4].data.root_pose_w.torch.clone() + pose[0, 3:] = torch.tensor([2**-0.5, 0, 0, 2**-0.5]) + velocity = torch.zeros(2, 6) + velocity[0] = torch.tensor([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + pool.assets[4].write_root_pose_to_sim_index(root_pose=pose, skip_forward=True) + pool.assets[4].write_root_velocity_to_sim_index(root_velocity=velocity, skip_forward=True) + env.sim.forward() + env.scene.update(env.step_dt) policy = env.observation_manager.compute()["policy"] - expected = positions[:, :4] + expected = positions[:, :4] if pool is None else positions[torch.arange(2)[:, None], pool.slot_ids] torch.testing.assert_close(policy[:, 16:28], expected.flatten(1)) torch.testing.assert_close(policy[:, 76:79], positions[:, 2]) torch.testing.assert_close(policy[:, 85:89], torch.tensor([[0, 0, 1, 0]] * 2).float()) @@ -102,15 +143,29 @@ def test_checkpoint_interface_and_invalid_action_recovery(): policy[:, 89:101], torch.tensor([[1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1], [1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1]]).float(), ) + if pool is not None: + torch.testing.assert_close(policy[0, 40:43], torch.tensor([0.0, -1.0, 0.0]), atol=1e-6, rtol=0) + torch.testing.assert_close(policy[0, 52:58], velocity[0]) + remote = positions.clone() + remote[:, 2:4] = torch.tensor([[2.8, 0.1, 0.56], [1.2, -0.59, 0.16]]) + _place(env, remote) + adapted = env.observation_manager.compute()["policy"][:, 16:28].reshape(2, 4, 3) + torch.testing.assert_close(adapted[:, 2:, 1:], torch.tensor([[[0.75, 0.06], [-0.75, 0.06]]] * 2)) + physical = ( + torch.stack([p.data.root_pos_w.torch for p in pool.assets], dim=1) - env.scene.env_origins[:, None] + ) + torch.testing.assert_close(physical, remote) + _place(env, positions) assert command.held_cube_ids.tolist() == [-1, -1] assert command.subgoal_start_steps.tolist() == [11, 19] - command.subgoal_start_steps[:] = env.episode_length_buf - torch.tensor([1199, 1200]) - assert mdp.subgoal_time_out(env, timeout_s=20).tolist() == [False, True] - command.pending_success[1] = True - assert not mdp.subgoal_time_out(env, timeout_s=20).any() - command.transfer_counts[:] = torch.tensor([7, 8]) - assert mdp.transfer_sequence_time_out(env, maximum_transfers=8).tolist() == [False, True] - command.pending_success.zero_() + if pool is None: + command.subgoal_start_steps[:] = env.episode_length_buf - torch.tensor([1199, 1200]) + assert mdp.subgoal_time_out(env, timeout_s=20).tolist() == [False, True] + command.pending_success[1] = True + assert not mdp.subgoal_time_out(env, timeout_s=20).any() + command.transfer_counts[:] = torch.tensor([7, 8]) + assert mdp.transfer_sequence_time_out(env, maximum_transfers=8).tolist() == [False, True] + command.pending_success.zero_() arm = env.action_manager.get_term("arm_action") gripper = env.action_manager.get_term("gripper_action") previous = torch.tensor([[0.1] * 7 + [-1.0], [-0.2] * 7 + [1.0]]) @@ -124,6 +179,7 @@ def test_checkpoint_interface_and_invalid_action_recovery(): robot = env.scene["robot"] joint_ids, _ = robot.find_joints("panda_joint[1-7]", preserve_order=True) joints = robot.data.joint_pos.torch.clone() + # Interior joint positions make the learned 0.12-rad residual independently observable. initial = torch.tensor([0.05, 0.2, -0.1, -2.25, 0, 2.45, 0.775]).repeat(2, 1) joints[:, joint_ids] = initial robot.write_joint_position_to_sim_index(position=joints) @@ -138,6 +194,7 @@ def test_checkpoint_interface_and_invalid_action_recovery(): torch.testing.assert_close(gripper.processed_actions, torch.tensor([[0.0, 0.0], [0.04, 0.04]])) assert torch.isfinite(arm.processed_actions).all() and torch.isfinite(gripper.processed_actions).all() assert torch.isfinite(mdp.finite_action_rate_l2(env)).all() + # Isolate an arm-only failure from a gripper-only failure. bad[0, 7] = -1 env.action_manager.process_action(bad) assert mdp.invalid_action(env).tolist() == [True, True] @@ -145,6 +202,8 @@ def test_checkpoint_interface_and_invalid_action_recovery(): assert mdp.invalid_action(env).tolist() == [False, True] env.action_manager.reset() assert not mdp.invalid_action(env).any() + if pool is not None: + command.has_target.zero_() terminated = env.step(bad) assert terminated[2].tolist() == [True, True] assert torch.isfinite(terminated[0]["policy"]).all() and torch.isfinite(terminated[1]).all() @@ -154,6 +213,141 @@ def test_checkpoint_interface_and_invalid_action_recovery(): cfg.validate() +def test_sorter_dispatch_inventory_and_selected_resets(): + """Actual parcels are shuffled, individually dispatched, pinned while grasped, and left sorted.""" + from isaaclab_tasks.contrib.conveyor_franka.conveyor_warehouse_geometry import warehouse_parcel_positions + + with closing(_environment(_SORTER)) as env: + env.reset() + pool = env.conveyor_cube_pool + before_assignments = pool.assignment_counts.clone() + before_transfers = pool.transfer_counts.clone() + before_slots = pool.slot_ids[0].clone() + before_velocity = torch.stack([p.data.root_vel_w.torch.clone() for p in pool.assets], dim=1) + before = torch.stack([p.data.root_pose_w.torch.clone() for p in pool.assets], dim=1) + repeated = None + for ids in (torch.tensor([1]), slice(1, 2)): + pool.assignment_counts.fill_(7) + pool.transfer_counts.fill_(3) + velocity = pool.assets[-1].data.root_vel_w.torch.clone() + velocity[1].fill_(0.2) + pool.assets[-1].write_root_velocity_to_sim_index(root_velocity=velocity) + torch.manual_seed(42) + env._reset_idx(ids) + assert not pool.assets[-1].data.root_vel_w.torch[1].any() + torch.testing.assert_close( + pool.assignment_counts[1], torch.tensor([1] * CUBE_COUNT + [0] * (len(pool.assets) - CUBE_COUNT)) + ) + assert not pool.transfer_counts[1].any() + assert (pool.assignment_counts[0] == 7).all() and (pool.transfer_counts[0] == 3).all() + actual = torch.stack([p.data.root_pose_w.torch.clone() for p in pool.assets], dim=1) + torch.testing.assert_close(actual[0], before[0]) + torch.testing.assert_close(pool.slot_ids[0], before_slots) + torch.testing.assert_close(pool.slot_ids[1], torch.arange(4)) + torch.testing.assert_close( + torch.stack([p.data.root_vel_w.torch[0] for p in pool.assets]), before_velocity[0] + ) + local = actual[1, :, :3] - env.scene.env_origins[1] + destinations = torch.tensor(warehouse_parcel_positions()) + distance = torch.linalg.vector_norm(local[:, None] - destinations[None], dim=-1) + assert distance.min(1).values.max() < 1e-5 + assert distance.argmin(1).unique().numel() == len(pool.assets) + assert not torch.allclose(local, destinations) + if repeated is not None: + torch.testing.assert_close(actual, repeated) + repeated = actual.clone() + pool.assignment_counts.copy_(before_assignments) + pool.transfer_counts.copy_(before_transfers) + positions = _waiting_positions(env) + positions[:, 0] = torch.tensor([0.7, 0.27, 0.06]) # Correct class, so leave it alone. + positions[0, 2:4] = torch.tensor([[0.6, 0.27, 0.06], [0.6, -0.27, 0.06]]) + positions[0, 7] = torch.tensor([0.9, 0.27, 0.06]) + pool.assignment_counts[0, 7] = 100 # A repeatedly assigned, nearer arrival must not starve a new one. + positions[0, 5] = torch.tensor([0.8, 0.27, 0.06]) # Arrival outside the initial four policy slots. + _place(env, positions) + command = env.command_manager.get_term("transfer") + command.has_target.zero_() + command._update_command() + assert command.has_target.tolist() == [True, False] + slot = int(command.target_cube_ids[0]) + assert pool.slot_ids[0, slot] == 5 + assert command.command[0, -2:].tolist() == [0, 1] + assert pool.slot_ids[0, 0] == 0 + assert command.metrics["sorted_parcels"].tolist() == [3, 1] + torch.testing.assert_close(pool.assets[5].data.root_pos_w.torch[0] - env.scene.env_origins[0], positions[0, 5]) + # Move the assigned parcel into a real closed-gripper grasp, even outside the pickup lane. + robot = env.scene["robot"] + fingers, _ = robot.find_joints("panda_finger_joint[1-2]", preserve_order=True) + joints = robot.data.joint_pos.torch.clone() + joints[:, fingers] = 0.019 + robot.write_joint_position_to_sim_index(position=joints) + arms, _ = robot.find_joints("panda_joint[1-7]", preserve_order=True) + lift = next( + r + for r in build_reset_rows() + if r.recipe == ConveyorResetRecipe.LIFT + and r.variant_id == 3 + and r.target_cube_id == 0 + and r.source_side_id == 0 + ) + joints[:, arms] = torch.tensor(lift.arm_positions) + robot.write_joint_position_to_sim_index(position=joints) + env.sim.forward() + env.scene.update(env.step_dt) + tool, _ = end_effector_pose(env) + positions[0, 5] = tool[0] - env.scene.env_origins[0] + _place(env, positions) + assert mdp.physical_cube_acquisition_mask(env)[0] + command._update_command() + assert pool.slot_ids[0, slot] == 5 and command.has_target[0] + positions[0, 5] = torch.tensor([0.8, 0.27, 0.06]) + joints[:, fingers] = 0.04 + robot.write_joint_position_to_sim_index(position=joints) + _place(env, positions) + env.episode_length_buf += command.cfg.minimum_subgoal_steps + command.evaluate() + assert not command.pending_success[0] + positions[0, 5] = torch.tensor([0.8, -0.27, 0.06]) + joints[:, fingers] = 0.04 + robot.write_joint_position_to_sim_index(position=joints) + _place(env, positions) + env.episode_length_buf += command.cfg.minimum_subgoal_steps + for _ in range(command.cfg.hold_steps): + env.episode_length_buf += 1 + command.evaluate() + assert pool.transfer_counts[0, 5] == 1 and pool.transfer_counts[1].sum() == 0 + # Completed classes stay circulating and cannot keep paying a previous success reward. + positions[:, :, 0] = 0.6 + positions[:, :, 1] = torch.tensor([0.27, -0.27] * (len(pool.assets) // 2)) + positions[:, :, 2] = 0.06 + _place(env, positions) + command._update_command() + assert not command.has_target.any() + assert command.metrics["batch_complete"].tolist() == [1, 1] + command.new_success.fill_(True) + command.is_success.fill_(True) + command.evaluate() + assert not command.new_success.any() and not command.is_success.any() + assert not mdp.cube_out_of_workspace(env, **env.cfg.terminations.cube_out_of_workspace.params).any() + # Present every identity as a separate arrival and let the real dispatcher refill its slots. + for parcel_id in range(len(pool.assets)): + positions[0] = _waiting_positions(env)[0] + source_y = -0.27 if command.parcel_destinations[parcel_id] == 0 else 0.27 + positions[0, parcel_id] = torch.tensor([0.8, source_y, 0.06]) + _place(env, positions) + command.pending_success[0] = True + command._update_command() + assert pool.slot_ids[0, command.target_cube_ids[0]] == parcel_id + assert (pool.assignment_counts[0] > 0).all() + unassigned = next(i for i in range(len(pool.assets)) if i not in pool.slot_ids[0]) + positions[0, unassigned, 2] = -1 + _place(env, positions) + assert mdp.cube_out_of_workspace(env, **env.cfg.terminations.cube_out_of_workspace.params).tolist() == [ + True, + False, + ] + + def test_curriculum_checkpoint_restores_progress_and_deployment_outcomes(tmp_path): """Actual RSL-RL saves preserve reset evidence; phase progress alone cannot advance deployment.""" with closing(_environment(_BASE)) as env: From bb347941bc92b9db65a93c36f6f46129eb27e960 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Wed, 7 Oct 2026 18:24:58 +0200 Subject: [PATCH 05/45] Advance warehouse animation during headless Kit playback --- .../maximiliank-conveyor-warehouse.added.rst | 2 +- .../conveyor_franka_warehouse_env.py | 11 +++++------ .../contrib/test_conveyor_franka_asset_cfg.py | 17 ++++++++++------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.added.rst b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.added.rst index ddd7e5c24658..0e1f45b16423 100644 --- a/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.added.rst +++ b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-warehouse.added.rst @@ -1 +1 @@ -* Added warehouse parcel sorting with four dynamically assigned policy slots, colored batches, circulating conveyors, and referenced USD presentation assets. +* Added warehouse parcel sorting with four dynamically assigned policy slots, colored batches, circulating conveyors, and referenced USD presentation assets. Background parcel motion follows simulation steps in both interactive Kit playback and headless recordings. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py index 5f76eaafdca1..b94500cdcd3b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py @@ -58,7 +58,6 @@ def __init__(self, cfg: ConveyorFrankaA09A12EnvCfg, render_mode: str | None = No target = self.sim.stage.GetPrimAtPath(f"{env_path}/WarehouseVisual/Parcels/{source.GetName()}") for name in ("xformOp:translate", "xformOp:rotateXYZ"): self._warehouse_animation.append((source.GetAttribute(name), target.GetAttribute(name))) - self.sim.add_render_callback("conveyor_warehouse_animation", self._animate_warehouse) def load_managers(self) -> None: """Create slot identities before command, reward, and observation terms inspect cubes.""" @@ -98,7 +97,9 @@ def _adapt_policy_cube_state(self, positions, quaternions, velocities): ) def step(self, action: torch.Tensor) -> VecEnvStepReturn: - """Pass training actions through; optionally park during idle policy playback.""" + """Advance presentation motion; pass training actions through or park during idle playback.""" + if self._warehouse_animation: + self._animate_warehouse() if not self.cfg.park_when_idle: return super().step(action) command = self.command_manager.get_term("transfer") @@ -117,7 +118,7 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: use_policy = command.has_target | ~torch.isfinite(action).all(dim=1) return super().step(torch.where(use_policy[:, None], action, parked)) - def _animate_warehouse(self, _event) -> None: + def _animate_warehouse(self) -> None: """Sample USD motion using policy time, independently of render frame rate.""" from pxr import Sdf @@ -148,8 +149,6 @@ def _reset_idx(self, env_ids) -> None: cube.write_root_velocity_to_sim_index(root_velocity=pose.new_zeros((len(env_ids), 6)), env_ids=env_ids) def close(self) -> None: - """Remove the presentation callback before releasing the shared task resources.""" - if getattr(self, "sim", None) is not None: - self.sim.remove_render_callback("conveyor_warehouse_animation") + """Release presentation attributes before closing the shared task resources.""" self._warehouse_animation.clear() super().close() diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py index c9ef102772b6..96901a675a9e 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py @@ -167,6 +167,8 @@ def test_asset_transforms_preserve_the_original_workcell() -> None: def test_warehouse_animation_uses_active_kit_viewer_and_policy_time(tmp_path, monkeypatch) -> None: """A CLI-selected Kit viewer animates and loops authored parcels without changing task state.""" + import torch + from pxr import Usd, UsdGeom from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_env import ConveyorFrankaEnv @@ -186,7 +188,6 @@ def test_warehouse_animation_uses_active_kit_viewer_and_policy_time(tmp_path, mo source.GetRootLayer().Save() stage = Usd.Stage.CreateInMemory() stage.DefinePrim("/World/envs/env_0/WarehouseVisual").GetReferences().AddReference(path) - callbacks = {} def initialize(env, cfg, **kwargs): env.cfg = cfg @@ -196,28 +197,30 @@ def initialize(env, cfg, **kwargs): stage=stage, visualizers=[SimpleNamespace(cfg=SimpleNamespace(visualizer_type="kit"))], set_setting=lambda name, value: None, - add_render_callback=lambda name, callback: callbacks.update({name: callback}), - remove_render_callback=lambda name: callbacks.pop(name, None), + add_render_callback=lambda name, callback: None, + remove_render_callback=lambda name: None, ) monkeypatch.setattr(ConveyorFrankaEnv, "__init__", initialize) monkeypatch.setattr(ConveyorFrankaEnv, "close", lambda env: None) + monkeypatch.setattr(ConveyorFrankaEnv, "step", lambda env, action: None) cfg = ConveyorFrankaA09A12EnvCfg() # CLI viewer selection is resolved by SimulationContext, not written back into this list. cfg.sim.visualizer_cfgs = [] cfg.scene.warehouse_visual.spawn.usd_path = path env = ConveyorFrankaWarehouseEnv(cfg) + action = torch.zeros(1, 8) try: - callback = callbacks["conveyor_warehouse_animation"] - callback(None) + # Headless Kit recording captures on demand, without invoking render callbacks. + env.step(action) position = stage.GetPrimAtPath("/World/envs/env_0/WarehouseVisual/Parcels/Parcel00").GetAttribute( "xformOp:translate" ) assert tuple(position.Get()) == pytest.approx((1, 0, 1)) env.common_step_counter = 180 - callback(None) + env.step(action) assert tuple(position.Get()) == pytest.approx((1, 0, 1)) finally: env.close() _presentation_layer.cache_clear() - assert not callbacks + assert not env._warehouse_animation From f54d05c25433e386913e56a6d99b6364a944aa30 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 8 Oct 2026 13:43:48 +0200 Subject: [PATCH 06/45] Trim unused conveyor adapter state and compatibility APIs --- .../isaaclab_contrib/conveyors/README.md | 6 +- .../isaaclab_contrib/conveyors/newton.py | 172 ++---------------- .../isaaclab_contrib/conveyors/physx.py | 48 +---- .../conveyors/surface_velocity.py | 11 +- .../conveyors/test_surface_velocity_newton.py | 4 +- .../conveyors/test_surface_velocity_physx.py | 13 +- 6 files changed, 28 insertions(+), 226 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md b/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md index 751755a2487e..1c5a84cba40b 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md @@ -14,8 +14,8 @@ experimental and is not exported from Isaac Lab core or the backend packages. Author the API before simulation parsing with `apply_surface_velocity_api`. This path requires the PhysX backend and Kit; GPU dynamics are unsupported. -Both adapters retain commanded speeds while disabled and clear selected worlds' -runtime state on reset. The Newton adapter supports live traction controls; -PhysX uses authored contact materials and rejects unsupported traction setters. +Both adapters retain commanded speeds while disabled and restart their startup +ramp on a full reset. Newton also clears stale forces for selected worlds. +Newton uses the spec's traction parameters; PhysX uses authored contact materials. No meshes, assets, task registrations, or policy checkpoints are bundled here. diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py index 52e4c5b8fcfa..61845a1bcdb0 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py @@ -19,18 +19,6 @@ import warp as wp from isaaclab_newton.physics import NewtonManager from newton.examples.basic.example_basic_conveyor_forces import ConveyorForceModel -from newton.examples.basic.example_basic_conveyor_forces import ( - Vec3Pair as Vec3Pair, -) -from newton.examples.basic.example_basic_conveyor_forces import ( - compute_basis_vectors as compute_basis_vectors, -) -from newton.examples.basic.example_basic_conveyor_forces import ( - compute_point_force as compute_point_force, -) -from newton.examples.basic.example_basic_conveyor_forces import ( - compute_point_impulse as compute_point_impulse, -) from isaaclab.physics import PhysicsEvent @@ -68,16 +56,6 @@ def _advance_startup_scale( scale[0] = wp.min(1.0, elapsed[0] / duration) -@wp.kernel -def _integrate_encoders( - dt: wp.float32, - effective_velocity: wp.array[wp.float32], - position: wp.array[wp.float32], -): - conveyor_id = wp.tid() - position[conveyor_id] += dt * effective_velocity[conveyor_id] - - @wp.kernel def _update_effective_velocities( commanded_velocity: wp.array[wp.float32], @@ -89,20 +67,10 @@ def _update_effective_velocities( @wp.kernel -def _gather_float_values( - source: wp.array[wp.float32], +def _gather_values( + source: wp.array(dtype=Any), indices: wp.array[wp.int32], - values: wp.array[wp.float32], -): - output_id = wp.tid() - values[output_id] = source[indices[output_id]] - - -@wp.kernel -def _gather_int_values( - source: wp.array[wp.int32], - indices: wp.array[wp.int32], - values: wp.array[wp.int32], + values: wp.array(dtype=Any), ): output_id = wp.tid() values[output_id] = source[indices[output_id]] @@ -120,25 +88,6 @@ def _clear_selected_body_forces( body_force[body_id] = wp.spatial_vector() -@wp.kernel -def _clear_selected_encoders( - conveyor_world: wp.array[wp.int32], - world_mask: wp.array[wp.bool], - encoder_position: wp.array[wp.float32], -): - conveyor_id = wp.tid() - world_id = conveyor_world[conveyor_id] - if world_mask[world_id]: - encoder_position[conveyor_id] = 0.0 - - -def _require_buffer_length(name: str, buffer: Any, expected: int) -> None: - """Reject missing or mis-sized buffers before a Warp launch can access them.""" - actual = len(buffer) if buffer is not None else 0 - if actual != expected: - raise RuntimeError(f"Conveyor force buffer {name!r} has length {actual}, expected {expected}.") - - def _as_numpy(values: Any) -> np.ndarray: """Convert supported tensor-like values to a host NumPy array.""" if hasattr(values, "detach"): @@ -210,7 +159,7 @@ def _validate_newton_surface_specs(surface_specs: Sequence[SurfaceVelocitySpec]) class SurfaceVelocity: - """Adapt Newton's MuJoCo conveyor force model to the Isaac Lab lifecycle. + """Adapt Newton's upstream conveyor force model to the Isaac Lab lifecycle. The driver is created after the simulation context but before its first reset. It requests solved contact forces before model finalization, then @@ -308,11 +257,6 @@ def num_surfaces(self) -> int: """Total number of resolved surfaces across all environments.""" return self._num_envs * self.surfaces_per_env - @property - def count(self) -> int: - """Alias for :attr:`num_surfaces`, matching tensor-view naming.""" - return self.num_surfaces - @property def initialized(self) -> bool: """Whether the driver is bound to the active Newton solver.""" @@ -323,11 +267,6 @@ def prim_paths(self) -> tuple[str, ...]: """Resolved Newton shape labels in environment-major belt order.""" return self._require_binding().surface_paths - @property - def surface_paths(self) -> tuple[str, ...]: - """Alias for :attr:`prim_paths`.""" - return self.prim_paths - def set_velocities(self, velocities: Any, indices: Any = None) -> None: """Set signed surface speeds, preserving commands while surfaces are disabled.""" self._require_binding().set_velocities(velocities, indices) @@ -348,12 +287,8 @@ def get_enabled(self, indices: Any = None, clone: bool = True) -> wp.array: """Return integer enabled flags for selected surfaces.""" return self._require_binding().get_enabled(indices, clone) - def get_encoder_positions(self, indices: Any = None, clone: bool = True) -> wp.array: - """Return physics-rate integrated surface travel distances [m].""" - return self._require_binding().get_encoder_positions(indices, clone) - def reset(self, env_ids: Any = None) -> None: - """Clear stale force and encoder state for selected environments.""" + """Clear stale force state for selected environments.""" self._require_binding().reset(env_ids) def _request_contact_forces(self, _event: Any) -> None: @@ -418,18 +353,8 @@ def __init__( startup_duration_s: Duration of the initial traction ramp [s]. env_path_format: Format string resolving one exact environment root from its integer world index. """ - if not isinstance(num_envs, int) or isinstance(num_envs, bool) or num_envs <= 0: - raise ValueError(f"num_envs must be a positive integer, got {num_envs!r}.") - if not np.isfinite(startup_duration_s) or startup_duration_s <= 0.0: - raise ValueError(f"Conveyor startup duration must be positive, got {startup_duration_s}.") - _validate_env_path_format(env_path_format) - self._surface_specs = tuple(surface_specs) - _validate_newton_surface_specs(self._surface_specs) - try: - compiled_body_pattern = re.compile(body_pattern) - except re.error as exc: - raise ValueError(f"Invalid body pattern: {body_pattern!r}.") from exc + compiled_body_pattern = re.compile(body_pattern) if model is None or contacts is None: raise RuntimeError("The conveyor driver requires an initialized Newton model and contact buffer.") @@ -447,7 +372,6 @@ def __init__( self._num_envs = num_envs self._startup_duration_s = startup_duration_s self._closed = False - self._validate_backend_buffers() surfaces_per_env = len(self._surface_specs) conveyor_count = num_envs * surfaces_per_env @@ -456,7 +380,6 @@ def __init__( pivot_point = [wp.vec3() for _ in range(conveyor_count)] radius = [1.0] * conveyor_count surface_normal = [wp.vec3() for _ in range(conveyor_count)] - conveyor_world = [conveyor_id // surfaces_per_env for conveyor_id in range(conveyor_count)] surface_paths = [""] * conveyor_count shape_body = model.shape_body.numpy() @@ -548,7 +471,6 @@ def __init__( self._body_is_tracked = wp.array(body_is_tracked, dtype=wp.int32, device=self._device) self._direction = wp.array(direction, dtype=wp.vec3, device=self._device) self._radius = wp.array(radius, dtype=wp.float32, device=self._device) - self._conveyor_world = wp.array(conveyor_world, dtype=wp.int32, device=self._device) authored_velocity = np.asarray([spec.velocity for spec in self._surface_specs], dtype=np.float32) authored_enabled = np.asarray([spec.enabled for spec in self._surface_specs], dtype=np.int32) @@ -557,7 +479,6 @@ def __init__( self._command_velocity = wp.array(self._command_velocity_host, dtype=wp.float32, device=self._device) self._enabled = wp.array(self._enabled_host, dtype=wp.int32, device=self._device) self._effective_velocity = wp.zeros(conveyor_count, dtype=wp.float32, device=self._device) - self._encoder_position = wp.zeros(conveyor_count, dtype=wp.float32, device=self._device) self._elapsed_time = wp.zeros(1, dtype=wp.float32, device=self._device) self._conveyor.set_speed_scale(0.0) @@ -600,14 +521,10 @@ def set_enabled(self, flags: Any, indices: Any = None) -> None: def get_enabled(self, indices: Any = None, clone: bool = True) -> wp.array: """Return integer enabled flags for selected surfaces.""" - return self._get_device_int_values(self._enabled, indices, clone) - - def get_encoder_positions(self, indices: Any = None, clone: bool = True) -> wp.array: - """Return physics-rate integrated surface travel distances [m].""" - return self._get_device_values(self._encoder_position, indices, clone) + return self._get_device_values(self._enabled, indices, clone) def reset(self, env_ids: Any = None) -> None: - """Clear stale force and encoder state for selected environments. + """Clear stale force state for selected environments. A full reset also restarts the global startup ramp. Partial vectorized resets leave other environments' conveyor forces and startup state intact. @@ -617,7 +534,6 @@ def reset(self, env_ids: Any = None) -> None: """ if env_ids is None: self._conveyor.conveyor_body_f.zero_() - self._encoder_position.zero_() self._elapsed_time.zero_() self._conveyor.global_velocity_scale.zero_() return @@ -641,13 +557,6 @@ def reset(self, env_ids: Any = None) -> None: outputs=[self._conveyor.conveyor_body_f], device=self._device, ) - wp.launch( - _clear_selected_encoders, - dim=len(self._surface_paths), - inputs=[self._conveyor_world, self._world_mask], - outputs=[self._encoder_position], - device=self._device, - ) def close(self) -> None: """Deregister Newton callbacks and release references held by the driver.""" @@ -662,7 +571,7 @@ def apply(self, state) -> None: self._conveyor.apply(state) def update(self, solver, contacts, state, dt: float) -> None: - """Update surface travel and let Newton compute the next conveyor wrench.""" + """Advance the startup ramp and let Newton compute the next conveyor wrench.""" wp.launch( _advance_startup_scale, dim=1, @@ -670,13 +579,6 @@ def update(self, solver, contacts, state, dt: float) -> None: outputs=[self._elapsed_time, self._conveyor.global_velocity_scale], device=self._device, ) - wp.launch( - _integrate_encoders, - dim=len(self._surface_paths), - inputs=[dt, self._effective_velocity], - outputs=[self._encoder_position], - device=self._device, - ) self._conveyor.update(solver, contacts, state, dt) # Exclude robot links and other bodies outside the transported-body selection. wp.launch( @@ -687,41 +589,6 @@ def update(self, solver, contacts, state, dt: float) -> None: device=self._device, ) - def _validate_backend_buffers(self) -> None: - """Validate every fixed-size Newton buffer consumed by conveyor kernels.""" - model = self._model - contacts = self._contacts - _require_buffer_length("model.shape_body", model.shape_body, model.shape_count) - _require_buffer_length("model.shape_world", model.shape_world, model.shape_count) - _require_buffer_length("model.shape_transform", model.shape_transform, model.shape_count) - _require_buffer_length("model.body_world", model.body_world, model.body_count) - _require_buffer_length("model.body_com", model.body_com, model.body_count) - _require_buffer_length("model.body_inv_mass", model.body_inv_mass, model.body_count) - _require_buffer_length("model.body_inv_inertia", model.body_inv_inertia, model.body_count) - _require_buffer_length("contacts.force", contacts.force, contacts.rigid_contact_max) - _require_buffer_length( - "contacts.rigid_contact_shape0", contacts.rigid_contact_shape0, contacts.rigid_contact_max - ) - _require_buffer_length( - "contacts.rigid_contact_shape1", contacts.rigid_contact_shape1, contacts.rigid_contact_max - ) - _require_buffer_length( - "contacts.rigid_contact_normal", contacts.rigid_contact_normal, contacts.rigid_contact_max - ) - _require_buffer_length( - "contacts.rigid_contact_point0", contacts.rigid_contact_point0, contacts.rigid_contact_max - ) - _require_buffer_length( - "contacts.rigid_contact_point1", contacts.rigid_contact_point1, contacts.rigid_contact_max - ) - _require_buffer_length( - "contacts.rigid_contact_offset0", contacts.rigid_contact_offset0, contacts.rigid_contact_max - ) - _require_buffer_length( - "contacts.rigid_contact_offset1", contacts.rigid_contact_offset1, contacts.rigid_contact_max - ) - _require_buffer_length("contacts.rigid_contact_count", contacts.rigid_contact_count, 1) - def _resolve_indices(self, indices: Any) -> np.ndarray: """Normalize and validate a surface index selection.""" if indices is None: @@ -763,27 +630,10 @@ def _get_device_values(self, source: wp.array, indices: Any, clone: bool) -> wp. return wp.clone(source) if clone else source selected = self._resolve_indices(indices) selected_device = wp.array(selected, dtype=wp.int32, device=self._device) - values = wp.empty(len(selected), dtype=wp.float32, device=self._device) - if len(selected) > 0: - wp.launch( - _gather_float_values, - dim=len(selected), - inputs=[source, selected_device], - outputs=[values], - device=self._device, - ) - return values - - def _get_device_int_values(self, source: wp.array, indices: Any, clone: bool) -> wp.array: - """Clone an integer device buffer or gather a selected subset.""" - if indices is None: - return wp.clone(source) if clone else source - selected = self._resolve_indices(indices) - selected_device = wp.array(selected, dtype=wp.int32, device=self._device) - values = wp.empty(len(selected), dtype=wp.int32, device=self._device) + values = wp.empty(len(selected), dtype=source.dtype, device=self._device) if len(selected) > 0: wp.launch( - _gather_int_values, + _gather_values, dim=len(selected), inputs=[source, selected_device], outputs=[values], diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py index 93622a53f039..c492fdc9389b 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py @@ -18,7 +18,7 @@ import math from collections.abc import Sequence from dataclasses import dataclass -from typing import Any, Protocol +from typing import Any import numpy as np @@ -167,7 +167,7 @@ class SurfaceVelocity: The facade binds exact environment-major paths whose schemas were already authored by :func:`apply_surface_velocity_api`. Call :meth:`start` to register physics-rate updates, or call :meth:`update` manually. Commands and enabled state survive full resets; a full reset - clears encoders and restarts the one-second startup ramp. This facade authors USD attributes + restarts the startup ramp. This facade authors USD attributes on the host and is not a GPU conveyor implementation. """ @@ -179,7 +179,6 @@ def __init__( env_path_format: str = "/World/envs/env_{}", startup_duration_s: float = 1.0, stage: Any | None = None, - writer: Any | None = None, ) -> None: """Bind native surface attributes and initialize host-side control state. @@ -189,8 +188,6 @@ def __init__( env_path_format: Exact replicated environment path format. startup_duration_s: Duration of the global surface-speed ramp [s]. stage: Optional USD stage used by the default schema writer. - writer: Optional writer implementing ``write(index, enabled=..., twist=...)`` and - ``close()``. This import-light seam is primarily for focused tests. """ duration = _finite_float("startup_duration_s", startup_duration_s) if duration <= 0.0: @@ -204,11 +201,7 @@ def __init__( self._velocity_scale = 0.0 self._closed = False self._callback_handle: Any | None = None - self._writer: _SurfaceVelocityWriter = ( - writer - if writer is not None - else _PhysxSchemaSurfaceWriter(self._surface_paths, stage=stage, apply_api=False) - ) + self._writer = _PhysxSchemaSurfaceWriter(self._surface_paths, stage=stage, apply_api=False) self._command_velocity = np.tile( np.asarray([spec.velocity for spec in self._surface_specs], dtype=np.float32), self._num_envs @@ -216,7 +209,6 @@ def __init__( self._enabled = np.tile( np.asarray([spec.enabled for spec in self._surface_specs], dtype=np.bool_), self._num_envs ) - self._encoder_position = np.zeros(self.num_surfaces, dtype=np.float32) self._last_authored: list[tuple[bool, PhysxSurfaceVelocityTwist] | None] = [None] * self.num_surfaces self._flush(force=True) @@ -235,21 +227,11 @@ def prim_paths(self) -> tuple[str, ...]: """Return exact PhysX prim paths in environment-major order.""" return self._surface_paths - @property - def surface_paths(self) -> tuple[str, ...]: - """Return an alias for :attr:`prim_paths`.""" - return self.prim_paths - @property def num_surfaces(self) -> int: """Return the total number of bound conveyor surfaces.""" return len(self._surface_paths) - @property - def count(self) -> int: - """Return an alias for :attr:`num_surfaces`.""" - return self.num_surfaces - @property def initialized(self) -> bool: """Return whether the facade still owns live schema bindings.""" @@ -269,7 +251,7 @@ def start(self) -> None: ) def update(self, dt: float) -> None: - """Advance encoder state and the startup ramp by one physics step. + """Advance the startup ramp by one physics step. Args: dt: Positive physics step duration [s]. @@ -278,8 +260,6 @@ def update(self, dt: float) -> None: step = _finite_float("physics dt", dt) if step <= 0.0: raise ValueError(f"Conveyor physics dt must be positive, got {dt!r}.") - effective_velocity = self._command_velocity * self._enabled - self._encoder_position += np.asarray(step * effective_velocity, dtype=np.float32) self._elapsed_time = min(self._startup_duration_s, self._elapsed_time + step) self._velocity_scale = self._elapsed_time / self._startup_duration_s self._flush() @@ -317,12 +297,8 @@ def get_enabled(self, indices: Any = None, clone: bool = True) -> np.ndarray: """Return enabled flags as integer values.""" return self._get_values(self._enabled.astype(np.int32), indices, clone) - def get_encoder_positions(self, indices: Any = None, clone: bool = True) -> np.ndarray: - """Return physics-rate integrated commanded belt travel [m].""" - return self._get_values(self._encoder_position, indices, clone) - def reset(self, env_ids: Any = None) -> None: - """Clear selected encoders and restart the global ramp on a full reset. + """Restart the global startup ramp on a full reset. Velocity commands and enabled state are deliberately preserved, including across a full hard-reset path. Partial resets do not disturb the global ramp used by other environments. @@ -332,8 +308,6 @@ def reset(self, env_ids: Any = None) -> None: """ self._require_open() ids = self._resolve_env_ids(env_ids) - rows = (ids[:, None] * self._surfaces_per_env + np.arange(self._surfaces_per_env)[None, :]).reshape(-1) - self._encoder_position[rows] = 0.0 if len(np.unique(ids)) == self._num_envs: self._elapsed_time = 0.0 self._velocity_scale = 0.0 @@ -426,18 +400,6 @@ def _require_open(self) -> None: raise RuntimeError("The PhysX conveyor surface facade is closed.") -class _SurfaceVelocityWriter(Protocol): - """Minimal schema-writer seam used by the runtime facade.""" - - def write(self, index: int, *, enabled: bool, twist: PhysxSurfaceVelocityTwist) -> None: - """Author one bound surface state.""" - ... - - def close(self) -> None: - """Release retained schema attributes.""" - ... - - class _PhysxSchemaSurfaceWriter: """Cached USD attribute writer with all schema imports kept lazy.""" diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py index f00badb6b941..b5251657958b 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py @@ -63,7 +63,7 @@ class SurfaceVelocitySpec: The fields follow the authored conveyor model proposed for Isaac Sim while remaining independent of Kit, OpenUSD, and any physics backend. Directions, surface normals, and the optional pivot point are - expressed in the collision prim's local frame. Runtime state such as encoder positions and applied + expressed in the collision prim's local frame. Runtime state such as commanded speeds and applied forces intentionally does not belong in this description. ``prim_path`` may contain Isaac Lab's ``{ENV_REGEX_NS}`` placeholder. A backend resolves that template @@ -141,11 +141,6 @@ def num_surfaces(self) -> int: """Number of resolved moving surfaces.""" ... - @property - def count(self) -> int: - """Alias for :attr:`num_surfaces`, matching tensor-view naming.""" - ... - def set_velocities(self, velocities: Any, indices: Any = None) -> None: """Set signed surface velocities [m/s] for selected surfaces.""" ... @@ -166,10 +161,6 @@ def get_enabled(self, indices: Any = None, clone: bool = True) -> Any: """Return integer enabled flags for selected surfaces.""" ... - def get_encoder_positions(self, indices: Any = None, clone: bool = True) -> Any: - """Return integrated surface travel [m] for selected surfaces.""" - ... - def reset(self, env_ids: Any = None) -> None: """Clear runtime state for selected replicated environments.""" ... diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py index 9bea66335600..365358ad6929 100644 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py @@ -89,15 +89,15 @@ def initialized(model, contacts): driver.set_velocities([0.3, -0.25, -0.4], indices=[0, 1, 3]) driver.set_enabled([False, True], indices=[2, 3]) np.testing.assert_allclose(driver.get_velocities().numpy(), [0.3, 0, 0, -0.4]) + np.testing.assert_allclose(driver.get_velocities(indices=[3, 0]).numpy(), [-0.4, 0.3]) + np.testing.assert_array_equal(driver.get_enabled(indices=[2, 0]).numpy(), [0, 1]) sim.step(render=False) assert calls == ["actuator"] + ["force", "substep"] * cfg.physics.num_substeps - np.testing.assert_allclose(driver.get_encoder_positions().numpy(), np.array([0.3, 0, 0, -0.4]) / 120) first._conveyor.conveyor_body_f.assign(np.ones((2, 6), dtype=np.float32)) driver.reset([0]) state = model.state() first.apply(state) np.testing.assert_array_equal(state.body_f.numpy(), [[0] * 6, [1] * 6]) - np.testing.assert_allclose(driver.get_encoder_positions().numpy(), [0, 0, 0, -0.4 / 120]) sim.reset() assert first._closed and driver._binding is not first assert driver._binding._model is NewtonManager.get_model() diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py index 91348d447553..81d992469aae 100644 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py @@ -87,30 +87,29 @@ def test_paths_are_resolved_in_environment_major_order() -> None: surface_module.resolve_surface_velocity_paths(2, (SurfaceVelocitySpec(prim_path="/World/Shared/Belt"),)) -def test_facade_ramps_playback_integrates_encoders_and_preserves_commands_on_reset() -> None: +def test_facade_ramps_playback_and_preserves_commands_on_reset(monkeypatch: pytest.MonkeyPatch) -> None: """Full resets restart playback without erasing policy-visible command state.""" writer = _FakeWriter() - facade = surface_module.SurfaceVelocity(2, (_surface_spec(),), writer=writer) + monkeypatch.setattr(surface_module, "_PhysxSchemaSurfaceWriter", lambda *args, **kwargs: writer) + facade = surface_module.SurfaceVelocity(2, (_surface_spec(),)) assert facade.prim_paths == ("/World/envs/env_0/Belt", "/World/envs/env_1/Belt") - assert facade.num_surfaces == facade.count == 2 + assert facade.num_surfaces == 2 assert [record[2].linear_velocity for record in writer.writes] == [(0.0, 0.0, 0.0)] * 2 facade.update(0.25) np.testing.assert_allclose([record[2].linear_velocity[0] for record in writer.writes[-2:]], (0.1, 0.1)) - np.testing.assert_allclose(facade.get_encoder_positions(), (0.1, 0.1)) facade.set_velocities(torch.tensor([0.8], requires_grad=True), indices=torch.tensor([0])) facade.set_enabled(False, indices=[1]) facade.update(0.25) np.testing.assert_allclose(facade.get_commanded_velocities(), (0.8, 0.4)) np.testing.assert_allclose(facade.get_velocities(), (0.8, 0.0)) - np.testing.assert_allclose(facade.get_encoder_positions(), (0.3, 0.1)) + writes_before_reset = list(writer.writes) facade.reset(env_ids=torch.tensor([0])) - np.testing.assert_allclose(facade.get_encoder_positions(), (0.0, 0.1)) + assert writer.writes == writes_before_reset facade.reset(env_ids=[1, 0]) - np.testing.assert_allclose(facade.get_encoder_positions(), (0.0, 0.0)) np.testing.assert_allclose(facade.get_commanded_velocities(), (0.8, 0.4)) assert facade.get_enabled().tolist() == [1, 0] np.testing.assert_allclose(writer.writes[-2][2].linear_velocity, (0.0, 0.0, 0.0)) From d1aa9a16ebc2764e8c3aba52d1511b51ab7cf290 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 8 Oct 2026 13:44:46 +0200 Subject: [PATCH 07/45] Reuse conveyor prism geometry and simplify task helpers --- .../conveyor_franka/conveyor_franka_env.py | 10 +- .../conveyor_franka_env_cfg.py | 27 +--- .../conveyor_franka/conveyor_geometry.py | 131 ++++-------------- .../conveyor_franka/conveyor_goal_selector.py | 7 +- .../contrib/test_conveyor_franka_geometry.py | 2 + 5 files changed, 39 insertions(+), 138 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py index 9bdd7e0822f7..643fea691e61 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py @@ -53,10 +53,10 @@ def _init_sim(self) -> None: # command adapter is attached only after PhysX has parsed that scene. from isaaclab_newton.physics import NewtonCfg - from isaaclab_contrib.conveyors.newton import SurfaceVelocity - if isinstance(self.cfg.sim.physics, NewtonCfg): - driver = SurfaceVelocity( + from isaaclab_contrib.conveyors.newton import SurfaceVelocity as NewtonSurfaceVelocity + + driver = NewtonSurfaceVelocity( num_envs=self.cfg.scene.num_envs, surface_specs=belt_specs, startup_duration_s=self.cfg.conveyor_force.startup_duration_s, @@ -75,7 +75,7 @@ def _init_sim(self) -> None: from isaaclab_physx.physics import PhysxCfg - from isaaclab_contrib.conveyors.physx import SurfaceVelocity + from isaaclab_contrib.conveyors.physx import SurfaceVelocity as PhysxSurfaceVelocity if not isinstance(self.cfg.sim.physics, PhysxCfg): raise ValueError(f"Unsupported conveyor physics backend: {type(self.cfg.sim.physics).__name__}.") @@ -84,7 +84,7 @@ def _init_sim(self) -> None: if configure_conveyor is not None: configure_conveyor(friction_coefficient=self.cfg.conveyor_force.friction) super()._init_sim() - driver = SurfaceVelocity( + driver = PhysxSurfaceVelocity( num_envs=self.cfg.scene.num_envs, surface_specs=belt_specs, env_path_format=env_path_format, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py index 0d91becceea2..1099cb449806 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py @@ -81,18 +81,6 @@ def _validate_common_config(cfg: ConveyorFrankaEnvCfg) -> None: arm_action = cfg.actions.arm_action if arm_action.joint_names != list(_ARM_JOINT_NAMES) or not arm_action.preserve_order: raise ValueError("Arm actions must preserve the explicit panda_joint1-to-panda_joint7 ordering.") - if not math.isfinite(arm_action.max_delta) or arm_action.max_delta <= 0.0: - raise ValueError("Arm max_delta must be finite and positive.") - if not math.isfinite(arm_action.joint_limit_margin) or arm_action.joint_limit_margin < 0.0: - raise ValueError("Arm joint_limit_margin must be finite and non-negative.") - lower = arm_action.workspace_lower - upper = arm_action.workspace_upper - if len(lower) != len(_ARM_JOINT_NAMES) or len(upper) != len(_ARM_JOINT_NAMES): - raise ValueError("Arm workspace bounds must contain one value per controlled joint.") - if any(not math.isfinite(value) for value in (*lower, *upper)): - raise ValueError("Arm workspace bounds must be finite.") - if any(low >= high for low, high in zip(lower, upper, strict=True)): - raise ValueError("Every arm workspace lower bound must be less than its upper bound.") def _collision_properties(contact_margin: float = 0.0, mujoco_priority: int = 0) -> list[CollisionFragment]: @@ -349,22 +337,13 @@ def validate_config(self) -> None: @sim_utils.clone def _spawn_shape_with_display_color( prim_path: str, - cfg: sim_utils.ShapeCfg, + cfg: sim_utils.CuboidCfg, translation: tuple[float, float, float] | None = None, orientation: tuple[float, float, float, float] | None = None, **kwargs, ): - """Spawn a primitive and author a renderer-independent USD display color.""" - if isinstance(cfg, sim_utils.CuboidCfg): - prim = sim_utils.spawn_cuboid(prim_path, cfg, translation, orientation, **kwargs) - elif isinstance(cfg, sim_utils.CylinderCfg): - prim = sim_utils.spawn_cylinder(prim_path, cfg, translation, orientation, **kwargs) - elif isinstance(cfg, sim_utils.CapsuleCfg): - prim = sim_utils.spawn_capsule(prim_path, cfg, translation, orientation, **kwargs) - elif isinstance(cfg, sim_utils.SphereCfg): - prim = sim_utils.spawn_sphere(prim_path, cfg, translation, orientation, **kwargs) - else: - raise TypeError(f"Unsupported colored primitive configuration: {type(cfg).__name__}") + """Spawn a cuboid and author a renderer-independent USD display color.""" + prim = sim_utils.spawn_cuboid(prim_path, cfg, translation, orientation, **kwargs) if cfg.visual_material is not None: from pxr import Usd, UsdGeom diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py index 1c8d7c6e2bab..555cc08ed755 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py @@ -118,6 +118,29 @@ def _racetrack_centerline(center_y: float) -> tuple[tuple[float, float, float, f return tuple(points) +def _prism_faces(count: int, *, closed: bool) -> tuple[tuple[int, int, int], ...]: + """Triangulate top, bottom, and side walls for inner/outer vertex rings. + + Vertices are grouped as inner top, outer top, inner bottom, outer bottom. + Counter-clockwise centerlines produce outward faces; open arcs also receive end caps. + """ + quads = [] + for i in range(count if closed else count - 1): + j = (i + 1) % count + quads.extend( + ( + (i, count + i, count + j, j), + (2 * count + i, 2 * count + j, 3 * count + j, 3 * count + i), + (3 * count + i, 3 * count + j, count + j, count + i), + (2 * count + i, i, j, 2 * count + j), + ) + ) + if not closed: + end = count - 1 + quads.extend(((0, 2 * count, 3 * count, count), (end, count + end, 3 * count + end, 2 * count + end))) + return tuple(triangle for a, b, c, d in quads for triangle in ((a, b, c), (a, c, d))) + + def _racetrack_prism_mesh( name: str, center_y: float, @@ -138,70 +161,9 @@ def _racetrack_prism_mesh( inner_bottom = [(x + nx * inner_offset, y + ny * inner_offset, z_min) for x, y, nx, ny in centerline] points = tuple(inner_top + outer_top + inner_bottom + outer_bottom) - count = len(centerline) - outer_top_offset = count - inner_bottom_offset = 2 * count - outer_bottom_offset = 3 * count - indices: list[int] = [] - for index in range(count): - next_index = (index + 1) % count - inner_top_i = index - inner_top_j = next_index - outer_top_i = outer_top_offset + index - outer_top_j = outer_top_offset + next_index - inner_bottom_i = inner_bottom_offset + index - inner_bottom_j = inner_bottom_offset + next_index - outer_bottom_i = outer_bottom_offset + index - outer_bottom_j = outer_bottom_offset + next_index - - # Top, bottom, outer wall, and inner wall; two triangles per surface. - indices.extend((inner_top_i, outer_top_i, outer_top_j, inner_top_i, outer_top_j, inner_top_j)) - indices.extend( - ( - inner_bottom_i, - inner_bottom_j, - outer_bottom_j, - inner_bottom_i, - outer_bottom_j, - outer_bottom_i, - ) - ) - indices.extend( - ( - outer_bottom_i, - outer_bottom_j, - outer_top_j, - outer_bottom_i, - outer_top_j, - outer_top_i, - ) - ) - indices.extend( - ( - inner_bottom_i, - inner_top_i, - inner_top_j, - inner_bottom_i, - inner_top_j, - inner_bottom_j, - ) - ) - - # The centerline is sampled clockwise so its tangent matches the positive - # conveyor direction. The face pattern above is written for a - # counter-clockwise ring, so reverse every triangle to keep its collision - # normal outward (most importantly, the belt top must point +Z). - for triangle_start in range(0, len(indices), 3): - indices[triangle_start + 1], indices[triangle_start + 2] = ( - indices[triangle_start + 2], - indices[triangle_start + 1], - ) - - return MeshSpec( - name=name, - vertices=points, - faces=tuple(tuple(indices[offset : offset + 3]) for offset in range(0, len(indices), 3)), - ) + # Racetrack centerlines are clockwise; reverse the shared counter-clockwise winding. + faces = tuple((a, c, b) for a, b, c in _prism_faces(len(centerline), closed=True)) + return MeshSpec(name=name, vertices=points, faces=faces) def belt_mesh_spec(side: str) -> MeshSpec: @@ -254,49 +216,10 @@ def _turn_collision_mesh( inner_bottom = tuple((x, y, bottom_z) for x, y, _ in inner_top) outer_bottom = tuple((x, y, bottom_z) for x, y, _ in outer_top) - count = len(inner_top) - outer_top_offset = count - inner_bottom_offset = 2 * count - outer_bottom_offset = 3 * count - faces: list[tuple[int, int, int]] = [] - for index in range(segment_count): - next_index = index + 1 - inner_top_i = index - inner_top_j = next_index - outer_top_i = outer_top_offset + index - outer_top_j = outer_top_offset + next_index - inner_bottom_i = inner_bottom_offset + index - inner_bottom_j = inner_bottom_offset + next_index - outer_bottom_i = outer_bottom_offset + index - outer_bottom_j = outer_bottom_offset + next_index - faces.extend( - ( - # Top, bottom, outer wall, and inner wall. - (inner_top_i, outer_top_i, outer_top_j), - (inner_top_i, outer_top_j, inner_top_j), - (inner_bottom_i, inner_bottom_j, outer_bottom_j), - (inner_bottom_i, outer_bottom_j, outer_bottom_i), - (outer_bottom_i, outer_bottom_j, outer_top_j), - (outer_bottom_i, outer_top_j, outer_top_i), - (inner_bottom_i, inner_top_i, inner_top_j), - (inner_bottom_i, inner_top_j, inner_bottom_j), - ) - ) - - # Close both radial ends of the annular prism. - end = segment_count - faces.extend( - ( - (0, inner_bottom_offset, outer_bottom_offset), - (0, outer_bottom_offset, outer_top_offset), - (end, outer_top_offset + end, outer_bottom_offset + end), - (end, outer_bottom_offset + end, inner_bottom_offset + end), - ) - ) return MeshSpec( name=name, vertices=inner_top + outer_top + inner_bottom + outer_bottom, - faces=tuple(faces), + faces=_prism_faces(len(inner_top), closed=False), ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_goal_selector.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_goal_selector.py index 853580a8a95e..093dcdbd0a16 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_goal_selector.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_goal_selector.py @@ -47,9 +47,7 @@ def render(self, imgui: Any) -> None: return imgui.separator() - if not self._refresh_command(): - imgui.text_disabled("Waiting for transfer state...") - return + self._refresh_command() style = imgui.get_style() available_width = float(imgui.get_content_region_avail().x) @@ -95,11 +93,10 @@ def render(self, imgui: Any) -> None: imgui.text(f"Cube {self._target_cube_id + 1}: {source_name} -> {target_name}") imgui.text_disabled("Click a color to change the next transfer.") - def _refresh_command(self, force: bool = False) -> bool: + def _refresh_command(self, force: bool = False) -> None: """Refresh the small host-side UI cache at most ten times per second.""" current_time = time.monotonic() if force or current_time - self._last_refresh_time >= 0.1: self._target_cube_id = int(self._command.target_cube_ids[self._env_id].item()) self._source_side_id = int(self._command.source_side_ids[self._env_id].item()) self._last_refresh_time = current_time - return True diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py index 3ad79475f00d..79ecd533f33b 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py @@ -10,6 +10,7 @@ from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_env_cfg import _collision_properties, _cube from isaaclab_tasks.contrib.conveyor_franka.conveyor_geometry import ( MeshSpec, + belt_collision_geometry_specs, belt_mesh_spec, guard_mesh_specs, ) @@ -28,6 +29,7 @@ def test_belt_top_faces_point_upward(): """One-sided triangle-mesh surfaces support parcels from above.""" for side in ("Left", "Right"): specs = [belt_mesh_spec(side), *guard_mesh_specs(side)] + specs.extend(spec for spec in belt_collision_geometry_specs(side) if isinstance(spec, MeshSpec)) for spec in specs: assert set(_edge_use_counts(spec).values()) == {2} _assert_top_faces_point_upward(spec) From 1c91d6ea1b8afe8e5899c986aadeec9a4a9c7d81 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 8 Oct 2026 13:50:41 +0200 Subject: [PATCH 08/45] Remove conveyor test-only lifecycle seams --- .../isaaclab_contrib/conveyors/newton.py | 1 - .../test/conveyors/test_surface_velocity_newton.py | 4 ++-- .../isaaclab_newton/physics/newton_manager.py | 14 -------------- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py index 61845a1bcdb0..897199ba92ba 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py @@ -367,7 +367,6 @@ def __init__( raise RuntimeError(f"Newton model has {model.world_count} worlds, expected {num_envs}.") self._model = model - self._contacts = contacts self._device = model.device self._num_envs = num_envs self._startup_duration_s = startup_duration_s diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py index 365358ad6929..a8796a83d297 100644 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py @@ -57,8 +57,8 @@ def substep(solver, contacts, state, dt): def initialized(model, contacts): bindings.append((model, contacts)) + NewtonManager.register_post_actuator_callback(actuator) observers = ( - (actuator, NewtonManager.register_post_actuator_callback, NewtonManager.unregister_post_actuator_callback), (force, NewtonManager.register_state_force_callback, NewtonManager.unregister_state_force_callback), ( substep, @@ -101,13 +101,13 @@ def initialized(model, contacts): sim.reset() assert first._closed and driver._binding is not first assert driver._binding._model is NewtonManager.get_model() - assert driver._binding._contacts is NewtonManager.get_contacts() np.testing.assert_allclose(driver.get_velocities().numpy(), [0.3, 0, 0, -0.4]) assert len(bindings) == 2 and bindings[1] == (NewtonManager.get_model(), NewtonManager.get_contacts()) assert bindings[0][0] is not bindings[1][0] and bindings[0][1] is not bindings[1][1] for callback, _, unregister in observers: unregister(callback) unregister(callback) + NewtonManager._post_actuator_callbacks.remove(actuator) previous_calls = list(calls) sim.step(render=False) sim.reset() diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index bfbe8a2d506f..9ee451d09947 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -1935,20 +1935,6 @@ def unregister_state_force_callback(cls, callback: Callable[[State], None]) -> N with contextlib.suppress(ValueError): NewtonManager._state_force_callbacks.remove(callback) - @classmethod - def unregister_post_actuator_callback(cls, callback: Callable[[], None]) -> None: - """Remove a previously registered post-actuator callback. - - Removing a callback that was never registered or was already removed is - a safe no-op. This lets scene-owned systems release bound-method - references before the global Newton manager is cleared. - - Args: - callback: Previously registered callback. - """ - with contextlib.suppress(ValueError): - cls._post_actuator_callbacks.remove(callback) - @classmethod def register_post_solver_substep_callback( cls, callback: Callable[[SolverBase, Contacts | None, State, float], None] From 94cc7dc728da620d21bbe02762a97f94e87c608a Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 8 Oct 2026 13:51:19 +0200 Subject: [PATCH 09/45] Use current schema and actuator APIs in conveyor tasks --- .../conveyor_franka/conveyor_franka_env_cfg.py | 4 ++-- .../conveyor_franka/conveyor_franka_physx_env_cfg.py | 11 +++++------ .../contrib/conveyor_franka/mdp/actions.py | 2 +- .../contrib/conveyor_franka/mdp/reset_events.py | 4 ++-- .../contrib/conveyor_franka/mdp/terminations.py | 3 ++- .../test/contrib/test_conveyor_franka_physx_cfg.py | 6 +++++- 6 files changed, 17 insertions(+), 13 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py index 1099cb449806..8e8e6409ce02 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py @@ -48,7 +48,7 @@ from .franka_robot_cfg import FRANKA_PANDA_CONVEYOR_CFG from .mdp.terminations import invalid_action as invalid_policy_action -_DYNAMIC_PROPERTIES = sim_utils.RigidBodyBaseCfg() +_DYNAMIC_PROPERTIES = sim_utils.UsdPhysicsRigidBodyCfg() _CONTACT_GAP = 0.01 _CUBE_CONTACT_MARGIN = 0.003 _MUJOCO_SOLIMP = (0.9, 0.95, 0.001, 0.5, 2.0) @@ -475,7 +475,7 @@ def _cube( visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=color, roughness=0.75), ) spawn.rigid_props = _DYNAMIC_PROPERTIES - spawn.mass_props = sim_utils.MassPropertiesCfg(mass=0.05) + spawn.mass_props = sim_utils.MassCfg(mass=0.05) spawn.collision_props = _collision_properties(contact_margin=_CUBE_CONTACT_MARGIN) spawn.physics_material = NewtonMaterialPropertiesCfg( # The belt's higher MuJoCo contact priority overrides this friction diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py index 2590b16a1fdc..7661d955ba20 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py @@ -11,7 +11,7 @@ from dataclasses import replace from isaaclab_physx.physics import PhysxCfg -from isaaclab_physx.sim.schemas import PhysxCollisionCfg, PhysxSDFMeshCfg +from isaaclab_physx.sim.schemas import PhysxCollisionCfg, PhysxRigidBodyCfg, PhysxSDFMeshCfg from isaaclab_physx.sim.spawners.materials import PhysxRigidBodyMaterialCfg import isaaclab.sim as sim_utils @@ -41,11 +41,10 @@ ) from .franka_robot_cfg import FRANKA_PANDA_CONVEYOR_PHYSX_CFG -_PHYSX_KINEMATIC_PROPERTIES = sim_utils.RigidBodyBaseCfg( - rigid_body_enabled=True, - kinematic_enabled=True, - disable_gravity=True, -) +_PHYSX_KINEMATIC_PROPERTIES = [ + sim_utils.UsdPhysicsRigidBodyCfg(rigid_body_enabled=True, kinematic_enabled=True), + PhysxRigidBodyCfg(disable_gravity=True), +] def _physx_collision_properties(contact_offset: float = 0.005) -> list[CollisionFragment]: diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py index bdadbac2b7b7..3e24729dd627 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py @@ -76,7 +76,7 @@ def process_actions(self, actions: torch.Tensor) -> None: def apply_actions(self) -> None: """Hold the policy-step target through all physics substeps.""" - self._asset.set_joint_position_target_index(target=self._position_targets, joint_ids=self._joint_ids) + self._asset.actuators.target_command.set_position_index(value=self._position_targets, joint_ids=self._joint_ids) def reset(self, env_ids: Sequence[int] | None = None) -> None: """Initialize targets from the sampled reset pose.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py index 5f6ff0dd9017..5e8995a48cb4 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py @@ -442,8 +442,8 @@ def __call__( joint_positions[:, self._arm_joint_ids] = arm_positions finger_positions = self._finger_positions[row_ids].to(dtype=joint_positions.dtype).unsqueeze(1).expand(-1, 2) joint_positions[:, self._finger_joint_ids] = finger_positions - self._robot.set_joint_position_target_index(target=joint_positions, env_ids=env_ids) - self._robot.set_joint_velocity_target_index(target=joint_velocities, env_ids=env_ids) + self._robot.actuators.target_command.set_position_index(value=joint_positions, env_ids=env_ids) + self._robot.actuators.target_command.set_velocity_index(value=joint_velocities, env_ids=env_ids) self._robot.write_joint_position_to_sim_index(position=joint_positions, env_ids=env_ids) self._robot.write_joint_velocity_to_sim_index(velocity=joint_velocities, env_ids=env_ids) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/terminations.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/terminations.py index f63cfefc7c5a..eee38a695683 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/terminations.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/terminations.py @@ -109,5 +109,6 @@ def nonfinite_scene_state( robot: Articulation = env.scene[robot_cfg.name] invalid = ~torch.all(torch.isfinite(robot.data.joint_pos.torch), dim=1) invalid |= ~torch.all(torch.isfinite(robot.data.joint_vel.torch), dim=1) - invalid |= ~torch.isfinite(cube_values(env, "root_state_w", all_cubes=True)).all(dim=(1, 2)) + for attribute in ("root_link_pose_w", "root_com_vel_w"): + invalid |= ~torch.isfinite(cube_values(env, attribute, all_cubes=True)).all(dim=(1, 2)) return invalid diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py index e9ba21acc88b..b74e381c981a 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py @@ -7,6 +7,8 @@ import pytest +from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg + from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_env_cfg import ConveyorFrankaEnvCfg from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_physx_env_cfg import physx_belt_section_specs from isaaclab_tasks.contrib.conveyor_franka.conveyor_geometry import BELT_TURN_RADIUS @@ -33,7 +35,9 @@ def test_physx_preserves_cpu_policy_and_pivot_conventions(): for key in ("top_straight", "bottom_straight", "right_turn", "left_turn"): spawn = getattr(cfg.scene, f"conveyor_{side.lower()}_{key}_collision").spawn assert spawn.physics_material.static_friction == spawn.physics_material.dynamic_friction == 0.42 - assert spawn.rigid_props.kinematic_enabled + assert next( + props for props in spawn.rigid_props if isinstance(props, UsdPhysicsRigidBodyCfg) + ).kinematic_enabled assert ( getattr(cfg.scene, f"conveyor_{side.lower()}_right_turn_collision").spawn.collision_approximation == "sdf" ) From 8a7d0574d41a8a6fb11478b1bd33dcaf3ee73721 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 8 Oct 2026 13:51:19 +0200 Subject: [PATCH 10/45] Trim warehouse slot bookkeeping and task documentation --- .../contrib/conveyor_franka/README.md | 171 ++++++------------ .../conveyor_franka/conveyor_cube_pool.py | 8 +- 2 files changed, 60 insertions(+), 119 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md index 25e72627ab93..fad732db1c5f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md @@ -94,65 +94,37 @@ intermediate-reset training reward alone does not establish deployment reliabili ## Warehouse sorting task -Run the warehouse sorting task with the same checkpoint. The two parallel manipulation -straights and their adjoining 90-degree bends retain their original positions, widths, radii, -and 0.35 m/s surface speed. Beyond these fixed sections, two short rising feeds climb 0.10 m at less than 10 degrees and -join a shared elevated deck. Guides keep the two return lanes assigned through the upper split, -and separate descending conveyors deliver the parcels back to the original workcell approaches. -The new incline panels use slope-aligned traction and a 0.95 friction coefficient; the original -manipulation sections retain their trained 0.5 setting. - -Blue conveyor frames reach the floor, and the scanner faces along the background main belt after -a 90-degree counterclockwise rotation. The cubes wear SimReady cardboard meshes normalized to -**40 × 40 × 40 mm**, centered on their original colliders; mass remains 50 g. Actions and observation -ordering stay checkpoint-compatible. The sorting task widens its travel bounds and resets all parcels as a randomized mixed batch on the -raised supply belts. -The base training and PhysX tasks retain the original compact layout. - -The workcell contains **24 physical parcels**, exposed to the unchanged checkpoint through -four policy slots. The sorting command fills remote slots with misplaced arrivals; local assignments and an -active grasp stay pinned. Reassignment runs in the command manager, before the next policy observation, -and changes only identity mapping, never a physical pose. Commands, rewards, and placement checks -use the same mapping. All 24 parcels receive belt forces and participate in safety checks. -During playback, the arm parks when there is no active sorting transfer. The policy sees remote -inventory in canonical waiting slots; -all local parcel poses and velocities, physical transport, rewards, and transfer checks remain -actual simulated states. This adapter is necessary because the checkpoint was trained on compact, -flat returns. Tensor ordering stays 123 observations to eight actions, but remote observation values -are intentionally adapted. Invalid actions still reach the original sanitization and termination path. -The checkpoint can still miss grasps and reset before a batch finishes. The larger randomized -inventory preserves the policy interface, but reliable complete-batch sorting is not established. - -The near loop has a rounded return; the far loop has a shorter squared return with rounded corners -and a shorter supply belt. Both retain the exact original manipulation geometry. Oversized yellow -drive blocks are omitted from the workcell. - -The batch contains six cartons in each of four clearly marked colors: **blue, orange, green, -and purple**. Colored paper bands wrap the textured cardboard without changing its 40 mm bounds. -Reset shuffles physical identities across 24 supply positions, independently -for each environment, using the simulation's seeded random generator. Counts remain balanced while -arrival order and initial conveyor assignments vary. Colors stay fixed throughout each batch. -The 0.043–0.052 m/s feeds release cartons through **12 cm gravity drops** onto the 0.35 m/s loops. -Blue and green belong on the positive-Y loop; orange and purple belong on the negative-Y loop. -The dispatcher requests only wrong-lane transfers and retains ownership through grasp and stable -release. Missed arrivals recirculate for another opportunity. Once the batch is sorted, the arm -parks and both loops continue running. No parcels are recolored, teleported, or replaced during sorting. - -Class assignment is explicit supervisory metadata, not a claim that the unchanged state-based -checkpoint recognizes color. `commands.transfer.parcel_colors` assigns appearances and -`commands.transfer.parcel_destinations` assigns loop IDs; all parcels of a color must share a -single destination. `commands.transfer.randomize_arrivals=False` uses the authored ordering. -`sorted_parcels` counts settled, correctly routed inventory; `batch_complete` indicates that all -24 parcels are settled on their assigned loops. - -The surrounding warehouse includes loaded rack aisles, packing shelves, pallet staging, scan and -outbound signs, safety markings, overhead beams, and warm/cool industrial lighting. Its additional -23.11 m parcel loop includes A29 elevated runs and A38 ramps, with twelve animated cartons traveling -along the main transport line. These background cartons remain render-only inventory and do not -add contacts or policy observations. The 24 small workcell parcels have real collision and -dynamics. A Y-divert dresses the outbound bay. - -Use **Kit/RTX** to see the authored MDL textures, USD lights, and background animation: +The workcell contains 24 physical 40 mm, 50 g parcels: six each in blue, orange, green, +and purple. Blue and green belong on the positive-Y conveyor; orange and purple belong +on the negative-Y conveyor. Resets shuffle a mixed batch across the supply feeds using +seeded randomness. Slow feeds deliver parcels through 12 cm gravity drops; misplaced +arrivals recirculate until transferred. Colors and physical identities remain fixed. + +The two manipulation straights and adjoining 90-degree bends preserve the trained +positions, dimensions, and 0.35 m/s speed. Compact elevated returns connect them to +asymmetric routes. Incline normals follow their panels, with 0.95 traction friction; +the original manipulation sections retain their 0.5 setting. + +Four policy slots map to the larger physical pool. Local parcels and the active grasp +keep their assignments; the command manager assigns misplaced arrivals to available +slots before observations are computed. Reassignment changes identity mapping, never +physical poses. Commands, rewards, placement checks, and safety checks use the same +mapping. All 24 parcels receive physical belt forces. + +Remote inventory is represented through canonical waiting-slot observations because +the checkpoint learned compact, flat returns. Local states and physical interactions +remain exact. Class dispatch uses supervisory metadata; the state-based policy does +not recognize color. Playback parks the arm between transfers and after sorting. +Invalid actions still reach sanitization and termination. The unchanged checkpoint +can miss grasps and reset before finishing; reliable complete-batch sorting is not established. + +`commands.transfer.parcel_colors` sets appearances and `parcel_destinations` sets +conveyor IDs; each color must have one destination. Set `randomize_arrivals=False` +for authored arrival ordering. `sorted_parcels` counts settled, correctly routed parcels; +`batch_complete` indicates that all parcels have reached their assigned loops. + +Use Kit/RTX for the USD materials, warehouse lighting, and twelve animated background +cartons. Background inventory is render-only and adds no contacts or policy observations. ```bash DISPLAY=:1 uv run --extra isaacsim isaaclab play --rl_library rsl_rl \ @@ -161,20 +133,17 @@ DISPLAY=:1 uv run --extra isaacsim isaaclab play --rl_library rsl_rl \ --kit_args=--/UJITSO/geometry=false ``` -Kit renders the USD directly, so the Play configuration excludes visual-only meshes from the -Newton model (`sim.physics.load_visual_shapes=False`). This avoids importing warehouse dressing -into the physics model. For a static approximation in `--viz newton_gl`, explicitly pass -`env.sim.physics.load_visual_shapes=True`; materials and lighting are simplified in that viewer. -The launch override disables experimental geometry streaming to prevent disappearing meshes with -Fabric transforms. Presentation defaults to one environment. Asset references and textures are downloaded and cached -on first use, so the first launch takes longer. +Kit renders USD directly; `sim.physics.load_visual_shapes=False` excludes dressing +from the Newton model. For a static approximation with `--viz newton_gl`, pass +`env.sim.physics.load_visual_shapes=True`. The Kit override disables experimental +geometry streaming to prevent disappearing meshes with Fabric transforms. Assets +and textures are downloaded and cached on first use. Presentation defaults to one environment. ### Fine-tuning -Warehouse training starts the robot at home and parcels on the actual supply feeds. It disables -the racetrack reset curriculum and passes every sampled policy action through, including idle -steps. Parking is enabled only by the playback configuration so PPO trains on its own actions. -Use the shared racetrack checkpoint to warm-start warehouse fine-tuning: +Warehouse training starts at home with parcels on the actual feeds, disables the +racetrack reset curriculum, and passes every sampled policy action through. +Idle parking applies only during playback. Warm-start with the shared checkpoint: ```bash uv run isaaclab train --rl_library rsl_rl \ @@ -182,50 +151,28 @@ uv run isaaclab train --rl_library rsl_rl \ --checkpoint /path/to/model.pt --num_envs 256 --device cuda:0 ``` -This provides a consistent fine-tuning path; reliable full-batch sorting still requires evaluation. - ### Editing the presentation -* `assets/warehouse.usda` owns the warehouse layout, referenced props, materials, lights, cameras, - and looping parcel transform samples. It can be opened in a USD authoring application with an - Omniverse-compatible asset resolver. `Cameras/Workcell` and `Cameras/Overview` provide two views. -* `assets/parcel_{blue,orange,green,purple}.usda` reference the carton, tint its cardboard, and - add a colored paper band within the original bounds. -* `assets/parcel.usda` normalizes the measured SimReady `cardbox_a1` visual bounds to a centered - 40 mm cube. Keep the shell bounds aligned with the task's original collider when editing it. -* `assets/conveyor_*_supported.usda` reference the source conveyors and describe their support - extension with two scalar attributes. The visual spawner stretches only the lower frame on load; - mesh vertices and normals are not bundled in the package. -* `assets/conveyor_routes.usda` owns the elevated network's centerlines, frame meshes, material - references, ramp traction, feed speeds, and mixed-batch spawn positions. Physics reads these same paths after application startup; - USD is not imported during task discovery. Incline normals follow each panel's slope. All 24 - physical parcel spawn positions are authored in `conveyor:parcelSpawnPositions`. -* The Python adapter resolves remote references into the normal local asset cache, removes all - imported physics/action-graph ownership, and samples background animation using simulation time. - `conveyor_warehouse_geometry.py` sweeps lightweight collision proxies along the authored paths. - `mdp/sorting.py` extends the shared transfer command with class dispatch and batch metrics; - the action, observation, reward, and stable-placement contracts remain shared with the trained task. -* `env.conveyor_cube_pool.slot_ids` maps each environment's four policy slots to physical parcel IDs. - `transfer_counts` records placements per physical parcel; assignments never duplicate a parcel - within an environment, and resetting one environment does not change another's slots. - -### Asset and visual references - -The asset survey covered SimReady Central (`simready-central.nvidia.com`), -`omniverse://ov-isaac-dev.nvidia.com/Isaac/SimReady/Industrial/Warehouse`, -`Isaac/Environments/{Digital_Twin_Warehouse,Modular_Warehouse}`, `Isaac/Props/Conveyors`, and -`NVIDIA/Assets/DigitalTwin/Assets/Warehouse`. The composition references publicly accessible -Omniverse counterparts so playback does not require internal Nucleus credentials. Public catalog -usage is described in the [SimReady Explorer documentation](https://docs.omniverse.nvidia.com/extensions/latest/ext_core/ext_browser-extensions/simready-explorer.html). - -Selected assets are the Omniverse A03/A09/A12/A24/A29/A38 conveyors, `RackLarge_A1`, SimReady -`bulkstoragerack_a01` and `cardbox_a1`, the Isaac packing table, and loaded pallets. NVIDIA assets -remain under their original licenses; this package contains authored scene composition and support parameters; NVIDIA geometry and textures remain external references. -The layout draws on [Dematic's modular conveyor examples](https://www.dematic.com/content/dam/dematic/downloads/brochures/NA_BR_1039_MCS.pdf) -and [KION's warehouse installation photographs](https://www.kiongroup.com/en/News-Stories/Stories/Innovation/Warehousing-is-being-transformed.html): -parallel transport, elevation changes, rack storage, packing zones, and clear marked aisles. -The induction and recirculation layout also follows the concepts in -[Dematic’s sortation overview](https://www.dematic.com/content/dam/dematic/downloads/whitepapers/NA_WP-1015_Sorting-Out-Sortation.pdf). +- `assets/warehouse.usda` owns layout, props, materials, lights, cameras, and looping + background transforms. `Cameras/Workcell` and `Cameras/Overview` provide two views. +- `assets/conveyor_routes.usda` owns route centerlines, frame meshes, traction, + feed speeds, and `conveyor:parcelSpawnPositions`. Python builds collision proxies + from these paths after application startup; task discovery does not import USD. +- `assets/parcel.usda` normalizes the referenced SimReady carton to the original + centered 40 mm collider; `parcel_{blue,orange,green,purple}.usda` add color bands. +- `assets/conveyor_*_supported.usda` reference source conveyors and describe support + extension with two scalar attributes. The spawner derives lower-frame points and normals. +- `env.conveyor_cube_pool.slot_ids` maps policy slots to physical IDs; + `transfer_counts` records placements. Selected resets clear that batch's counters. + +The spawner caches external assets, removes imported physics and action graphs, +and samples background animation using simulation time. NVIDIA geometry and textures +remain external references under their original licenses. See the +[SimReady Explorer documentation](https://docs.omniverse.nvidia.com/extensions/latest/ext_core/ext_browser-extensions/simready-explorer.html) +for catalog access. Layout references include +[Dematic modular conveyors](https://www.dematic.com/content/dam/dematic/downloads/brochures/NA_BR_1039_MCS.pdf), +[Dematic sortation](https://www.dematic.com/content/dam/dematic/downloads/whitepapers/NA_WP-1015_Sorting-Out-Sortation.pdf), +and [KION warehouse installations](https://www.kiongroup.com/en/News-Stories/Stories/Innovation/Warehousing-is-being-transformed.html). ## PhysX CPU playback diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_cube_pool.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_cube_pool.py index ce59b18507ea..f1477f79a266 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_cube_pool.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_cube_pool.py @@ -48,7 +48,7 @@ def refresh( candidates: torch.Tensor, target_slots: torch.Tensor, pinned: torch.Tensor, - ) -> torch.Tensor: + ) -> None: """Assign arriving parcels to remote slots, preserving all local and pinned identities. Args: @@ -57,15 +57,11 @@ def refresh( candidates: Parcels eligible for pickup, shape ``(N, P)``. target_slots: Current command's policy slot, shape ``(N,)``. pinned: Whether the active parcel must retain its slot, shape ``(N,)``. - - Returns: - Environments whose slot assignments changed, shape ``(N,)``. """ mapped = torch.zeros_like(candidates).scatter_(1, self.slot_ids, True) available = candidates & ~mapped reusable = ~local.gather(1, self.slot_ids) reusable.scatter_(1, target_slots[:, None], ~pinned[:, None] & reusable.gather(1, target_slots[:, None])) - changed = torch.zeros_like(pinned) # Prefer parcels assigned less often, then the arrival with more belt travel remaining. priority = positions[..., 0] - 10.0 * self.assignment_counts for slot in range(CUBE_COUNT): @@ -77,8 +73,6 @@ def refresh( self.slot_ids[rows, slot] = choices self.assignment_counts[rows, choices] += 1 available[rows, choices] = False - changed[rows] = True - return changed def record_transfers(self, env_ids: torch.Tensor, target_slots: torch.Tensor) -> None: """Credit stable placements to physical parcels before the command selects its next slot.""" From ffbfb8d31be120939b6e1283930b471eaf0078ce Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 8 Oct 2026 14:13:12 +0200 Subject: [PATCH 11/45] Share conveyor path resolution across backends --- .../isaaclab_contrib/conveyors/newton.py | 41 +++------------- .../isaaclab_contrib/conveyors/physx.py | 48 +------------------ .../conveyors/surface_velocity.py | 48 +++++++++++++++++++ .../conveyors/test_surface_velocity_newton.py | 14 +++--- .../conveyors/test_surface_velocity_physx.py | 16 ------- .../conveyors/test_surface_velocity_spec.py | 19 +++++++- 6 files changed, 81 insertions(+), 105 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py index 897199ba92ba..5e82df8a9dc4 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py @@ -22,7 +22,7 @@ from isaaclab.physics import PhysicsEvent -from .surface_velocity import SurfaceVelocitySpec +from .surface_velocity import SurfaceVelocitySpec, resolve_surface_velocity_paths @wp.kernel @@ -122,33 +122,14 @@ def _world_point(transform_values: np.ndarray, local_point: tuple[float, float, return wp.vec3(*(float(point[index]) for index in range(3))) -def _resolve_belt_prim_path(prim_path: str, env_path_format: str, world_id: int) -> str: - """Resolve one replicated conveyor template to an exact Newton shape label.""" - if prim_path.startswith("{ENV_REGEX_NS}/"): - return prim_path.format(ENV_REGEX_NS=env_path_format.format(world_id)) - return prim_path - - def _shape_belongs_to_prim(shape_label: str, prim_path: str) -> bool: """Return whether a Newton collision-shape label is the prim or one of its descendants.""" return shape_label == prim_path or shape_label.startswith(f"{prim_path.rstrip('/')}/") -def _validate_env_path_format(env_path_format: str) -> None: - """Validate the concrete per-world path format derived from the scene cloner.""" - if not isinstance(env_path_format, str) or env_path_format.count("{}") != 1: - raise ValueError(f"Conveyor env_path_format must contain exactly one '{{}}', got {env_path_format!r}.") - if not env_path_format.startswith("/"): - raise ValueError(f"Conveyor env_path_format must be absolute, got {env_path_format!r}.") - - def _validate_newton_surface_specs(surface_specs: Sequence[SurfaceVelocitySpec]) -> None: """Validate Newton-specific requirements before registering lifecycle callbacks.""" - if not surface_specs: - raise ValueError("At least one surface-velocity specification is required.") prim_paths = [spec.prim_path for spec in surface_specs] - if len(set(prim_paths)) != len(prim_paths): - raise ValueError(f"Conveyor prim paths must be unique, got {prim_paths}.") for index, path in enumerate(prim_paths): for other in prim_paths[index + 1 :]: if _shape_belongs_to_prim(path, other) or _shape_belongs_to_prim(other, path): @@ -190,18 +171,10 @@ def __init__( env_path_format: Format string resolving one exact environment root from its integer world index. """ surface_specs = tuple(surface_specs) - if not all(isinstance(spec, SurfaceVelocitySpec) for spec in surface_specs): - raise TypeError("Every surface specification must be a SurfaceVelocitySpec.") + prim_paths = resolve_surface_velocity_paths(num_envs, surface_specs, env_path_format) _validate_newton_surface_specs(surface_specs) - if not isinstance(num_envs, int) or isinstance(num_envs, bool) or num_envs <= 0: - raise ValueError(f"num_envs must be a positive integer, got {num_envs!r}.") - if num_envs > 1 and any(not spec.prim_path.startswith("{ENV_REGEX_NS}/") for spec in surface_specs): - raise ValueError( - "Replicated conveyor environments require every belt prim_path to start with '{ENV_REGEX_NS}/'." - ) if not np.isfinite(startup_duration_s) or startup_duration_s <= 0.0: raise ValueError(f"Conveyor startup duration must be positive, got {startup_duration_s}.") - _validate_env_path_format(env_path_format) if not isinstance(body_pattern, str): raise ValueError(f"body_pattern must be a regular-expression string, got {body_pattern!r}.") try: @@ -220,7 +193,7 @@ def __init__( "num_envs": num_envs, "surface_specs": self._surface_specs, "startup_duration_s": startup_duration_s, - "env_path_format": env_path_format, + "prim_paths": prim_paths, "body_pattern": body_pattern, "body_count_per_env": body_count_per_env, } @@ -339,7 +312,7 @@ def __init__( body_pattern: str, body_count_per_env: int | None = None, startup_duration_s: float = 1.0, - env_path_format: str = "/World/envs/env_{}", + prim_paths: tuple[str, ...], ) -> None: """Initialize the binding before Newton CUDA graph capture. @@ -351,7 +324,7 @@ def __init__( body_pattern: Regular expression selecting bodies that receive surface traction. body_count_per_env: Expected selected body count per environment, or ``None``. startup_duration_s: Duration of the initial traction ramp [s]. - env_path_format: Format string resolving one exact environment root from its integer world index. + prim_paths: Exact collision prim paths in environment-major order. """ self._surface_specs = tuple(surface_specs) compiled_body_pattern = re.compile(body_pattern) @@ -391,8 +364,8 @@ def __init__( continue matching_specs = [ index - for index, spec in enumerate(self._surface_specs) - if _shape_belongs_to_prim(label, _resolve_belt_prim_path(spec.prim_path, env_path_format, world_id)) + for index in range(surfaces_per_env) + if _shape_belongs_to_prim(label, prim_paths[world_id * surfaces_per_env + index]) ] if not matching_specs: continue diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py index c492fdc9389b..1d85f1cd08a8 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py @@ -22,9 +22,7 @@ import numpy as np -from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec - -_ENV_REGEX_NS = "{ENV_REGEX_NS}" +from .surface_velocity import SurfaceVelocitySpec, resolve_surface_velocity_paths @dataclass(frozen=True, slots=True) @@ -117,50 +115,6 @@ def apply_surface_velocity_api( binding.close() -def resolve_surface_velocity_paths( - num_envs: int, - surface_specs: Sequence[SurfaceVelocitySpec], - env_path_format: str = "/World/envs/env_{}", -) -> tuple[str, ...]: - """Resolve conveyor templates to exact environment-major prim paths. - - Args: - num_envs: Number of replicated environments. - surface_specs: Within-environment surface descriptions. - env_path_format: Exact environment path format containing one ``{}`` field. - - Returns: - Exact paths ordered by environment, then by ``surface_specs`` order. - - Raises: - ValueError: If inputs cannot produce one unique path per environment and belt. - """ - if not isinstance(num_envs, int) or isinstance(num_envs, bool) or num_envs <= 0: - raise ValueError(f"Conveyor num_envs must be a positive integer, got {num_envs!r}.") - specs = tuple(surface_specs) - if not specs or not all(isinstance(spec, SurfaceVelocitySpec) for spec in specs): - raise ValueError("surface_specs must contain at least one SurfaceVelocitySpec.") - if not isinstance(env_path_format, str) or env_path_format.count("{}") != 1: - raise ValueError(f"Conveyor env_path_format must contain exactly one '{{}}', got {env_path_format!r}.") - try: - env_paths = tuple(env_path_format.format(env_id) for env_id in range(num_envs)) - except (IndexError, KeyError, ValueError) as exc: - raise ValueError(f"Invalid conveyor env_path_format: {env_path_format!r}.") from exc - if any(not path.startswith("/") or "{" in path or "}" in path for path in env_paths): - raise ValueError(f"Conveyor env_path_format must produce exact absolute paths, got {env_path_format!r}.") - - templates = tuple(spec.prim_path for spec in specs) - if len(set(templates)) != len(templates): - raise ValueError("Conveyor belt spec paths must be unique within an environment.") - if num_envs > 1 and any(_ENV_REGEX_NS not in path for path in templates): - raise ValueError("Replicated PhysX conveyors require every belt prim_path to use {ENV_REGEX_NS}.") - - paths = tuple(template.replace(_ENV_REGEX_NS, env_path) for env_path in env_paths for template in templates) - if len(set(paths)) != len(paths): - raise ValueError("Resolved PhysX conveyor prim paths must be unique.") - return paths - - class SurfaceVelocity: """Host-side CPU reference facade for native PhysX surface velocity. diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py index b5251657958b..b528cc9bf668 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py @@ -8,6 +8,7 @@ from __future__ import annotations import math +from collections.abc import Sequence from dataclasses import dataclass from typing import Any, Protocol, runtime_checkable @@ -127,6 +128,53 @@ def __post_init__(self) -> None: object.__setattr__(self, "friction_coefficient", friction_coefficient) +def resolve_surface_velocity_paths( + num_envs: int, + surface_specs: Sequence[SurfaceVelocitySpec], + env_path_format: str = "/World/envs/env_{}", +) -> tuple[str, ...]: + """Resolve conveyor templates to exact environment-major prim paths. + + Args: + num_envs: Number of replicated environments. + surface_specs: Within-environment surface descriptions. + env_path_format: Exact environment path format containing one ``{}`` field. + + Returns: + Exact paths ordered by environment, then by ``surface_specs`` order. + + Raises: + ValueError: If inputs cannot produce one unique path per environment and belt. + TypeError: If a surface description is not a :class:`SurfaceVelocitySpec`. + """ + if not isinstance(num_envs, int) or isinstance(num_envs, bool) or num_envs <= 0: + raise ValueError(f"Conveyor num_envs must be a positive integer, got {num_envs!r}.") + specs = tuple(surface_specs) + if not specs: + raise ValueError("At least one SurfaceVelocitySpec is required.") + if not all(isinstance(spec, SurfaceVelocitySpec) for spec in specs): + raise TypeError("Every surface specification must be a SurfaceVelocitySpec.") + if not isinstance(env_path_format, str) or env_path_format.count("{}") != 1: + raise ValueError(f"Conveyor env_path_format must contain exactly one '{{}}', got {env_path_format!r}.") + try: + env_paths = tuple(env_path_format.format(env_id) for env_id in range(num_envs)) + except (IndexError, KeyError, ValueError) as exc: + raise ValueError(f"Invalid conveyor env_path_format: {env_path_format!r}.") from exc + if any(not path.startswith("/") or "{" in path or "}" in path for path in env_paths): + raise ValueError(f"Conveyor env_path_format must produce exact absolute paths, got {env_path_format!r}.") + + templates = tuple(spec.prim_path for spec in specs) + if len(set(templates)) != len(templates): + raise ValueError("Conveyor belt spec paths must be unique within an environment.") + if num_envs > 1 and any(_ENV_REGEX_NS not in path for path in templates): + raise ValueError("Replicated conveyors require every belt prim_path to use {ENV_REGEX_NS}.") + + paths = tuple(template.replace(_ENV_REGEX_NS, env_path) for env_path in env_paths for template in templates) + if len(set(paths)) != len(paths): + raise ValueError("Resolved conveyor prim paths must be unique.") + return paths + + @runtime_checkable class SurfaceVelocityView(Protocol): """Common tensorized control contract implemented by physics backends.""" diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py index a8796a83d297..46476138dab3 100644 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py @@ -149,6 +149,13 @@ def fail_registration(cls, callback): ((SurfaceVelocitySpec("/Curve", curved=True),), 1, "/World/envs/env_{}", ValueError, "positive radius"), ((SurfaceVelocitySpec("/Shared/Belt"),), 2, "/World/envs/env_{}", ValueError, "Replicated"), ((SurfaceVelocitySpec("{ENV_REGEX_NS}/Belt"),), 1, "/World/envs/env_.*", ValueError, "env_path_format"), + ( + (SurfaceVelocitySpec("{ENV_REGEX_NS}/Belt"),), + 1, + "/World/envs/env_{}/{missing}", + ValueError, + "env_path_format", + ), ], ) def test_invalid_conveyors_leave_no_callbacks(specs, num_envs, env_path, error, message): @@ -157,10 +164,3 @@ def test_invalid_conveyors_leave_no_callbacks(specs, num_envs, env_path, error, with pytest.raises(error, match=message): SurfaceVelocity(num_envs, specs, env_path_format=env_path, body_pattern="Cube0$") assert set(NewtonManager._callbacks) == before - - -def test_unreplicated_absolute_surface_path_is_preserved(): - """A single-environment surface can target a global authored path.""" - from isaaclab_contrib.conveyors.newton import _resolve_belt_prim_path - - assert _resolve_belt_prim_path("/World/Shared/Belt", "/World/envs/env_{}", 0) == "/World/Shared/Belt" diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py index 81d992469aae..a6f229031f58 100644 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py @@ -71,22 +71,6 @@ def test_curved_twist_requires_an_explicit_radius() -> None: surface_module.compute_surface_velocity_twist(spec) -def test_paths_are_resolved_in_environment_major_order() -> None: - """Runtime rows stay deterministic across stage discovery ordering.""" - specs = (_surface_spec("BeltA"), _surface_spec("Nested/BeltB")) - - paths = surface_module.resolve_surface_velocity_paths(2, specs) - - assert paths == ( - "/World/envs/env_0/BeltA", - "/World/envs/env_0/Nested/BeltB", - "/World/envs/env_1/BeltA", - "/World/envs/env_1/Nested/BeltB", - ) - with pytest.raises(ValueError, match="require every belt"): - surface_module.resolve_surface_velocity_paths(2, (SurfaceVelocitySpec(prim_path="/World/Shared/Belt"),)) - - def test_facade_ramps_playback_and_preserves_commands_on_reset(monkeypatch: pytest.MonkeyPatch) -> None: """Full resets restart playback without erasing policy-visible command state.""" writer = _FakeWriter() diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py index db0a7adedb90..e34fc0e1d78e 100644 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py @@ -9,7 +9,7 @@ import pytest -from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec, resolve_surface_velocity_paths def test_surface_velocity_spec_normalizes_vectors() -> None: @@ -43,3 +43,20 @@ def test_surface_velocity_spec_rejects_invalid_authored_values(kwargs: dict, mes """Invalid persistent intent fails before any physics lifecycle is registered.""" with pytest.raises(ValueError, match=message): SurfaceVelocitySpec(**kwargs) + + +def test_paths_are_resolved_in_environment_major_order() -> None: + """Runtime rows stay deterministic across stage discovery ordering.""" + specs = (SurfaceVelocitySpec("{ENV_REGEX_NS}/BeltA"), SurfaceVelocitySpec("{ENV_REGEX_NS}/Nested/BeltB")) + + paths = resolve_surface_velocity_paths(2, specs) + + assert paths == ( + "/World/envs/env_0/BeltA", + "/World/envs/env_0/Nested/BeltB", + "/World/envs/env_1/BeltA", + "/World/envs/env_1/Nested/BeltB", + ) + with pytest.raises(ValueError, match="require every belt"): + resolve_surface_velocity_paths(2, (SurfaceVelocitySpec(prim_path="/World/Shared/Belt"),)) + assert resolve_surface_velocity_paths(1, (SurfaceVelocitySpec("/World/Shared/Belt"),)) == ("/World/Shared/Belt",) From de3d752c5030c7992db78a76ad9ab82d6c80164d Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Mon, 12 Oct 2026 12:38:58 +0200 Subject: [PATCH 12/45] Detach conveyor callbacks from captured CUDA graphs --- .../isaaclab_contrib/conveyors/README.md | 4 +- .../isaaclab_contrib/conveyors/newton.py | 2 +- .../isaaclab_contrib/conveyors/physx.py | 8 +--- .../conveyors/test_surface_velocity_newton.py | 38 +++++++++++++++++++ .../physics/newton_collision_cfg.py | 6 +-- .../isaaclab_newton/physics/newton_manager.py | 22 ++++++++--- 6 files changed, 62 insertions(+), 18 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md b/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md index 1c5a84cba40b..89f3abf701a6 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md @@ -8,8 +8,8 @@ experimental and is not exported from Isaac Lab core or the backend packages. control operations. - `newton.SurfaceVelocity` uses Newton's upstream `ConveyorForceModel`. The adapter owns replicated surface/body bindings and solver lifecycle integration, - including hard resets and registration before CUDA graph capture. It requires - the Newton backend. + including hard resets and registration before CUDA graph capture. It supports + the Newton MJWarp solver; other Newton solvers are outside this adapter's scope. - `physx.SurfaceVelocity` uses native `PhysxSurfaceVelocityAPI` on CPU. Author the API before simulation parsing with `apply_surface_velocity_api`. This path requires the PhysX backend and Kit; GPU dynamics are unsupported. diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py index 5e82df8a9dc4..a8ba32930cb2 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py @@ -140,7 +140,7 @@ def _validate_newton_surface_specs(surface_specs: Sequence[SurfaceVelocitySpec]) class SurfaceVelocity: - """Adapt Newton's upstream conveyor force model to the Isaac Lab lifecycle. + """Adapt Newton's upstream MuJoCo conveyor force model to the MJWarp lifecycle. The driver is created after the simulation context but before its first reset. It requests solved contact forces before model finalization, then diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py index 1d85f1cd08a8..3cae39e948db 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py @@ -359,13 +359,7 @@ class _PhysxSchemaSurfaceWriter: def __init__(self, prims_or_paths: Sequence[Any], *, stage: Any | None, apply_api: bool) -> None: """Resolve prims, optionally apply schemas, and cache surface attributes.""" - try: - from pxr import Gf, PhysxSchema, UsdPhysics - except ImportError as exc: - raise RuntimeError( - "PhysxSurfaceVelocityAPI is unavailable. Use this backend inside an Isaac Sim process with PhysX " - "schemas." - ) from exc + from pxr import Gf, PhysxSchema, UsdPhysics if stage is None and any(isinstance(value, str) for value in prims_or_paths): from isaaclab.sim.utils.stage import get_current_stage diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py index 92c4cf278ea0..a4030da85fa4 100644 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py @@ -11,6 +11,7 @@ from isaaclab_newton.physics import MJWarpSolverCfg, NewtonBuilderCfg, NewtonCfg, NewtonManager from isaaclab.sim import SimulationCfg, build_simulation_context +from isaaclab.test.utils import DeviceScope, test_devices from isaaclab_contrib.conveyors.newton import SurfaceVelocity from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec @@ -163,3 +164,40 @@ def test_invalid_conveyors_leave_no_callbacks(specs, num_envs, env_path, error, with pytest.raises(error, match=message): SurfaceVelocity(num_envs, specs, env_path_format=env_path, body_pattern="Cube0$") assert set(NewtonManager._callbacks) == before + + +@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) +def test_removed_callbacks_leave_captured_graph(device): + """Removed callbacks stop writing GPU buffers in eager and recaptured simulation steps.""" + cfg = SimulationCfg(device=device, physics=NewtonCfg(use_cuda_graph=True)) + with build_simulation_context(sim_cfg=cfg) as sim: + builder = sim.get_or_create_backend(NewtonBuilderCfg(physics_cfg=cfg.physics)) + body = builder.add_link(xform=wp.transform_identity()) + builder.add_shape_box(body, hx=0.1, hy=0.1, hz=0.1) + builder.add_articulation([builder.add_joint_free(body)]) + observed = wp.zeros(2, device=device) + ones = wp.ones(2, device=device) + + def force(state): + wp.copy(observed, ones, dest_offset=0, count=1) + + def substep(solver, contacts, state, dt): + wp.copy(observed, ones, dest_offset=1, count=1) + + NewtonManager.register_state_force_callback(force) + NewtonManager.register_post_solver_substep_callback(substep) + sim.reset() + sim.step(render=False) + assert NewtonManager._graph is not None + np.testing.assert_array_equal(observed.numpy(), [1, 1]) + NewtonManager.unregister_state_force_callback(force) + NewtonManager.unregister_post_solver_substep_callback(substep) + observed.zero_() + sim.step(render=False) + np.testing.assert_array_equal(observed.numpy(), [0, 0]) + graph = NewtonManager._graph + NewtonManager.unregister_state_force_callback(force) + NewtonManager.unregister_post_solver_substep_callback(substep) + assert NewtonManager._graph is graph + sim.step(render=False) + np.testing.assert_array_equal(observed.numpy(), [0, 0]) diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py index a13c4a84c21e..305671fa3fa8 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_collision_cfg.py @@ -107,10 +107,10 @@ class NewtonCollisionPipelineCfg: """ include_static_kinematic_pairs: bool = True - """Whether to generate contacts between two immovable shapes. + """Whether to generate static-kinematic and kinematic-kinematic contacts. - Set to ``False`` to exclude static-static, static-kinematic, and kinematic-kinematic - pairs. Defaults to ``True``, matching Newton's default. + Static-static pairs are always excluded. Set to ``False`` to also exclude + static-kinematic and kinematic-kinematic pairs. Defaults to ``True``, matching Newton's default. """ reduce_contacts: bool = True diff --git a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py index 3a0b3ef9dd48..6f4b854188cd 100644 --- a/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py +++ b/source/isaaclab_newton/isaaclab_newton/physics/newton_manager.py @@ -1960,13 +1960,18 @@ def unregister_state_force_callback(cls, callback: Callable[[State], None]) -> N Removing a callback that was never registered or was already removed is a safe no-op. This lets scene-owned systems release bound-method - references before the global Newton manager is cleared. + references before the global Newton manager is cleared. An active CUDA + graph is rebuilt on the next step to discard the removed callback. Args: callback: Previously registered callback. """ - with contextlib.suppress(ValueError): - NewtonManager._state_force_callbacks.remove(callback) + if callback not in cls._state_force_callbacks: + return + cls._state_force_callbacks.remove(callback) + if cls._graph is not None: + cls._graph = None + cls._graph_capture_pending = True @classmethod def register_post_solver_substep_callback( @@ -2026,11 +2031,18 @@ def unregister_post_solver_substep_callback( ) -> None: """Remove a previously registered post-solver-substep callback. + An active CUDA graph is rebuilt on the next step. Removing an absent + callback is a safe no-op. + Args: callback: Previously registered callback. """ - with contextlib.suppress(ValueError): - cls._post_solver_substep_callbacks.remove(callback) + if callback not in cls._post_solver_substep_callbacks: + return + cls._post_solver_substep_callbacks.remove(callback) + if cls._graph is not None: + cls._graph = None + cls._graph_capture_pending = True @classmethod def register_post_step_callback(cls, callback: Callable[[], None]) -> None: From 54613ef14cf291c87f2f3eb2a9386befb5333a1f Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Mon, 12 Oct 2026 12:39:48 +0200 Subject: [PATCH 13/45] Use shared mesh cooking properties and reject lossy inputs --- .../isaaclab/sim/spawners/meshes/meshes.py | 35 ++++++++++--------- .../sim/spawners/meshes/meshes_cfg.py | 6 ++-- source/isaaclab/test/sim/test_spawn_meshes.py | 12 +++++++ 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index 2d19bb18da5d..394233c96816 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -29,6 +29,7 @@ props_expr, resolve_deformable_slot, ) +from .meshes_cfg import MeshCustomCfg if TYPE_CHECKING: from . import meshes_cfg @@ -61,22 +62,22 @@ def spawn_mesh_custom( Raises: ValueError: If the vertex or face arrays are malformed, a face index is - out of range, or the collision approximation is unknown. + out of range. """ del kwargs - vertices = np.asarray(cfg.vertices, dtype=np.float32) - faces = np.asarray(cfg.faces, dtype=np.int64) + with np.errstate(over="ignore"): + vertices = np.asarray(cfg.vertices, dtype=np.float32) + if not np.isfinite(vertices).all(): + raise ValueError("Custom mesh vertices must be finite USD float32 points.") + faces = np.asarray(cfg.faces) + if not np.issubdtype(faces.dtype, np.integer): + raise ValueError("Custom mesh face indices must be integers.") if vertices.ndim != 2 or vertices.shape[1:] != (3,) or len(vertices) < 3: raise ValueError(f"Custom mesh vertices must have shape (N, 3) with N >= 3, got {vertices.shape}.") if faces.ndim != 2 or faces.shape[1:] != (3,) or len(faces) < 1: raise ValueError(f"Custom mesh faces must have shape (M, 3) with M >= 1, got {faces.shape}.") if np.any(faces < 0) or np.any(faces >= len(vertices)): raise ValueError(f"Custom mesh face indices must be in [0, {len(vertices) - 1}].") - if cfg.collision_approximation not in schemas.MESH_APPROXIMATION_TOKENS: - raise ValueError( - f"Unknown mesh collision approximation {cfg.collision_approximation!r}. " - f"Valid options are: {list(schemas.MESH_APPROXIMATION_TOKENS)}" - ) mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) stage = get_current_stage() @@ -485,15 +486,15 @@ def _spawn_mesh_geom_from_mesh( ) elif cfg.collision_props is not None: # decide on type of collision approximation based on the mesh - collision_approximation = getattr(cfg, "collision_approximation", None) - if collision_approximation is None: - if cfg.__class__.__name__ == "MeshSphereCfg": - collision_approximation = "boundingSphere" - elif cfg.__class__.__name__ == "MeshCuboidCfg": - collision_approximation = "boundingCube" - else: - # for: MeshCylinderCfg, MeshCapsuleCfg, MeshConeCfg - collision_approximation = "convexHull" + if isinstance(cfg, MeshCustomCfg): + collision_approximation = "none" + elif cfg.__class__.__name__ == "MeshSphereCfg": + collision_approximation = "boundingSphere" + elif cfg.__class__.__name__ == "MeshCuboidCfg": + collision_approximation = "boundingCube" + else: + # for: MeshCylinderCfg, MeshCapsuleCfg, MeshConeCfg + collision_approximation = "convexHull" # apply collision approximation to mesh # note: for primitives, we use the convex hull approximation -- this should be sufficient for most cases. UsdPhysics.MeshCollisionAPI.Apply(mesh_prim).GetApproximationAttr().Set(collision_approximation) diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py index 2afb87b9a0d5..6145dec84174 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py @@ -94,7 +94,8 @@ class MeshCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): class MeshCustomCfg(MeshCfg): """Configuration parameters for a mesh authored from vertices and triangular faces. - See :meth:`spawn_mesh_custom` for more information. + Collision defaults to exact triangles. Use inherited ``mesh_collision_props`` + to choose another collision approximation. See :meth:`spawn_mesh_custom` for more information. """ func: Callable | str = "{DIR}.meshes:spawn_mesh_custom" @@ -105,9 +106,6 @@ class MeshCustomCfg(MeshCfg): faces: tuple[tuple[int, int, int], ...] = MISSING """Triangle vertex indices with counter-clockwise front-face winding.""" - collision_approximation: str = "none" - """Mesh collision approximation name. Defaults to ``"none"`` for exact triangle-mesh collision.""" - subdivision_scheme: Literal["none", "catmullClark", "loop", "bilinear"] = "none" """USD subdivision scheme. Defaults to ``"none"`` to preserve the authored surface.""" diff --git a/source/isaaclab/test/sim/test_spawn_meshes.py b/source/isaaclab/test/sim/test_spawn_meshes.py index 95a38d6d63fc..dbe5c785c73c 100644 --- a/source/isaaclab/test/sim/test_spawn_meshes.py +++ b/source/isaaclab/test/sim/test_spawn_meshes.py @@ -179,6 +179,18 @@ def test_spawn_custom_mesh(sim): assert not mesh_prim.GetAttribute("primvars:displayColor").HasAuthoredValue() +def test_spawn_custom_mesh_rejects_malformed_data(sim): + """Reject lossy face indices and non-finite USD points before authoring any prim.""" + cfg = sim_utils.MeshCustomCfg(vertices=((0, 0, 0), (1, 0, 0), (0, 1, 0)), faces=((0, 1, 2),)) + for invalid, message in ( + (cfg.replace(faces=((0, 1.9, 2),)), "integer"), + (cfg.replace(vertices=((float("nan"), 0, 0), (1, 0, 0), (0, 1, 0))), "finite"), + ): + with pytest.raises(ValueError, match=message): + invalid.func("/World/InvalidCustomMesh", invalid) + assert not sim.stage.GetPrimAtPath("/World/InvalidCustomMesh").IsValid() + + """ Physics properties. """ From 72500f01bf2bacc0b0002e2436807cba4a012cf6 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Mon, 12 Oct 2026 12:46:48 +0200 Subject: [PATCH 14/45] Correct conveyor tool velocity and reuse mesh cooking settings --- .../contrib/conveyor_franka/conveyor_franka_env.py | 6 +++--- .../conveyor_franka/conveyor_franka_physx_env_cfg.py | 8 ++------ .../contrib/conveyor_franka/mdp/kinematics.py | 2 +- .../test/contrib/test_conveyor_franka_mdp.py | 12 ++++++++++++ 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py index 643fea691e61..1fe1b2bdffa6 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py @@ -109,7 +109,7 @@ def conveyor_belt(self) -> SurfaceVelocityView: def _setup_goal_selector(self) -> None: """Attach one task panel to the first supported interactive visualizer.""" for visualizer in self.sim.visualizers: - if getattr(visualizer.cfg, "visualizer_type", None) not in {"newton_gl", "newton_rtx"}: + if visualizer.cfg.visualizer_type not in {"newton_gl", "newton_rtx"}: continue register_callback = getattr(visualizer, "register_ui_callback", None) if register_callback is None: @@ -126,13 +126,13 @@ def _reset_idx(self, env_ids: Sequence[int] | slice): env_ids = self.scene._ALL_INDICES[env_ids] super()._reset_idx(env_ids) - conveyor_driver = getattr(self, "_conveyor_driver", None) + conveyor_driver = self._conveyor_driver if conveyor_driver is not None: conveyor_driver.reset(env_ids) def close(self): """Release conveyor callbacks before the physics scene is destroyed.""" - conveyor_driver = getattr(self, "_conveyor_driver", None) + conveyor_driver = self._conveyor_driver if conveyor_driver is not None: conveyor_driver.close() self._conveyor_driver = None diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py index 7661d955ba20..6dbbc07a21fb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py @@ -148,11 +148,8 @@ def _physx_conveyor_collision( faces=geometry.faces, visible=False, rigid_props=_PHYSX_KINEMATIC_PROPERTIES, - collision_props=[ - *_physx_collision_properties(contact_offset=_CONTACT_GAP), - PhysxSDFMeshCfg(sdf_resolution=128, sdf_subgrid_resolution=6), - ], - collision_approximation="sdf", + collision_props=_physx_collision_properties(contact_offset=_CONTACT_GAP), + mesh_collision_props=PhysxSDFMeshCfg(sdf_resolution=128, sdf_subgrid_resolution=6), physics_material=_physx_material(friction), ) spawn.func = functools.partial(_spawn_physx_conveyor, belt_spec=section.belt) @@ -170,7 +167,6 @@ def _physx_guard_collision(prim_path: str, spec: MeshSpec) -> AssetBaseCfg: faces=spec.faces, visible=False, collision_props=_physx_collision_properties(), - collision_approximation="none", physics_material=_physx_material(1.1e-5), ) spawn.func = _spawn_hidden_collision_mesh diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py index 29ab17c9ce6f..2edaaa038abd 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py @@ -65,7 +65,7 @@ def tool_velocity( """Return tool-center linear and angular velocity [m/s, rad/s].""" robot, body_id, offset = _end_effector_cache_entry(env, robot_cfg, body_name, body_offset) orientation = robot.data.body_quat_w.torch[:, body_id] - body_velocity = robot.data.body_vel_w.torch[:, body_id] + body_velocity = robot.data.body_link_vel_w.torch[:, body_id] offset_world = math_utils.quat_apply(orientation, offset.expand(env.num_envs, -1)) linear_velocity = body_velocity[:, :3] + torch.linalg.cross(body_velocity[:, 3:], offset_world) return torch.cat((linear_velocity, body_velocity[:, 3:]), dim=1) diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py index cb75f41feea3..9cdef6827970 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py @@ -129,6 +129,18 @@ def test_checkpoint_interface_and_invalid_action_recovery(): robot.write_joint_position_to_sim_index(position=joints) env.sim.forward() env.scene.update(env.step_dt) + if env.spec.id == _BASE: + # Differentiate the independent reset FK rather than repeating the velocity shift. + rates = torch.tensor([0.5, -0.4, 0.3, -0.2, 0.4, -0.5, 0.2]).repeat(2, 1) + velocity = torch.zeros_like(robot.data.joint_vel.torch) + velocity[:, joint_ids] = rates + robot.write_joint_velocity_to_sim_index(velocity=velocity) + env.sim.forward() + env.scene.update(env.step_dt) + expected_velocity = ( + franka_tool_position(initial + 1e-3 * rates) - franka_tool_position(initial - 1e-3 * rates) + ) / 2e-3 + torch.testing.assert_close(mdp.end_effector_velocity(env)[:, :3], expected_velocity, atol=1e-4, rtol=1e-3) env.action_manager.process_action(bad) torch.testing.assert_close( arm.processed_actions, initial + torch.tensor([[0, 0.12, -0.12, 0.12, -0.12, 0.06, -0.06], [0.06] * 7]) From a21ea3bd8c93cba5f6cf785c36c915347dc2ff98 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Mon, 12 Oct 2026 12:49:30 +0200 Subject: [PATCH 15/45] Document authored warehouse layers and license support overrides --- .../isaaclab_tasks/contrib/conveyor_franka/README.md | 7 +++++-- .../conveyor_franka/assets/conveyor_quarter_supported.usda | 4 ++++ .../assets/conveyor_straight_supported.usda | 4 ++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md index fad732db1c5f..67b3b83ec9b3 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md @@ -153,6 +153,10 @@ uv run isaaclab train --rl_library rsl_rl \ ### Editing the presentation +The authored USDA layers are versioned with the task because their route metadata, +collision paths, and animation must match its implementation. NVIDIA meshes and +textures stay at their original remote locations under their original licenses. + - `assets/warehouse.usda` owns layout, props, materials, lights, cameras, and looping background transforms. `Cameras/Workcell` and `Cameras/Overview` provide two views. - `assets/conveyor_routes.usda` owns route centerlines, frame meshes, traction, @@ -166,8 +170,7 @@ uv run isaaclab train --rl_library rsl_rl \ `transfer_counts` records placements. Selected resets clear that batch's counters. The spawner caches external assets, removes imported physics and action graphs, -and samples background animation using simulation time. NVIDIA geometry and textures -remain external references under their original licenses. See the +and samples background animation using simulation time. See the [SimReady Explorer documentation](https://docs.omniverse.nvidia.com/extensions/latest/ext_core/ext_browser-extensions/simready-explorer.html) for catalog access. Layout references include [Dematic modular conveyors](https://www.dematic.com/content/dam/dematic/downloads/brochures/NA_BR_1039_MCS.pdf), diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_quarter_supported.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_quarter_supported.usda index 628d67830e98..ac9c4bd584a2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_quarter_supported.usda +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_quarter_supported.usda @@ -1,4 +1,8 @@ #usda 1.0 +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause ( defaultPrim = "Conveyor" ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_straight_supported.usda b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_straight_supported.usda index f99552edf67e..4f06ee40ba80 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_straight_supported.usda +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_straight_supported.usda @@ -1,4 +1,8 @@ #usda 1.0 +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause ( defaultPrim = "Conveyor" ) From af6d277ad64a3ca79deb30ed40fd0bb8e97b0003 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Mon, 12 Oct 2026 12:51:06 +0200 Subject: [PATCH 16/45] Keep authored conveyor overrides reviewable as text --- .gitattributes | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitattributes b/.gitattributes index 3ae9fd3d23a7..33a7f2aea6b3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,7 +1,7 @@ *.usd filter=lfs diff=lfs merge=lfs -text *.usda filter=lfs diff=lfs merge=lfs -text -source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_*_supported.usda -filter -diff -merge text -source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel*.usda -filter -diff -merge text +source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/conveyor_*_supported.usda -filter diff merge text +source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/assets/parcel*.usda -filter diff merge text *.psd filter=lfs diff=lfs merge=lfs -text *.hdr filter=lfs diff=lfs merge=lfs -text *.dae filter=lfs diff=lfs merge=lfs -text From cffb3570ce129bb5d7d3788632e2774036e21a76 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Mon, 12 Oct 2026 12:58:23 +0200 Subject: [PATCH 17/45] Persist evaluation reports before Kit shutdown --- .../isaaclab_tasks/contrib/conveyor_franka/evaluate.py | 10 +++++----- .../test/contrib/test_conveyor_franka_physx_cfg.py | 6 ++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/evaluate.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/evaluate.py index eb9d5abbfb98..6ae9ba7a36e0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/evaluate.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/evaluate.py @@ -95,11 +95,11 @@ def main() -> None: "nonfinite_observations": nonfinite_observations, "transfers_per_environment_minute": float(transfers.sum()) * 60.0 / seconds, } - report = json.dumps(result, indent=2) - print(report) - if args.output is not None: - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(report + "\n") + report = json.dumps(result, indent=2) + print(report, flush=True) + if args.output is not None: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(report + "\n") if __name__ == "__main__": diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py index b74e381c981a..3d9394f8b1f3 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py @@ -6,6 +6,7 @@ """Native PhysX conveyor device and authored geometry contracts.""" import pytest +from isaaclab_physx.sim.schemas import PhysxSDFMeshCfg from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg @@ -38,8 +39,9 @@ def test_physx_preserves_cpu_policy_and_pivot_conventions(): assert next( props for props in spawn.rigid_props if isinstance(props, UsdPhysicsRigidBodyCfg) ).kinematic_enabled - assert ( - getattr(cfg.scene, f"conveyor_{side.lower()}_right_turn_collision").spawn.collision_approximation == "sdf" + assert isinstance( + getattr(cfg.scene, f"conveyor_{side.lower()}_right_turn_collision").spawn.mesh_collision_props, + PhysxSDFMeshCfg, ) cfg.sim.device = "cuda:0" with pytest.raises(ValueError, match="CPU-only.*--device cpu"): From 0eacad737eac619981059d557af90aca5648855b Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Mon, 12 Oct 2026 13:01:14 +0200 Subject: [PATCH 18/45] Record the tested conveyor integration snapshot --- tools/conveyor_stack.json | 58 +++++++++++ tools/conveyor_stack.md | 53 ++++++++++ tools/conveyor_stack.py | 209 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 tools/conveyor_stack.json create mode 100644 tools/conveyor_stack.md create mode 100644 tools/conveyor_stack.py diff --git a/tools/conveyor_stack.json b/tools/conveyor_stack.json new file mode 100644 index 000000000000..5beb502c2c70 --- /dev/null +++ b/tools/conveyor_stack.json @@ -0,0 +1,58 @@ +{ + "repository": "isaac-sim/IsaacLab", + "base": "develop", + "integration_pr": 6952, + "integration_branch": "maximiliank/conveyor-franka-env", + "components": [ + { + "pr": 8326, + "name": "Custom mesh spawning" + }, + { + "pr": 8327, + "name": "Experimental conveyor support in Contrib" + }, + { + "pr": 8331, + "name": "Racetrack transfer task" + }, + { + "pr": 8332, + "name": "Warehouse sorting task" + } + ], + "snapshot": { + "base": "dd2f470fcad5873980e9bc4acffb043238aca41c", + "components": [ + { + "pr": 8326, + "name": "Custom mesh spawning", + "head": "54613ef14cf291c87f2f3eb2a9386befb5333a1f", + "merged": false + }, + { + "pr": 8327, + "name": "Experimental conveyor support in Contrib", + "head": "de3d752c5030c7992db78a76ad9ab82d6c80164d", + "merged": false + }, + { + "pr": 8331, + "name": "Racetrack transfer task", + "head": "cffb3570ce129bb5d7d3788632e2774036e21a76", + "merged": false + }, + { + "pr": 8332, + "name": "Warehouse sorting task", + "head": "4fa636eae3a0bab51f0dda35322fe0d60e5d13a3", + "merged": false + } + ], + "checks": [ + "Focused CPU tests and lock consistency passed", + "360-step policy evaluation: 5 transfers, zero safety resets/non-finite observations" + ], + "checked_at": "2026-10-12T11:01:08.393551+00:00" + } +} diff --git a/tools/conveyor_stack.md b/tools/conveyor_stack.md new file mode 100644 index 000000000000..7fba63ab522f --- /dev/null +++ b/tools/conveyor_stack.md @@ -0,0 +1,53 @@ +# Conveyor integration draft + +PR #6952 is the combined validation draft. Review and merge #8326 (meshes), +#8327 (Contrib conveyor support), #8331 (racetrack transfer), and #8332 +(warehouse sorting) individually. Do not merge the integration draft or its +temporary tracking tools. + +After pushing a change to any component PR, run from this integration checkout: + +```bash +uv run --no-project python tools/conveyor_stack.py --check --push +``` + +To also check the trained policy on CPU: + +```bash +uv run --no-project python tools/conveyor_stack.py --check --push \ + --checkpoint /path/to/model.pt +``` + +The script fetches current `develop` and each current PR head, then merges them +in dependency order in a temporary worktree. Merged PRs are supplied by +`develop` instead of being merged again. Closed, unmerged components require +an explicit change to the list in `conveyor_stack.json`. + +Each test file runs in its own process. The refresh covers mesh authoring, +adapter controls/lifecycle, both task contracts, selected batch resets, sorting, +geometry, asset normalization, and the lock file. Optional policy evaluation +requires completed transfers with zero safety resets or non-finite +observations. Formatting also runs before publication. + +On success, the script publishes a freshly rebuilt branch with a lease against +concurrent updates and rewrites #6952's description with the exact included +SHAs and completed checks. The same snapshot is committed in +`tools/conveyor_stack.json`. A merge conflict or failed check stops publication. + +This runs on demand. Component pushes do not automatically refresh #6952. +GitHub CI runs on the published integration commit; expensive checks can be +requested with the repository's usual `run-ci` comment. + +The refresh uses the configured `fork` remote for publication, authenticated +`gh`, and the current checkout's uv environment. Local edits and the current +checkout are preserved; tests use only the published component heads. To +inspect a refreshed result without changing an existing worktree: + +```bash +git fetch fork maximiliank/conveyor-franka-env +git worktree add --detach /tmp/conveyor-preview FETCH_HEAD +``` + +When a prerequisite merges, rebase its dependent drafts onto current +`develop`, update their review-only comparison links, and refresh the +integration again. diff --git a/tools/conveyor_stack.py b/tools/conveyor_stack.py new file mode 100644 index 000000000000..76fae07ca48c --- /dev/null +++ b/tools/conveyor_stack.py @@ -0,0 +1,209 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Rebuild the conveyor integration draft from the current component PR heads.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import tempfile +from datetime import datetime, timezone +from pathlib import Path + + +def run(args: list[str], cwd: Path, *, env: dict[str, str] | None = None, capture: bool = False) -> str: + """Run an argument vector and stop immediately on failure.""" + result = subprocess.run(args, cwd=cwd, env=env, check=True, text=True, capture_output=capture) + return result.stdout.strip() if capture else "" + + +def main() -> None: + """Fetch, merge, validate, and optionally publish the current stack.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="Run focused CPU tests before publishing.") + parser.add_argument("--push", action="store_true", help="Update the integration branch and draft description.") + parser.add_argument("--checkpoint", type=Path, help="Also evaluate this trained policy for 360 CPU steps.") + args = parser.parse_args() + if args.push and not args.check: + parser.error("--push requires --check") + root = Path(__file__).resolve().parents[1] + manifest = json.loads((root / "tools/conveyor_stack.json").read_text()) + repository = manifest["repository"] + branch = manifest["integration_branch"] + if branch in {"main", "develop"} or branch.startswith("release/"): + parser.error("The integration target must be a feature branch.") + git = ["git", "-c", "http.version=HTTP/1.1"] + expected = run(git + ["ls-remote", "fork", f"refs/heads/{branch}"], root, capture=True).split()[0] + components = [] + refs = [f"+refs/heads/{manifest['base']}:refs/conveyor-stack/base"] + for component in manifest["components"]: + number = int(component["pr"]) + data = json.loads(run(["gh", "api", f"repos/{repository}/pulls/{number}"], root, capture=True)) + if data["state"] != "open" and not data["merged"]: + raise RuntimeError(f"PR #{number} was closed without merging; update the component list.") + components.append({**component, "head": data["head"]["sha"], "merged": data["merged"]}) + if not data["merged"]: + refs.append(f"+refs/pull/{number}/head:refs/conveyor-stack/{number}") + run(git + ["fetch", "--no-tags", f"https://github.com/{repository}.git", *refs], root) + base = run(git + ["rev-parse", "refs/conveyor-stack/base"], root, capture=True) + with tempfile.TemporaryDirectory(prefix="conveyor-stack-") as directory: + checkout = Path(directory) / "checkout" + run(git + ["worktree", "add", "--detach", str(checkout), base], root) + try: + for component in components: + if component["merged"]: + continue + number = component["pr"] + head = run(git + ["rev-parse", f"refs/conveyor-stack/{number}"], root, capture=True) + if head != component["head"]: + raise RuntimeError(f"PR #{number} changed during fetch; rerun the refresh.") + run(git + ["merge", "--no-ff", "-m", f"Integrate #{number} at {head[:12]}", head], checkout) + for name in ("conveyor_stack.py", "conveyor_stack.json", "conveyor_stack.md"): + shutil.copyfile(root / "tools" / name, checkout / "tools" / name) + env = os.environ.copy() + env.pop("VIRTUAL_ENV", None) + env["UV_PROJECT_ENVIRONMENT"] = str(Path(directory) / ".venv") + env["ISAACLAB_TEST_DEVICES"] = "100" + env["PYTHONPATH"] = os.pathsep.join(str(path) for path in (checkout / "source").iterdir() if path.is_dir()) + uv = ["uv", "run", "--no-sync"] + run(["uv", "sync", "--frozen", "--extra", "dev", "--extra", "isaacsim"], checkout, env=env) + # Reuse prior acceptance only for the same installed Kit package version. + for pattern in ( + "lib/python*/site-packages/isaacsim/kit/EULA_ACCEPTED", + "Lib/site-packages/isaacsim/kit/EULA_ACCEPTED", + ): + for marker in (root / ".venv").glob(pattern): + target = Path(env["UV_PROJECT_ENVIRONMENT"]) / marker.relative_to(root / ".venv") + versions = marker.parents[2].glob("isaacsim_kernel-*.dist-info") + if target.parent.is_dir() and any( + (target.parents[2] / version.name).is_dir() for version in versions + ): + shutil.copyfile(marker, target) + run( + uv + + [ + "python", + "-c", + "import sys; from pathlib import Path; from isaaclab.paths import ISAACLAB_ROOT; " + "assert Path(ISAACLAB_ROOT).resolve() == Path.cwd().resolve(); " + "print(f'Validation interpreter: {sys.executable}; source root: {ISAACLAB_ROOT}')", + ], + checkout, + env=env, + ) + checks = [] + if args.check: + tests = [ + "source/isaaclab/test/sim/test_spawn_meshes.py", + "source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py", + "source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py", + "source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py", + "source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py", + "source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py", + "source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py", + "source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py", + ] + for test in tests: + run(uv + ["python", "-m", "pytest", test, "-q"], checkout, env=env) + run(["uv", "lock", "--check"], checkout, env=env) + checks.append("Focused CPU tests and lock consistency passed") + if args.checkpoint: + report = Path(directory) / "policy.json" + run( + uv + + [ + "python", + "-m", + "isaaclab_tasks.contrib.conveyor_franka.evaluate", + "--checkpoint", + str(args.checkpoint.resolve()), + "--num_envs", + "2", + "--device", + "cpu", + "--steps", + "360", + "--seed", + "0", + "--output", + str(report), + ], + checkout, + env=env, + ) + policy = json.loads(report.read_text()) + if policy["failures"] or policy["nonfinite_observations"] or not policy["completed_transfers"]: + raise RuntimeError(f"Policy evaluation did not meet the bounded transfer check: {policy}") + checks.append( + f"360-step policy evaluation: {policy['completed_transfers']} transfers, " + "zero safety resets/non-finite observations" + ) + manifest["snapshot"] = { + "base": base, + "components": components, + "checks": checks, + "checked_at": datetime.now(timezone.utc).isoformat(), + } + (checkout / "tools/conveyor_stack.json").write_text(json.dumps(manifest, indent=2) + "\n") + run( + git + ["add", "tools/conveyor_stack.py", "tools/conveyor_stack.json", "tools/conveyor_stack.md"], + checkout, + ) + run(uv + ["isaaclab", "-f"], checkout, env=env) + run(git + ["diff", "--check"], checkout) + run(git + ["commit", "-m", "Record the tested conveyor integration snapshot"], checkout) + head = run(git + ["rev-parse", "HEAD"], checkout, capture=True) + rows = "\n".join(f"| #{item['pr']} | {item['name']} | `{item['head'][:12]}` |" for item in components) + body = ( + "## Conveyor integration draft\n\n**Do not merge this PR.** " + "Review and merge the four component PRs individually.\n\n" + f"| PR | Scope | Included head |\n| --- | --- | --- |\n{rows}\n\n" + f"Base: `{base[:12]}`. Integration commit: `{head[:12]}`.\n\n" + + "\n".join(f"- {check}" for check in checks) + + "\n\nRefresh from the latest published PR heads with:\n\n" + "```bash\nuv run --no-project python tools/conveyor_stack.py --check --push\n```\n\n" + "Add `--checkpoint /path/to/model.pt` for bounded policy evaluation. " + "A failed test or merge conflict stops publication. The refresh uses a temporary checkout " + "and records exact SHAs in `tools/conveyor_stack.json`. " + "It runs on demand; it does not poll or refresh automatically.\n" + ) + if args.push: + run( + git + + [ + "push", + f"--force-with-lease=refs/heads/{branch}:{expected}", + "fork", + f"HEAD:refs/heads/{branch}", + ], + checkout, + ) + payload = Path(directory) / "description.json" + payload.write_text(json.dumps({"body": body})) + run( + [ + "gh", + "api", + "--method", + "PATCH", + f"repos/{repository}/pulls/{manifest['integration_pr']}", + "--input", + str(payload), + "--silent", + ], + root, + ) + else: + print(body) + finally: + run(git + ["worktree", "remove", "--force", str(checkout)], root) + + +if __name__ == "__main__": + main() From e6d61086807feb38cbbc59bf9783d5aa5e34f756 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 12:31:00 +0200 Subject: [PATCH 19/45] Reuse success-guided sampling for conveyor transfer --- ...nveyor-success-guided-sampling.changed.rst | 3 + .../contrib/conveyor_franka/README.md | 19 +- .../conveyor_franka/agents/conveyor_ppo.py | 48 --- .../conveyor_franka/agents/rsl_rl_ppo_cfg.py | 2 +- .../conveyor_franka_env_cfg.py | 33 +- .../contrib/conveyor_franka/mdp/__init__.pyi | 8 + .../contrib/conveyor_franka/mdp/commands.py | 57 +--- .../conveyor_franka/mdp/curriculums.py | 314 +++--------------- .../contrib/conveyor_franka/mdp/rewards.py | 55 +-- .../test/contrib/test_conveyor_franka_mdp.py | 119 ++----- 10 files changed, 108 insertions(+), 550 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/conveyor-success-guided-sampling.changed.rst delete mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py diff --git a/source/isaaclab_tasks/changelog.d/conveyor-success-guided-sampling.changed.rst b/source/isaaclab_tasks/changelog.d/conveyor-success-guided-sampling.changed.rst new file mode 100644 index 000000000000..420492605bb4 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/conveyor-success-guided-sampling.changed.rst @@ -0,0 +1,3 @@ +* Reused the NIST success-guided sampler for conveyor reset rows, with completed transfers as the + success signal and standard PPO. Removed the task-specific progress curriculum and checkpoint + extension; sampling history now starts fresh when an environment is recreated. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md index 67b3b83ec9b3..1ce111d9a52a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md @@ -70,15 +70,16 @@ uv run isaaclab train --rl_library rsl_rl \ ### Curriculum and evaluation Racetrack training samples physically calibrated resets from release through moving-belt pickup. -The shared `SuccessMonitorCfg` tracks **phase progress** per reset row and weights intermediate -variants around a 50% progress target; this is not the full-transfer success rate. Sampling remains -balanced across phases, cube identities, and directions. Moving-belt starts receive at least 35% -of resets, increasing toward 90% only when completed transfers from those starts and reset-row -coverage improve. `deployment_transfer_success_rate` reports that separate rolling signal. - -New RSL-RL checkpoints save the curriculum evidence alongside standard PPO state and restore it -with `--checkpoint /path/to/model.pt`. Older checkpoints load normally with fresh evidence; -loading actor weights alone leaves the current curriculum intact. Playback disables the curriculum. +The shared `SuccessMonitor` records whether each episode completed a stable transfer from its +sampled reset row. NIST's reusable `Sampler` mixes uniform coverage with a Beta-shaped score +centered at 50% transfer success, giving partly learned rows more practice while keeping every +row eligible. Reset recipes generate the robot and cube states directly; the task needs no +cached physical-state bank or assembly-variant machinery. + +Training uses standard PPO with the existing arm-and-gripper action distribution. Checkpoints +save policy and optimizer state; sampling history starts fresh when the environment is recreated, +including when resuming training. Playback disables adaptive sampling. Evaluate from moving-belt +starts to measure pickup and transfer ability independently of the easier training resets. Evaluate from home on moving belts, with fixed reset settings and a chosen seed: diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py deleted file mode 100644 index e7626cf9ba72..000000000000 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""RSL-RL checkpoint integration for the conveyor's adaptive reset evidence.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from rsl_rl.algorithms import PPO - -if TYPE_CHECKING: - from rsl_rl.env import VecEnv - from tensordict import TensorDict - - from ..mdp.curriculums import ConveyorResetCurriculum - - -class ConveyorPPO(PPO): - """Use standard PPO while saving and restoring the task's reset curriculum.""" - - _curriculum: ConveyorResetCurriculum | None - - @staticmethod - def construct_algorithm(obs: TensorDict, env: VecEnv, cfg: dict, device: str) -> ConveyorPPO: - """Bind the active curriculum through RSL-RL's algorithm construction hook.""" - algorithm = PPO.construct_algorithm(obs, env, cfg, device) - curriculum = env.unwrapped.curriculum_manager - algorithm._curriculum = ( - curriculum.cfg.reset_sampling.func if "reset_sampling" in curriculum.active_terms else None - ) - return algorithm - - def save(self) -> dict: - """Include reset evidence in the standard model and optimizer checkpoint.""" - state = super().save() - if self._curriculum is not None: - state["conveyor_reset_curriculum"] = self._curriculum.get_state() - return state - - def load(self, loaded_dict: dict, load_cfg: dict | None, strict: bool) -> bool: - """Restore reset evidence on resume; older policy-only checkpoints remain supported.""" - resume = super().load(loaded_dict, load_cfg, strict) - if resume and self._curriculum is not None and "conveyor_reset_curriculum" in loaded_dict: - self._curriculum.set_state(loaded_dict["conveyor_reset_curriculum"]) - return resume diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py index 6d0b0bc04d70..c63d3574e8a0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py @@ -165,7 +165,7 @@ class ConveyorFrankaPPORunnerCfg(RslRlOnPolicyRunnerCfg): obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( - class_name="isaaclab_tasks.contrib.conveyor_franka.agents.conveyor_ppo:ConveyorPPO", + class_name="PPO", value_loss_coef=1.0, use_clipped_value_loss=True, clip_param=0.2, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py index 8e8e6409ce02..ef7169240dad 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py @@ -137,12 +137,6 @@ class CommandsCfg: maximum_cube_speed=0.65, minimum_finger_position=0.027, minimum_tool_clearance=0.055, - minimum_progress_steps=3, - minimum_progress=0.35, - maximum_target_potential=5.0, - minimum_acquisition_lift=0.025, - maximum_acquisition_tool_distance=0.075, - maximum_acquisition_finger_position=0.030, ) @@ -257,31 +251,20 @@ class TerminationsCfg: @configclass class CurriculumCfg: - """Adaptive phase-balanced reset-state sampling.""" + """Practice reset states near the measured transfer-success frontier.""" reset_sampling = CurrTerm( func=mdp.ConveyorResetCurriculum, params={ "command_name": "transfer", - # Intermediate rows adapt around 50% phase progress, not completed transfers. - "success_monitor": mdp.SuccessMonitorCfg( - monitored_history_len=50, - target_success_rate=0.5, - kappa=1.0, - temperature=1.0, + "success_monitor": mdp.SuccessMonitorCfg(monitored_history_len=50), + "sampling": mdp.SamplerCfg( + strategies=[ + mdp.BetaSamplingStrategyCfg(target=0.5, kappa=2.0), + mdp.UniformSamplingStrategyCfg(weight=0.05), + ], + eps=1.0e-4, ), - # Keep a deployment-facing stream while the remaining starts - # adapt around the rolling pickup-to-placement frontier. Deployment - # mass increases only after actual transfers from moving-belt starts. Every - # recipe, cube identity, and direction retains equal total mass. - "deployment_probability_initial": 0.35, - "deployment_probability_final": 0.90, - "deployment_progress_start": 0.45, - "deployment_progress_end": 0.80, - "deployment_coverage_target": 0.50, - # Optional staged-training control. None keeps the deployable - # bidirectional task; a side id can focus the same adaptive reset - # distribution on one weak direction without changing reset rows. "fixed_source_side_id": None, }, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi index 5bd90e830c86..6cb67792f9b5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi @@ -5,6 +5,7 @@ __all__ = [ "BELT_DEPLOYMENT_VARIANT", + "BetaSamplingStrategyCfg", "ConveyorRelativeJointPositionAction", "ConveyorRelativeJointPositionActionCfg", "ConveyorResetCurriculum", @@ -14,7 +15,9 @@ __all__ = [ "ConveyorTransferCommandCfg", "ResetBufferedGripperAction", "ResetBufferedGripperActionCfg", + "SamplerCfg", "SuccessMonitorCfg", + "UniformSamplingStrategyCfg", "action_term_l2", "active_transfer_features", "build_reset_rows", @@ -76,4 +79,9 @@ from .terminations import ( transfer_sequence_time_out, ) from isaaclab.envs.mdp import * +from isaaclab_tasks.contrib.nist.utils.sampling import ( + BetaSamplingStrategyCfg, + SamplerCfg, + UniformSamplingStrategyCfg, +) from isaaclab_tasks.utils.success_monitor import SuccessMonitorCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py index 8fbdc7317ef2..9b81c5f98c8b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py @@ -18,8 +18,7 @@ from ..conveyor_geometry import BELT_CENTER_X, BELT_HALF_STRAIGHT from .cube_state import cube_values from .kinematics import end_effector_pose -from .reset_events import CUBE_COUNT, ConveyorResetRecipe, select_next_transfer_cube, side_inner_y -from .rewards import current_transfer_potential, physical_cube_acquisition_mask +from .reset_events import CUBE_COUNT, select_next_transfer_cube, side_inner_y if TYPE_CHECKING: from isaaclab.assets import Articulation @@ -78,8 +77,6 @@ def __init__(self, cfg: ConveyorTransferCommandCfg, env: ManagerBasedRLEnv) -> N self.new_success = torch.zeros_like(self.is_success) self.pending_success = torch.zeros_like(self.is_success) self.ever_success = torch.zeros_like(self.is_success) - self.progress_ever_success = torch.zeros_like(self.is_success) - self._target_potential = torch.zeros(self.num_envs, dtype=torch.float32, device=self.device) self._last_evaluation_steps = torch.full_like(self.target_cube_ids, -1) self._resampling_from_reset = False @@ -90,21 +87,13 @@ def __init__(self, cfg: ConveyorTransferCommandCfg, env: ManagerBasedRLEnv) -> N @staticmethod def _validate_cfg(cfg: ConveyorTransferCommandCfg) -> None: - """Validate success and reset-progress thresholds.""" + """Validate stable transfer-completion thresholds.""" if cfg.minimum_subgoal_steps < 0 or cfg.hold_steps < 1: raise ValueError("minimum_subgoal_steps must be non-negative and hold_steps must be positive.") if cfg.lateral_tolerance <= 0.0 or cfg.maximum_cube_speed <= 0.0: raise ValueError("Conveyor placement tolerances and speed limits must be positive.") if cfg.minimum_finger_position <= 0.0 or cfg.minimum_tool_clearance <= 0.0: raise ValueError("Conveyor release thresholds must be positive.") - if cfg.minimum_progress_steps < 0 or cfg.minimum_progress <= 0.0 or cfg.maximum_target_potential <= 0.0: - raise ValueError("Conveyor reset-progress thresholds are invalid.") - if ( - cfg.minimum_acquisition_lift <= 0.0 - or cfg.maximum_acquisition_tool_distance <= 0.0 - or cfg.maximum_acquisition_finger_position <= 0.0 - ): - raise ValueError("Conveyor acquisition thresholds must be positive.") @property def command(self) -> torch.Tensor: @@ -133,17 +122,12 @@ def reset(self, env_ids: Sequence[int] | slice | None = None) -> dict[str, float finally: self._resampling_from_reset = False - initial_potential = current_transfer_potential(self._env, command=self) - self._target_potential[ids] = torch.clamp_max( - initial_potential[ids] + self.cfg.minimum_progress, - self.cfg.maximum_target_potential, - ) self._last_evaluation_steps[ids] = -1 self._env.extras.setdefault("log", {})["Metrics/success_rate"] = extras.pop("success_rate") return extras def evaluate(self) -> None: - """Evaluate stable completion and reset-learning progress once per policy step.""" + """Evaluate stable transfer completion once per policy step.""" evaluation_steps = self._env.episode_length_buf evaluate_mask = (self.command_counter > 0) & (self._last_evaluation_steps != evaluation_steps) if not bool(torch.any(evaluate_mask)): @@ -188,22 +172,6 @@ def evaluate(self) -> None: if pool is not None: pool.record_transfers(success_ids, self.target_cube_ids[success_ids]) - potential = current_transfer_potential(self._env, command=self) - progressed = (potential >= self._target_potential) & (evaluation_steps >= self.cfg.minimum_progress_steps) - acquisition_recipe = ( - (self.recipe_ids == int(ConveyorResetRecipe.GRASP)) - | (self.recipe_ids == int(ConveyorResetRecipe.PREGRASP)) - | (self.recipe_ids == int(ConveyorResetRecipe.BELT)) - ) - physically_acquired = physical_cube_acquisition_mask( - self._env, - command=self, - minimum_lift=self.cfg.minimum_acquisition_lift, - maximum_tool_distance=self.cfg.maximum_acquisition_tool_distance, - maximum_finger_position=self.cfg.maximum_acquisition_finger_position, - ) - progressed &= ~acquisition_recipe | physically_acquired - self.progress_ever_success |= evaluate_mask & progressed self._last_evaluation_steps[evaluate_mask] = evaluation_steps[evaluate_mask] self._env.extras["successes"] = self.ever_success.clone() @@ -237,7 +205,6 @@ def _resample_command(self, env_ids: Sequence[int]) -> None: self.transfer_counts[ids] = 0 self.direction_transfer_counts[ids] = 0 self.ever_success[ids] = False - self.progress_ever_success[ids] = False self._assign_goal(ids, target_cube_ids, source_side_ids) self.held_cube_ids[ids] = torch.where(held_rows, target_cube_ids, -1) self.subgoal_start_steps[ids] = 0 @@ -328,21 +295,3 @@ class ConveyorTransferCommandCfg(CommandTermCfg): minimum_tool_clearance: float = 0.055 """Minimum tool-to-cube distance for the cube to count as released [m].""" - - minimum_progress_steps: int = 3 - """Minimum policy steps before reset-learning progress can be credited.""" - - minimum_progress: float = 0.35 - """Required increase in the dimensionless transfer potential after reset.""" - - maximum_target_potential: float = 5.0 - """Upper bound for the dimensionless reset-progress target.""" - - minimum_acquisition_lift: float = 0.025 - """Minimum cube lift used to validate physical acquisition [m].""" - - maximum_acquisition_tool_distance: float = 0.075 - """Maximum tool-to-cube distance used to validate physical acquisition [m].""" - - maximum_acquisition_finger_position: float = 0.030 - """Maximum finger position used to validate physical acquisition [m].""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py index e85da72345b6..a01340f1b709 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Adaptive reset-state curriculum for conveyor transfer.""" +"""Success-guided reset sampling for conveyor transfer.""" from __future__ import annotations @@ -14,314 +14,86 @@ import torch from isaaclab.managers import CurriculumTermCfg, ManagerTermBase +from isaaclab.utils import instantiate +from isaaclab_tasks.contrib.nist.utils.sampling import Sampler, SamplerCfg from isaaclab_tasks.utils.success_monitor import SuccessMonitorCfg -from .reset_events import BELT_DEPLOYMENT_VARIANT, CUBE_COUNT, ConveyorResetRecipe, reset_variant_counts +from .reset_events import ConveyorResetRecipe if TYPE_CHECKING: from isaaclab.envs import ManagerBasedRLEnv -def reset_sampling_probabilities( - recipe_ids: torch.Tensor, - variant_ids: torch.Tensor, - target_cube_ids: torch.Tensor, - source_side_ids: torch.Tensor, - target_weights: torch.Tensor, - deployment_probability: float | torch.Tensor, -) -> torch.Tensor: - """Balance target-rate weights and mix in guaranteed deployment starts.""" - if not ( - recipe_ids.shape == variant_ids.shape == target_cube_ids.shape == source_side_ids.shape == target_weights.shape - ): - raise ValueError("Reset row metadata and target weights must have matching shapes.") - deployment_probability = torch.as_tensor( - deployment_probability, - dtype=torch.float32, - device=target_weights.device, - ) - if deployment_probability.numel() != 1: - raise ValueError("deployment_probability must be a scalar.") - deployment_probability = deployment_probability.reshape(()) - if bool((deployment_probability <= 0.0) | (deployment_probability >= 1.0)): - raise ValueError("deployment_probability must lie strictly between zero and one.") - if not bool(torch.all(torch.isfinite(target_weights))) or bool(torch.any(target_weights < 0.0)): - raise ValueError("target_weights must be finite and non-negative.") - - deployment_rows = (recipe_ids == int(ConveyorResetRecipe.BELT)) & (variant_ids == BELT_DEPLOYMENT_VARIANT) - if not bool(torch.any(deployment_rows)) or bool(torch.all(deployment_rows)): - raise ValueError("Reset table must contain deployment and intermediate rows.") - - adaptive = target_weights.clone() - adaptive[deployment_rows] = 0.0 - - # Mirror Franka Stack's recipe/layout balancing: success in one physical - # phase must not starve another cube identity or transfer direction. - command_ids = 2 * target_cube_ids + source_side_ids - command_count = 2 * CUBE_COUNT - if bool(torch.any((command_ids < 0) | (command_ids >= command_count))): - raise ValueError("Reset table contains an invalid cube or source-side id.") - stratum_ids = recipe_ids * command_count + command_ids - stratum_count = len(ConveyorResetRecipe) * command_count - stratum_mass = torch.zeros(stratum_count, dtype=adaptive.dtype, device=adaptive.device) - stratum_mass.scatter_add_(0, stratum_ids, adaptive) - if bool(torch.any(stratum_mass <= 0.0)): - raise ValueError("Every recipe, cube, and source-side stratum must have intermediate reset rows.") - adaptive /= stratum_mass[stratum_ids] - adaptive[deployment_rows] = 0.0 - adaptive *= (1.0 - deployment_probability) / adaptive.sum() - deployment = deployment_rows.to(dtype=adaptive.dtype) - deployment *= deployment_probability / deployment.sum() - return adaptive + deployment - - -def deployment_probability_from_progress( - progress_rate: torch.Tensor, - row_coverage: torch.Tensor, - initial_probability: float = 0.35, - final_probability: float = 0.90, - progress_start: float = 0.45, - progress_end: float = 0.80, - coverage_target: float = 0.50, -) -> torch.Tensor: - """Interpolate deployment sampling from rolling completed-transfer success and row coverage.""" - if progress_rate.numel() != 1 or row_coverage.numel() != 1: - raise ValueError("progress_rate and row_coverage must be scalar tensors.") - if not 0.0 < initial_probability <= final_probability < 1.0: - raise ValueError("Deployment probabilities must be ordered strictly inside (0, 1).") - if not 0.0 <= progress_start < progress_end <= 1.0: - raise ValueError("Deployment progress thresholds must be ordered inside [0, 1].") - if not 0.0 < coverage_target <= 1.0: - raise ValueError("coverage_target must lie inside (0, 1].") - if bool((progress_rate < 0.0) | (progress_rate > 1.0) | (row_coverage < 0.0) | (row_coverage > 1.0)): - raise ValueError("Rolling progress and row coverage must lie inside [0, 1].") - - progress_fraction = ((progress_rate - progress_start) / (progress_end - progress_start)).clamp(0.0, 1.0) - coverage_fraction = (row_coverage / coverage_target).clamp(0.0, 1.0) - readiness = progress_fraction * coverage_fraction - readiness = readiness.square() * (3.0 - 2.0 * readiness) - return initial_probability + (final_probability - initial_probability) * readiness - - class ConveyorResetCurriculum(ManagerTermBase): - """Record row outcomes and sample the next physical reset states.""" + """Sample reset rows using the shared NIST sampler and completed-transfer outcomes. + + Reset geometry and command metadata belong to the reset term. Only recent success + evidence lives here; standard PPO checkpoints start a fresh sampling history on resume. + """ def __init__(self, cfg: CurriculumTermCfg, env: ManagerBasedRLEnv): super().__init__(cfg, env) - reset_term = env.event_manager.get_term_cfg("reset_from_state_table").func - if not hasattr(reset_term, "row_count"): - raise RuntimeError("ConveyorResetCurriculum requires ConveyorResetStateTable.") - self._reset_term = reset_term - self._attempts = torch.zeros(reset_term.row_count, dtype=torch.long, device=env.device) - self._progress_successes = torch.zeros_like(self._attempts) - self._final_successes = torch.zeros_like(self._attempts) - monitor_cfg = cfg.params.get("success_monitor") - if not isinstance(monitor_cfg, SuccessMonitorCfg): - raise TypeError("ConveyorResetCurriculum requires a SuccessMonitorCfg.") - self._progress_monitor = monitor_cfg.class_type( - monitor_cfg, - num_partitions=1, - partition_size=reset_term.row_count, - device=env.device, - ) - self._deployment_monitor = monitor_cfg.class_type( - monitor_cfg, num_partitions=1, partition_size=2 * CUBE_COUNT, device=env.device - ) - variant_counts = reset_variant_counts() - self._diagnostic_variant_rows = tuple( - ( - recipe.name.lower(), - variant_id, - (reset_term.recipe_ids == int(recipe)) & (reset_term.variant_ids == variant_id), - ) - for recipe in (ConveyorResetRecipe.PREGRASP, ConveyorResetRecipe.BELT) - for variant_id in range(variant_counts[int(recipe)]) - ) + self._reset_term = env.event_manager.get_term_cfg("reset_from_state_table").func + self.success_monitor = instantiate(cfg.params["success_monitor"], 1, self._reset_term.row_count, env.device) + self.sampler: Sampler = instantiate(cfg.params["sampling"], self.success_monitor.success_rate) def __call__( self, env: ManagerBasedRLEnv, - env_ids: Sequence[int], + env_ids: Sequence[int] | slice, success_monitor: SuccessMonitorCfg, + sampling: SamplerCfg, command_name: str = "transfer", - deployment_probability_initial: float = 0.35, - deployment_probability_final: float = 0.90, - deployment_progress_start: float = 0.45, - deployment_progress_end: float = 0.80, - deployment_coverage_target: float = 0.50, fixed_source_side_id: int | None = None, ) -> dict[str, torch.Tensor]: - """Update adaptive evidence, sample rows, and expose diagnostics.""" - del success_monitor - ids = torch.as_tensor(env_ids, dtype=torch.long, device=env.device).flatten() + """Record finished episodes and draw rows for only the resetting environments.""" + del success_monitor, sampling + ids = ( + torch.arange(env.num_envs, device=env.device)[env_ids] + if isinstance(env_ids, slice) + else torch.as_tensor(env_ids, dtype=torch.long, device=env.device).flatten() + ) command = env.command_manager.get_term(command_name) - batch_progress = torch.zeros((), dtype=torch.float32, device=env.device) - batch_success = torch.zeros((), dtype=torch.float32, device=env.device) completed = (command.command_counter[ids] > 0) & (env.episode_length_buf[ids] > 0) completed_ids = ids[completed] - if completed_ids.numel(): - progressed = command.progress_ever_success[completed_ids] - succeeded = command.ever_success[completed_ids] - rows = self._reset_term.row_ids[completed_ids] - self._progress_monitor.success_update(rows, progressed) - deployment = (self._reset_term.recipe_ids[rows] == int(ConveyorResetRecipe.BELT)) & ( - self._reset_term.variant_ids[rows] == BELT_DEPLOYMENT_VARIANT - ) - deployment_slots = 2 * self._reset_term.target_cube_ids[rows] + self._reset_term.source_side_ids[rows] - self._deployment_monitor.success_update(deployment_slots, succeeded, valid=deployment) - self._attempts.scatter_add_(0, rows, torch.ones_like(rows)) - self._progress_successes.scatter_add_(0, rows, progressed.long()) - self._final_successes.scatter_add_(0, rows, succeeded.long()) - batch_progress = progressed.float().mean() - batch_success = succeeded.float().mean() + succeeded = command.ever_success[completed_ids] + self.success_monitor.success_update(self._reset_term.row_ids[completed_ids], succeeded) - history_size = self._progress_monitor.success_size - history_success_count = self._progress_monitor.success_buf.sum(dim=1) - attempted_rows = history_size > 0 - row_coverage = attempted_rows.float().mean() - total_progress = history_success_count.sum() / history_size.sum().clamp_min(1) - deployment_success = self._deployment_monitor.success_buf.sum() / ( - self._deployment_monitor.success_size.sum().clamp_min(1) - ) - deployment_probability = deployment_probability_from_progress( - deployment_success, - row_coverage, - initial_probability=deployment_probability_initial, - final_probability=deployment_probability_final, - progress_start=deployment_progress_start, - progress_end=deployment_progress_end, - coverage_target=deployment_coverage_target, - ) - probabilities = reset_sampling_probabilities( - self._reset_term.recipe_ids, - self._reset_term.variant_ids, - self._reset_term.target_cube_ids, - self._reset_term.source_side_ids, - self._progress_monitor.target_weights(), - deployment_probability, - ) + probabilities = self.sampler.probabilities() if fixed_source_side_id is not None: if fixed_source_side_id not in (0, 1): raise ValueError("fixed_source_side_id must be 0 (left) or 1 (right).") - probabilities *= self._reset_term.source_side_ids == fixed_source_side_id - probabilities /= probabilities.sum() + probabilities.mul_(self._reset_term.source_side_ids == fixed_source_side_id) + probabilities.div_(probabilities.sum()) if ids.numel(): - self._reset_term.row_ids[ids] = torch.multinomial(probabilities, ids.numel(), replacement=True) + self._reset_term.row_ids[ids] = self.sampler.sample(probabilities, ids.numel()) - cumulative_progress = self._progress_successes.sum().float() / self._attempts.sum().clamp_min(1) - total_success = self._final_successes.sum().float() / self._attempts.sum().clamp_min(1) + history_size = self.success_monitor.success_size + history_successes = self.success_monitor.success_buf.sum(dim=1) entropy = -(probabilities * probabilities.clamp_min(torch.finfo(probabilities.dtype).tiny).log()).sum() - entropy /= math.log(probabilities.numel()) metrics: dict[str, torch.Tensor] = { - "batch_progress_rate": batch_progress, - "batch_success_rate": batch_success, - "batch_transfer_count": ( - command.transfer_counts[completed_ids].float().mean() - if completed_ids.numel() - else torch.zeros((), dtype=torch.float32, device=env.device) - ), - "batch_left_to_right_transfers": ( - command.direction_transfer_counts[completed_ids, 0].float().mean() - if completed_ids.numel() - else torch.zeros((), dtype=torch.float32, device=env.device) - ), - "batch_right_to_left_transfers": ( - command.direction_transfer_counts[completed_ids, 1].float().mean() - if completed_ids.numel() - else torch.zeros((), dtype=torch.float32, device=env.device) - ), - "deployment_probability": deployment_probability, - "deployment_transfer_success_rate": deployment_success, - "row_coverage": row_coverage, - "overall_progress_rate": total_progress, - "cumulative_progress_rate": cumulative_progress, - "overall_success_rate": total_success, - "sampling_entropy": entropy, + "batch_success_rate": succeeded.float().sum() / max(completed_ids.numel(), 1), + "batch_transfer_count": command.transfer_counts[completed_ids].float().sum() + / max(completed_ids.numel(), 1), + "batch_left_to_right_transfers": command.direction_transfer_counts[completed_ids, 0].float().sum() + / max(completed_ids.numel(), 1), + "batch_right_to_left_transfers": command.direction_transfer_counts[completed_ids, 1].float().sum() + / max(completed_ids.numel(), 1), + "row_coverage": (history_size > 0).float().mean(), + "overall_success_rate": history_successes.sum() / history_size.sum().clamp_min(1), + "sampling_entropy": entropy / math.log(probabilities.numel()), } for recipe in ConveyorResetRecipe: mask = self._reset_term.recipe_ids == int(recipe) - recipe_attempts = self._attempts[mask].sum() metrics[f"recipe_{recipe.name.lower()}_probability"] = probabilities[mask].sum() - recipe_history_size = history_size[mask].sum() - metrics[f"recipe_{recipe.name.lower()}_progress_rate"] = history_success_count[ - mask - ].sum() / recipe_history_size.clamp_min(1) - metrics[f"recipe_{recipe.name.lower()}_success_rate"] = self._final_successes[ + metrics[f"recipe_{recipe.name.lower()}_success_rate"] = history_successes[mask].sum() / history_size[ mask - ].sum().float() / recipe_attempts.clamp_min(1) + ].sum().clamp_min(1) for side_id, side_name in ((0, "left_to_right"), (1, "right_to_left")): mask = self._reset_term.source_side_ids == side_id - attempts = self._attempts[mask].sum() - direction_history_size = history_size[mask].sum() metrics[f"direction_{side_name}_probability"] = probabilities[mask].sum() - metrics[f"direction_{side_name}_progress_rate"] = history_success_count[ + metrics[f"direction_{side_name}_success_rate"] = history_successes[mask].sum() / history_size[ mask - ].sum() / direction_history_size.clamp_min(1) - metrics[f"direction_{side_name}_success_rate"] = self._final_successes[ - mask - ].sum().float() / attempts.clamp_min(1) - for recipe_name, variant_id, mask in self._diagnostic_variant_rows: - attempts = self._attempts[mask].sum() - variant_history_size = history_size[mask].sum() - prefix = f"recipe_{recipe_name}_variant_{variant_id}" - metrics[f"{prefix}_probability"] = probabilities[mask].sum() - metrics[f"{prefix}_progress_rate"] = history_success_count[mask].sum() / variant_history_size.clamp_min(1) - metrics[f"{prefix}_success_rate"] = self._final_successes[mask].sum().float() / attempts.clamp_min(1) + ].sum().clamp_min(1) return metrics - - def get_state(self) -> dict[str, torch.Tensor]: - """Return curriculum evidence for checkpointing.""" - history_success_count = self._progress_monitor.success_buf.sum(dim=1).long() - return { - "attempts": self._attempts.clone(), - "progress_successes": self._progress_successes.clone(), - "final_successes": self._final_successes.clone(), - "progress_history": self._progress_monitor.success_buf.bool().clone(), - "history_pointer": self._progress_monitor.success_pointer.clone(), - "history_size": self._progress_monitor.success_size.clone(), - "history_success_count": history_success_count, - "rolling_progress_rates": self._progress_monitor.success_rate.clone(), - "deployment_history": self._deployment_monitor.success_buf.clone(), - "deployment_history_pointer": self._deployment_monitor.success_pointer.clone(), - "deployment_history_size": self._deployment_monitor.success_size.clone(), - "deployment_success_rates": self._deployment_monitor.success_rate.clone(), - } - - def set_state(self, state: dict[str, torch.Tensor]) -> None: - """Restore curriculum evidence from a checkpoint.""" - targets = { - "attempts": self._attempts, - "progress_successes": self._progress_successes, - "final_successes": self._final_successes, - "progress_history": self._progress_monitor.success_buf, - "history_pointer": self._progress_monitor.success_pointer, - "history_size": self._progress_monitor.success_size, - "rolling_progress_rates": self._progress_monitor.success_rate, - "deployment_history": self._deployment_monitor.success_buf, - "deployment_history_pointer": self._deployment_monitor.success_pointer, - "deployment_history_size": self._deployment_monitor.success_size, - "deployment_success_rates": self._deployment_monitor.success_rate, - } - for name, target in targets.items(): - if name not in state or state[name].shape != target.shape: - raise ValueError(f"Conveyor curriculum checkpoint has invalid '{name}'.") - if "history_success_count" not in state or state["history_success_count"].shape != self._attempts.shape: - raise ValueError("Conveyor curriculum checkpoint has invalid 'history_success_count'.") - history_len = self._progress_monitor.success_buf.shape[1] - if bool(torch.any((state["history_pointer"] < 0) | (state["history_pointer"] >= history_len))): - raise ValueError("Conveyor curriculum checkpoint has invalid history pointers.") - if bool(torch.any((state["history_size"] < 0) | (state["history_size"] > history_len))): - raise ValueError("Conveyor curriculum checkpoint has invalid history sizes.") - if bool( - torch.any((state["deployment_history_pointer"] < 0) | (state["deployment_history_pointer"] >= history_len)) - ): - raise ValueError("Conveyor curriculum checkpoint has invalid deployment history pointers.") - if bool(torch.any((state["deployment_history_size"] < 0) | (state["deployment_history_size"] > history_len))): - raise ValueError("Conveyor curriculum checkpoint has invalid deployment history sizes.") - if bool( - torch.any((state["history_success_count"] < 0) | (state["history_success_count"] > state["history_size"])) - ): - raise ValueError("Conveyor curriculum checkpoint has invalid rolling success counts.") - for name, target in targets.items(): - target.copy_(state[name].to(device=target.device, dtype=target.dtype)) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py index ee7b50c88d23..8a676881d395 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Progress utilities and sparse completion rewards for conveyor transfer.""" +"""Sparse completion rewards and acquisition checks for conveyor transfer.""" from __future__ import annotations @@ -15,7 +15,7 @@ from .cube_state import cube_values from .kinematics import end_effector_pose -from .reset_events import CUBE_REST_Z, TRANSFER_X, side_inner_y +from .reset_events import CUBE_REST_Z if TYPE_CHECKING: from isaaclab.assets import Articulation @@ -24,57 +24,6 @@ from .commands import ConveyorTransferCommand -def transfer_potential( - cube_positions: torch.Tensor, - tool_positions: torch.Tensor, - finger_positions: torch.Tensor, - source_side_ids: torch.Tensor, -) -> torch.Tensor: - """Return a monotonic pickup-to-release shaping potential.""" - source_y = side_inner_y(source_side_ids) - target_y = side_inner_y(1 - source_side_ids) - target_position = torch.stack( - ( - torch.full_like(source_y, TRANSFER_X), - target_y, - torch.full_like(source_y, CUBE_REST_Z), - ), - dim=1, - ) - tool_distance = torch.linalg.vector_norm(tool_positions - cube_positions, dim=1) - reach = torch.exp(-12.0 * tool_distance) - gripper_closure = torch.clamp((0.04 - torch.amin(finger_positions, dim=1)) / 0.021, min=0.0, max=1.0) - grasp = gripper_closure * torch.exp(-25.0 * tool_distance) - lift = torch.clamp((cube_positions[:, 2] - CUBE_REST_Z) / 0.14, min=0.0, max=1.0) - direction_denominator = (target_y - source_y).clamp(min=-1.0, max=1.0) - crossing = (cube_positions[:, 1] - source_y) / direction_denominator - crossing = torch.clamp(crossing, min=0.0, max=1.0) - target_distance = torch.linalg.vector_norm(cube_positions - target_position, dim=1) - target = torch.exp(-14.0 * target_distance) - transport = crossing * torch.maximum(torch.clamp(2.0 * lift, max=1.0), target) - released = (torch.amin(finger_positions, dim=1) > 0.027).float() * target - return 0.5 * reach + 0.75 * grasp + 1.25 * lift + 2.0 * transport + 2.0 * target + released - - -def current_transfer_potential( - env: ManagerBasedRLEnv, - command_name: str = "transfer", - command: ConveyorTransferCommand | None = None, -) -> torch.Tensor: - """Gather current task state and evaluate the shaping potential.""" - if command is None: - command = env.command_manager.get_term(command_name) - positions = cube_values(env, "root_pos_w") - index = command.target_cube_ids.view(env.num_envs, 1, 1).expand(-1, 1, 3) - active_position = torch.gather(positions, 1, index).squeeze(1) - env.scene.env_origins - tool_position, _ = end_effector_pose(env) - tool_position = tool_position - env.scene.env_origins - robot: Articulation = env.scene["robot"] - finger_ids, _ = robot.find_joints("panda_finger_joint[1-2]", preserve_order=True) - finger_positions = robot.data.joint_pos.torch[:, finger_ids] - return transfer_potential(active_position, tool_position, finger_positions, command.source_side_ids) - - def transfer_success_reward( env: ManagerBasedRLEnv, command_name: str = "transfer", diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py index 184b8525c52c..84b3e185e51e 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py @@ -10,11 +10,6 @@ import gymnasium as gym import pytest import torch -from rsl_rl.runners import OnPolicyRunner - -from isaaclab.utils import to_dict - -from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper import isaaclab_tasks # noqa: F401 from isaaclab_tasks.contrib.conveyor_franka import mdp @@ -25,10 +20,6 @@ BELT_OUTER_STRAIGHT_Y, ) from isaaclab_tasks.contrib.conveyor_franka.mdp.commands import select_next_transfer_cube -from isaaclab_tasks.contrib.conveyor_franka.mdp.curriculums import ( - deployment_probability_from_progress, - reset_sampling_probabilities, -) from isaaclab_tasks.contrib.conveyor_franka.mdp.kinematics import end_effector_pose from isaaclab_tasks.contrib.conveyor_franka.mdp.reset_events import ( CUBE_COUNT, @@ -39,8 +30,7 @@ franka_tool_position, reset_variant_counts, ) -from isaaclab_tasks.contrib.conveyor_franka.mdp.rewards import transfer_potential -from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry, parse_env_cfg +from isaaclab_tasks.utils.parse_cfg import parse_env_cfg _BASE = "IsaacContrib-Conveyor-Racetrack-Transfer-v0" _SORTER = "IsaacContrib-Conveyor-Warehouse-Sorting-v0" @@ -360,48 +350,38 @@ def test_sorter_dispatch_inventory_and_selected_resets(): ] -def test_curriculum_checkpoint_restores_progress_and_deployment_outcomes(tmp_path): - """Actual RSL-RL saves preserve reset evidence; phase progress alone cannot advance deployment.""" +def test_success_guided_sampling_records_completed_selected_episodes(): + """Real resets credit the previous row once and practice intermediate-success starts more often.""" with closing(_environment(_BASE)) as env: - reset_cfg = env.event_manager.get_term_cfg("reset_from_state_table") - reset_cfg.params.update( - fixed_recipe=int(ConveyorResetRecipe.BELT), fixed_variant_id=mdp.BELT_DEPLOYMENT_VARIANT - ) env.reset() command = env.command_manager.get_term("transfer") - # Supply partial-progress outcomes at the curriculum's episode boundary. - env.episode_length_buf.fill_(5) - command.progress_ever_success.fill_(True) - env.reset() - log = env.extras["log"] - assert log["Curriculum/reset_sampling/overall_progress_rate"] == 1 - assert log["Curriculum/reset_sampling/deployment_transfer_success_rate"] == 0 - torch.testing.assert_close(log["Curriculum/reset_sampling/deployment_probability"], torch.tensor(0.35)) - env.episode_length_buf.fill_(5) - command.ever_success[0] = True - env.reset() - - agent = load_cfg_from_registry(_BASE, "rsl_rl_cfg_entry_point") - agent.device = "cpu" - agent.actor.hidden_dims = agent.critic.hidden_dims = [16] - runner = OnPolicyRunner(RslRlVecEnvWrapper(env), to_dict(agent), log_dir=str(tmp_path), device=agent.device) curriculum = env.curriculum_manager.cfg.reset_sampling.func - expected = curriculum.get_state() - checkpoint = tmp_path / "model.pt" - runner.save(str(checkpoint), infos={"purpose": "resume"}) - assert "conveyor_reset_curriculum" in torch.load(checkpoint, weights_only=False) - env.episode_length_buf.fill_(5) + monitor = curriculum.success_monitor + table = env.event_manager.get_term_cfg("reset_from_state_table").func + # Repeated startup resets have no episode to credit. env.reset() - assert curriculum.get_state()["attempts"].sum() > expected["attempts"].sum() - assert runner.load(str(checkpoint), map_location="cpu") == {"purpose": "resume"} - for name, value in expected.items(): - torch.testing.assert_close(curriculum.get_state()[name], value) - # A policy-only load must not replace a fine-tuning run's curriculum. - env.episode_length_buf.fill_(5) - env.reset() - attempts = curriculum.get_state()["attempts"].sum() - runner.load(str(checkpoint), load_cfg={"actor": True}, map_location="cpu") - assert curriculum.get_state()["attempts"].sum() == attempts + assert monitor.success_size.sum() == 0 + for succeeded in (True, False): + table.row_ids[:] = torch.tensor([0, 1]) + env.episode_length_buf[:] = 5 + command.ever_success[:] = torch.tensor([succeeded, True]) + untouched_row = table.row_ids[1].clone() + env.reset(env_ids=torch.tensor([0])) + assert table.row_ids[1] == untouched_row + assert monitor.success_size[1] == 0 + assert monitor.success_size.sum() == monitor.success_size[0] == 2 + assert monitor.success_rate[0] == 0.5 + probabilities = curriculum.sampler.probabilities() + assert torch.isfinite(probabilities).all() and (probabilities > 0).all() + torch.testing.assert_close(probabilities.sum(), torch.tensor(1.0)) + assert probabilities[0] > probabilities[1] + # A genuine unsuccessful episode is counted; a zero-step manual reset is not. + table.row_ids[:] = torch.tensor([0, 1]) + command.ever_success[1] = False + env.reset(env_ids=torch.tensor([1])) + assert monitor.success_size[1] == 1 and monitor.success_rate[1] == 0 + env.reset(env_ids=torch.tensor([1])) + assert monitor.success_size.sum() == 3 def test_reset_bank_is_complete_and_physically_calibrated(): @@ -447,22 +427,8 @@ def test_reset_bank_is_complete_and_physically_calibrated(): ) -def test_transfer_shaping_and_binary_policy_likelihoods(): - """Release improves progress; remote closing earns no grasp credit; sampled gripper likelihoods remain finite.""" - cubes = torch.tensor( - [[0.52, 0.27, 0.06], [0.52, 0.27, 0.20], [0.52, 0, 0.20], [0.52, -0.27, 0.105], [0.52, -0.27, 0.06]] - ) - tool = cubes.clone() - tool[-1, 2] += 0.1 - fingers = torch.full((5, 2), 0.019) - fingers[-1] = 0.04 - potential = transfer_potential(cubes, tool, fingers, torch.zeros(5, dtype=torch.long)) - assert (potential[1:] > potential[:-1]).all() - cubes = torch.tensor([[0.52, 0.27, 0.06]] * 2) - tool = torch.tensor([[0.52, 0.27, 0.07], [0.52, 0.27, 0.20]]) - credit = transfer_potential(cubes, tool, torch.full((2, 2), 0.019), torch.zeros(2, dtype=torch.long)) - credit -= transfer_potential(cubes, tool, torch.full((2, 2), 0.04), torch.zeros(2, dtype=torch.long)) - assert credit[0] > 0.5 and credit[1] < 0.03 +def test_binary_policy_likelihoods(): + """The mixed distribution samples exact binary gripper actions with finite likelihoods and KL.""" distribution = ConveyorGaussianBernoulliDistribution(output_dim=8) distribution.update(torch.zeros(4096, 8)) samples, previous = distribution.sample(), tuple(p.clone() for p in distribution.params) @@ -473,31 +439,6 @@ def test_transfer_shaping_and_binary_policy_likelihoods(): assert torch.isfinite(divergence).all() and (divergence >= 0).all() -def test_curriculum_reserves_deployment_mass_and_balances_commands(): - """Adaptive weights cannot starve any recipe/cube/direction, or the moving-belt deployment starts.""" - rows = build_reset_rows() - recipe, variant, cube, side = torch.tensor( - [[r.recipe, r.variant_id, r.target_cube_id, r.source_side_id] for r in rows] - ).T - deployment = (recipe == ConveyorResetRecipe.BELT) & ( - variant == reset_variant_counts()[ConveyorResetRecipe.BELT] - 1 - ) - place = torch.where((recipe == ConveyorResetRecipe.PLACE) & (cube == 0) & (side == 0))[0] - weights = torch.ones(len(rows)) - weights[place[:2]] = torch.tensor([0.2, 0.8]) - probabilities = reset_sampling_probabilities(recipe, variant, cube, side, weights, deployment_probability=0.35) - torch.testing.assert_close(probabilities.sum(), torch.tensor(1.0)) - assert probabilities[place[0]] < probabilities[place[1]] - for r, c, s in {(r.recipe, r.target_cube_id, r.source_side_id) for r in rows}: - mask = (recipe == r) & (cube == c) & (side == s) & ~deployment - torch.testing.assert_close(probabilities[mask].sum(), torch.tensor(0.65 / (len(ConveyorResetRecipe) * 8))) - for s in (0, 1): - torch.testing.assert_close(probabilities[deployment & (side == s)].sum(), torch.tensor(0.35 / 2)) - for progress, coverage, expected in ((0.30, 1.0, 0.35), (0.625, 0.50, 0.625), (0.90, 1.0, 0.90)): - actual = deployment_probability_from_progress(torch.tensor(progress), torch.tensor(coverage)) - torch.testing.assert_close(actual, torch.tensor(expected)) - - def test_next_transfer_cube_is_random_among_eligible_alternatives(): """Continuing commands use the one-hot target instead of a cyclic identity shortcut.""" count = 4096 From e1dda73810c5a1cb3d90513a4680bd408ef9fd0f Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 12:33:11 +0200 Subject: [PATCH 20/45] Reuse success-guided sampling for conveyor transfer --- ...nveyor-success-guided-sampling.changed.rst | 3 + .../contrib/conveyor_franka/README.md | 19 +- .../conveyor_franka/agents/conveyor_ppo.py | 48 --- .../conveyor_franka/agents/rsl_rl_ppo_cfg.py | 2 +- .../conveyor_franka_env_cfg.py | 33 +- .../contrib/conveyor_franka/mdp/__init__.pyi | 8 + .../contrib/conveyor_franka/mdp/commands.py | 57 +--- .../conveyor_franka/mdp/curriculums.py | 314 +++--------------- .../contrib/conveyor_franka/mdp/rewards.py | 55 +-- .../test/contrib/test_conveyor_franka_mdp.py | 119 ++----- 10 files changed, 108 insertions(+), 550 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/conveyor-success-guided-sampling.changed.rst delete mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py diff --git a/source/isaaclab_tasks/changelog.d/conveyor-success-guided-sampling.changed.rst b/source/isaaclab_tasks/changelog.d/conveyor-success-guided-sampling.changed.rst new file mode 100644 index 000000000000..420492605bb4 --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/conveyor-success-guided-sampling.changed.rst @@ -0,0 +1,3 @@ +* Reused the NIST success-guided sampler for conveyor reset rows, with completed transfers as the + success signal and standard PPO. Removed the task-specific progress curriculum and checkpoint + extension; sampling history now starts fresh when an environment is recreated. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md index 4d9d5451d739..6f93e7e7ef22 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md @@ -56,15 +56,16 @@ uv run isaaclab train --rl_library rsl_rl \ ### Curriculum and evaluation Racetrack training samples physically calibrated resets from release through moving-belt pickup. -The shared `SuccessMonitorCfg` tracks **phase progress** per reset row and weights intermediate -variants around a 50% progress target; this is not the full-transfer success rate. Sampling remains -balanced across phases, cube identities, and directions. Moving-belt starts receive at least 35% -of resets, increasing toward 90% only when completed transfers from those starts and reset-row -coverage improve. `deployment_transfer_success_rate` reports that separate rolling signal. - -New RSL-RL checkpoints save the curriculum evidence alongside standard PPO state and restore it -with `--checkpoint /path/to/model.pt`. Older checkpoints load normally with fresh evidence; -loading actor weights alone leaves the current curriculum intact. Playback disables the curriculum. +The shared `SuccessMonitor` records whether each episode completed a stable transfer from its +sampled reset row. NIST's reusable `Sampler` mixes uniform coverage with a Beta-shaped score +centered at 50% transfer success, giving partly learned rows more practice while keeping every +row eligible. Reset recipes generate the robot and cube states directly; the task needs no +cached physical-state bank or assembly-variant machinery. + +Training uses standard PPO with the existing arm-and-gripper action distribution. Checkpoints +save policy and optimizer state; sampling history starts fresh when the environment is recreated, +including when resuming training. Playback disables adaptive sampling. Evaluate from moving-belt +starts to measure pickup and transfer ability independently of the easier training resets. Evaluate from home on moving belts, with fixed reset settings and a chosen seed: diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py deleted file mode 100644 index e7626cf9ba72..000000000000 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/conveyor_ppo.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""RSL-RL checkpoint integration for the conveyor's adaptive reset evidence.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from rsl_rl.algorithms import PPO - -if TYPE_CHECKING: - from rsl_rl.env import VecEnv - from tensordict import TensorDict - - from ..mdp.curriculums import ConveyorResetCurriculum - - -class ConveyorPPO(PPO): - """Use standard PPO while saving and restoring the task's reset curriculum.""" - - _curriculum: ConveyorResetCurriculum | None - - @staticmethod - def construct_algorithm(obs: TensorDict, env: VecEnv, cfg: dict, device: str) -> ConveyorPPO: - """Bind the active curriculum through RSL-RL's algorithm construction hook.""" - algorithm = PPO.construct_algorithm(obs, env, cfg, device) - curriculum = env.unwrapped.curriculum_manager - algorithm._curriculum = ( - curriculum.cfg.reset_sampling.func if "reset_sampling" in curriculum.active_terms else None - ) - return algorithm - - def save(self) -> dict: - """Include reset evidence in the standard model and optimizer checkpoint.""" - state = super().save() - if self._curriculum is not None: - state["conveyor_reset_curriculum"] = self._curriculum.get_state() - return state - - def load(self, loaded_dict: dict, load_cfg: dict | None, strict: bool) -> bool: - """Restore reset evidence on resume; older policy-only checkpoints remain supported.""" - resume = super().load(loaded_dict, load_cfg, strict) - if resume and self._curriculum is not None and "conveyor_reset_curriculum" in loaded_dict: - self._curriculum.set_state(loaded_dict["conveyor_reset_curriculum"]) - return resume diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py index 6d0b0bc04d70..c63d3574e8a0 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/agents/rsl_rl_ppo_cfg.py @@ -165,7 +165,7 @@ class ConveyorFrankaPPORunnerCfg(RslRlOnPolicyRunnerCfg): obs_normalization=True, ) algorithm = RslRlPpoAlgorithmCfg( - class_name="isaaclab_tasks.contrib.conveyor_franka.agents.conveyor_ppo:ConveyorPPO", + class_name="PPO", value_loss_coef=1.0, use_clipped_value_loss=True, clip_param=0.2, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py index 8e8e6409ce02..ef7169240dad 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py @@ -137,12 +137,6 @@ class CommandsCfg: maximum_cube_speed=0.65, minimum_finger_position=0.027, minimum_tool_clearance=0.055, - minimum_progress_steps=3, - minimum_progress=0.35, - maximum_target_potential=5.0, - minimum_acquisition_lift=0.025, - maximum_acquisition_tool_distance=0.075, - maximum_acquisition_finger_position=0.030, ) @@ -257,31 +251,20 @@ class TerminationsCfg: @configclass class CurriculumCfg: - """Adaptive phase-balanced reset-state sampling.""" + """Practice reset states near the measured transfer-success frontier.""" reset_sampling = CurrTerm( func=mdp.ConveyorResetCurriculum, params={ "command_name": "transfer", - # Intermediate rows adapt around 50% phase progress, not completed transfers. - "success_monitor": mdp.SuccessMonitorCfg( - monitored_history_len=50, - target_success_rate=0.5, - kappa=1.0, - temperature=1.0, + "success_monitor": mdp.SuccessMonitorCfg(monitored_history_len=50), + "sampling": mdp.SamplerCfg( + strategies=[ + mdp.BetaSamplingStrategyCfg(target=0.5, kappa=2.0), + mdp.UniformSamplingStrategyCfg(weight=0.05), + ], + eps=1.0e-4, ), - # Keep a deployment-facing stream while the remaining starts - # adapt around the rolling pickup-to-placement frontier. Deployment - # mass increases only after actual transfers from moving-belt starts. Every - # recipe, cube identity, and direction retains equal total mass. - "deployment_probability_initial": 0.35, - "deployment_probability_final": 0.90, - "deployment_progress_start": 0.45, - "deployment_progress_end": 0.80, - "deployment_coverage_target": 0.50, - # Optional staged-training control. None keeps the deployable - # bidirectional task; a side id can focus the same adaptive reset - # distribution on one weak direction without changing reset rows. "fixed_source_side_id": None, }, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi index 5bd90e830c86..6cb67792f9b5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi @@ -5,6 +5,7 @@ __all__ = [ "BELT_DEPLOYMENT_VARIANT", + "BetaSamplingStrategyCfg", "ConveyorRelativeJointPositionAction", "ConveyorRelativeJointPositionActionCfg", "ConveyorResetCurriculum", @@ -14,7 +15,9 @@ __all__ = [ "ConveyorTransferCommandCfg", "ResetBufferedGripperAction", "ResetBufferedGripperActionCfg", + "SamplerCfg", "SuccessMonitorCfg", + "UniformSamplingStrategyCfg", "action_term_l2", "active_transfer_features", "build_reset_rows", @@ -76,4 +79,9 @@ from .terminations import ( transfer_sequence_time_out, ) from isaaclab.envs.mdp import * +from isaaclab_tasks.contrib.nist.utils.sampling import ( + BetaSamplingStrategyCfg, + SamplerCfg, + UniformSamplingStrategyCfg, +) from isaaclab_tasks.utils.success_monitor import SuccessMonitorCfg diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py index 8185334767e1..549fb7950462 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py @@ -18,8 +18,7 @@ from ..conveyor_geometry import BELT_CENTER_X, BELT_HALF_STRAIGHT from .cube_state import cube_values from .kinematics import end_effector_pose -from .reset_events import CUBE_COUNT, ConveyorResetRecipe, select_next_transfer_cube, side_inner_y -from .rewards import current_transfer_potential, physical_cube_acquisition_mask +from .reset_events import CUBE_COUNT, select_next_transfer_cube, side_inner_y if TYPE_CHECKING: from isaaclab.assets import Articulation @@ -78,8 +77,6 @@ def __init__(self, cfg: ConveyorTransferCommandCfg, env: ManagerBasedRLEnv) -> N self.new_success = torch.zeros_like(self.is_success) self.pending_success = torch.zeros_like(self.is_success) self.ever_success = torch.zeros_like(self.is_success) - self.progress_ever_success = torch.zeros_like(self.is_success) - self._target_potential = torch.zeros(self.num_envs, dtype=torch.float32, device=self.device) self._last_evaluation_steps = torch.full_like(self.target_cube_ids, -1) self._resampling_from_reset = False @@ -90,21 +87,13 @@ def __init__(self, cfg: ConveyorTransferCommandCfg, env: ManagerBasedRLEnv) -> N @staticmethod def _validate_cfg(cfg: ConveyorTransferCommandCfg) -> None: - """Validate success and reset-progress thresholds.""" + """Validate stable transfer-completion thresholds.""" if cfg.minimum_subgoal_steps < 0 or cfg.hold_steps < 1: raise ValueError("minimum_subgoal_steps must be non-negative and hold_steps must be positive.") if cfg.lateral_tolerance <= 0.0 or cfg.maximum_cube_speed <= 0.0: raise ValueError("Conveyor placement tolerances and speed limits must be positive.") if cfg.minimum_finger_position <= 0.0 or cfg.minimum_tool_clearance <= 0.0: raise ValueError("Conveyor release thresholds must be positive.") - if cfg.minimum_progress_steps < 0 or cfg.minimum_progress <= 0.0 or cfg.maximum_target_potential <= 0.0: - raise ValueError("Conveyor reset-progress thresholds are invalid.") - if ( - cfg.minimum_acquisition_lift <= 0.0 - or cfg.maximum_acquisition_tool_distance <= 0.0 - or cfg.maximum_acquisition_finger_position <= 0.0 - ): - raise ValueError("Conveyor acquisition thresholds must be positive.") @property def command(self) -> torch.Tensor: @@ -133,17 +122,12 @@ def reset(self, env_ids: Sequence[int] | slice | None = None) -> dict[str, float finally: self._resampling_from_reset = False - initial_potential = current_transfer_potential(self._env, command=self) - self._target_potential[ids] = torch.clamp_max( - initial_potential[ids] + self.cfg.minimum_progress, - self.cfg.maximum_target_potential, - ) self._last_evaluation_steps[ids] = -1 self._env.extras.setdefault("log", {})["Metrics/success_rate"] = extras.pop("success_rate") return extras def evaluate(self) -> None: - """Evaluate stable completion and reset-learning progress once per policy step.""" + """Evaluate stable transfer completion once per policy step.""" evaluation_steps = self._env.episode_length_buf evaluate_mask = (self.command_counter > 0) & (self._last_evaluation_steps != evaluation_steps) if not bool(torch.any(evaluate_mask)): @@ -185,22 +169,6 @@ def evaluate(self) -> None: self.transfer_counts[success_ids] += 1 self.direction_transfer_counts[success_ids, source_sides] += 1 - potential = current_transfer_potential(self._env, command=self) - progressed = (potential >= self._target_potential) & (evaluation_steps >= self.cfg.minimum_progress_steps) - acquisition_recipe = ( - (self.recipe_ids == int(ConveyorResetRecipe.GRASP)) - | (self.recipe_ids == int(ConveyorResetRecipe.PREGRASP)) - | (self.recipe_ids == int(ConveyorResetRecipe.BELT)) - ) - physically_acquired = physical_cube_acquisition_mask( - self._env, - command=self, - minimum_lift=self.cfg.minimum_acquisition_lift, - maximum_tool_distance=self.cfg.maximum_acquisition_tool_distance, - maximum_finger_position=self.cfg.maximum_acquisition_finger_position, - ) - progressed &= ~acquisition_recipe | physically_acquired - self.progress_ever_success |= evaluate_mask & progressed self._last_evaluation_steps[evaluate_mask] = evaluation_steps[evaluate_mask] self._env.extras["successes"] = self.ever_success.clone() @@ -234,7 +202,6 @@ def _resample_command(self, env_ids: Sequence[int]) -> None: self.transfer_counts[ids] = 0 self.direction_transfer_counts[ids] = 0 self.ever_success[ids] = False - self.progress_ever_success[ids] = False self._assign_goal(ids, target_cube_ids, source_side_ids) self.held_cube_ids[ids] = torch.where(held_rows, target_cube_ids, -1) self.subgoal_start_steps[ids] = 0 @@ -325,21 +292,3 @@ class ConveyorTransferCommandCfg(CommandTermCfg): minimum_tool_clearance: float = 0.055 """Minimum tool-to-cube distance for the cube to count as released [m].""" - - minimum_progress_steps: int = 3 - """Minimum policy steps before reset-learning progress can be credited.""" - - minimum_progress: float = 0.35 - """Required increase in the dimensionless transfer potential after reset.""" - - maximum_target_potential: float = 5.0 - """Upper bound for the dimensionless reset-progress target.""" - - minimum_acquisition_lift: float = 0.025 - """Minimum cube lift used to validate physical acquisition [m].""" - - maximum_acquisition_tool_distance: float = 0.075 - """Maximum tool-to-cube distance used to validate physical acquisition [m].""" - - maximum_acquisition_finger_position: float = 0.030 - """Maximum finger position used to validate physical acquisition [m].""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py index e85da72345b6..a01340f1b709 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Adaptive reset-state curriculum for conveyor transfer.""" +"""Success-guided reset sampling for conveyor transfer.""" from __future__ import annotations @@ -14,314 +14,86 @@ import torch from isaaclab.managers import CurriculumTermCfg, ManagerTermBase +from isaaclab.utils import instantiate +from isaaclab_tasks.contrib.nist.utils.sampling import Sampler, SamplerCfg from isaaclab_tasks.utils.success_monitor import SuccessMonitorCfg -from .reset_events import BELT_DEPLOYMENT_VARIANT, CUBE_COUNT, ConveyorResetRecipe, reset_variant_counts +from .reset_events import ConveyorResetRecipe if TYPE_CHECKING: from isaaclab.envs import ManagerBasedRLEnv -def reset_sampling_probabilities( - recipe_ids: torch.Tensor, - variant_ids: torch.Tensor, - target_cube_ids: torch.Tensor, - source_side_ids: torch.Tensor, - target_weights: torch.Tensor, - deployment_probability: float | torch.Tensor, -) -> torch.Tensor: - """Balance target-rate weights and mix in guaranteed deployment starts.""" - if not ( - recipe_ids.shape == variant_ids.shape == target_cube_ids.shape == source_side_ids.shape == target_weights.shape - ): - raise ValueError("Reset row metadata and target weights must have matching shapes.") - deployment_probability = torch.as_tensor( - deployment_probability, - dtype=torch.float32, - device=target_weights.device, - ) - if deployment_probability.numel() != 1: - raise ValueError("deployment_probability must be a scalar.") - deployment_probability = deployment_probability.reshape(()) - if bool((deployment_probability <= 0.0) | (deployment_probability >= 1.0)): - raise ValueError("deployment_probability must lie strictly between zero and one.") - if not bool(torch.all(torch.isfinite(target_weights))) or bool(torch.any(target_weights < 0.0)): - raise ValueError("target_weights must be finite and non-negative.") - - deployment_rows = (recipe_ids == int(ConveyorResetRecipe.BELT)) & (variant_ids == BELT_DEPLOYMENT_VARIANT) - if not bool(torch.any(deployment_rows)) or bool(torch.all(deployment_rows)): - raise ValueError("Reset table must contain deployment and intermediate rows.") - - adaptive = target_weights.clone() - adaptive[deployment_rows] = 0.0 - - # Mirror Franka Stack's recipe/layout balancing: success in one physical - # phase must not starve another cube identity or transfer direction. - command_ids = 2 * target_cube_ids + source_side_ids - command_count = 2 * CUBE_COUNT - if bool(torch.any((command_ids < 0) | (command_ids >= command_count))): - raise ValueError("Reset table contains an invalid cube or source-side id.") - stratum_ids = recipe_ids * command_count + command_ids - stratum_count = len(ConveyorResetRecipe) * command_count - stratum_mass = torch.zeros(stratum_count, dtype=adaptive.dtype, device=adaptive.device) - stratum_mass.scatter_add_(0, stratum_ids, adaptive) - if bool(torch.any(stratum_mass <= 0.0)): - raise ValueError("Every recipe, cube, and source-side stratum must have intermediate reset rows.") - adaptive /= stratum_mass[stratum_ids] - adaptive[deployment_rows] = 0.0 - adaptive *= (1.0 - deployment_probability) / adaptive.sum() - deployment = deployment_rows.to(dtype=adaptive.dtype) - deployment *= deployment_probability / deployment.sum() - return adaptive + deployment - - -def deployment_probability_from_progress( - progress_rate: torch.Tensor, - row_coverage: torch.Tensor, - initial_probability: float = 0.35, - final_probability: float = 0.90, - progress_start: float = 0.45, - progress_end: float = 0.80, - coverage_target: float = 0.50, -) -> torch.Tensor: - """Interpolate deployment sampling from rolling completed-transfer success and row coverage.""" - if progress_rate.numel() != 1 or row_coverage.numel() != 1: - raise ValueError("progress_rate and row_coverage must be scalar tensors.") - if not 0.0 < initial_probability <= final_probability < 1.0: - raise ValueError("Deployment probabilities must be ordered strictly inside (0, 1).") - if not 0.0 <= progress_start < progress_end <= 1.0: - raise ValueError("Deployment progress thresholds must be ordered inside [0, 1].") - if not 0.0 < coverage_target <= 1.0: - raise ValueError("coverage_target must lie inside (0, 1].") - if bool((progress_rate < 0.0) | (progress_rate > 1.0) | (row_coverage < 0.0) | (row_coverage > 1.0)): - raise ValueError("Rolling progress and row coverage must lie inside [0, 1].") - - progress_fraction = ((progress_rate - progress_start) / (progress_end - progress_start)).clamp(0.0, 1.0) - coverage_fraction = (row_coverage / coverage_target).clamp(0.0, 1.0) - readiness = progress_fraction * coverage_fraction - readiness = readiness.square() * (3.0 - 2.0 * readiness) - return initial_probability + (final_probability - initial_probability) * readiness - - class ConveyorResetCurriculum(ManagerTermBase): - """Record row outcomes and sample the next physical reset states.""" + """Sample reset rows using the shared NIST sampler and completed-transfer outcomes. + + Reset geometry and command metadata belong to the reset term. Only recent success + evidence lives here; standard PPO checkpoints start a fresh sampling history on resume. + """ def __init__(self, cfg: CurriculumTermCfg, env: ManagerBasedRLEnv): super().__init__(cfg, env) - reset_term = env.event_manager.get_term_cfg("reset_from_state_table").func - if not hasattr(reset_term, "row_count"): - raise RuntimeError("ConveyorResetCurriculum requires ConveyorResetStateTable.") - self._reset_term = reset_term - self._attempts = torch.zeros(reset_term.row_count, dtype=torch.long, device=env.device) - self._progress_successes = torch.zeros_like(self._attempts) - self._final_successes = torch.zeros_like(self._attempts) - monitor_cfg = cfg.params.get("success_monitor") - if not isinstance(monitor_cfg, SuccessMonitorCfg): - raise TypeError("ConveyorResetCurriculum requires a SuccessMonitorCfg.") - self._progress_monitor = monitor_cfg.class_type( - monitor_cfg, - num_partitions=1, - partition_size=reset_term.row_count, - device=env.device, - ) - self._deployment_monitor = monitor_cfg.class_type( - monitor_cfg, num_partitions=1, partition_size=2 * CUBE_COUNT, device=env.device - ) - variant_counts = reset_variant_counts() - self._diagnostic_variant_rows = tuple( - ( - recipe.name.lower(), - variant_id, - (reset_term.recipe_ids == int(recipe)) & (reset_term.variant_ids == variant_id), - ) - for recipe in (ConveyorResetRecipe.PREGRASP, ConveyorResetRecipe.BELT) - for variant_id in range(variant_counts[int(recipe)]) - ) + self._reset_term = env.event_manager.get_term_cfg("reset_from_state_table").func + self.success_monitor = instantiate(cfg.params["success_monitor"], 1, self._reset_term.row_count, env.device) + self.sampler: Sampler = instantiate(cfg.params["sampling"], self.success_monitor.success_rate) def __call__( self, env: ManagerBasedRLEnv, - env_ids: Sequence[int], + env_ids: Sequence[int] | slice, success_monitor: SuccessMonitorCfg, + sampling: SamplerCfg, command_name: str = "transfer", - deployment_probability_initial: float = 0.35, - deployment_probability_final: float = 0.90, - deployment_progress_start: float = 0.45, - deployment_progress_end: float = 0.80, - deployment_coverage_target: float = 0.50, fixed_source_side_id: int | None = None, ) -> dict[str, torch.Tensor]: - """Update adaptive evidence, sample rows, and expose diagnostics.""" - del success_monitor - ids = torch.as_tensor(env_ids, dtype=torch.long, device=env.device).flatten() + """Record finished episodes and draw rows for only the resetting environments.""" + del success_monitor, sampling + ids = ( + torch.arange(env.num_envs, device=env.device)[env_ids] + if isinstance(env_ids, slice) + else torch.as_tensor(env_ids, dtype=torch.long, device=env.device).flatten() + ) command = env.command_manager.get_term(command_name) - batch_progress = torch.zeros((), dtype=torch.float32, device=env.device) - batch_success = torch.zeros((), dtype=torch.float32, device=env.device) completed = (command.command_counter[ids] > 0) & (env.episode_length_buf[ids] > 0) completed_ids = ids[completed] - if completed_ids.numel(): - progressed = command.progress_ever_success[completed_ids] - succeeded = command.ever_success[completed_ids] - rows = self._reset_term.row_ids[completed_ids] - self._progress_monitor.success_update(rows, progressed) - deployment = (self._reset_term.recipe_ids[rows] == int(ConveyorResetRecipe.BELT)) & ( - self._reset_term.variant_ids[rows] == BELT_DEPLOYMENT_VARIANT - ) - deployment_slots = 2 * self._reset_term.target_cube_ids[rows] + self._reset_term.source_side_ids[rows] - self._deployment_monitor.success_update(deployment_slots, succeeded, valid=deployment) - self._attempts.scatter_add_(0, rows, torch.ones_like(rows)) - self._progress_successes.scatter_add_(0, rows, progressed.long()) - self._final_successes.scatter_add_(0, rows, succeeded.long()) - batch_progress = progressed.float().mean() - batch_success = succeeded.float().mean() + succeeded = command.ever_success[completed_ids] + self.success_monitor.success_update(self._reset_term.row_ids[completed_ids], succeeded) - history_size = self._progress_monitor.success_size - history_success_count = self._progress_monitor.success_buf.sum(dim=1) - attempted_rows = history_size > 0 - row_coverage = attempted_rows.float().mean() - total_progress = history_success_count.sum() / history_size.sum().clamp_min(1) - deployment_success = self._deployment_monitor.success_buf.sum() / ( - self._deployment_monitor.success_size.sum().clamp_min(1) - ) - deployment_probability = deployment_probability_from_progress( - deployment_success, - row_coverage, - initial_probability=deployment_probability_initial, - final_probability=deployment_probability_final, - progress_start=deployment_progress_start, - progress_end=deployment_progress_end, - coverage_target=deployment_coverage_target, - ) - probabilities = reset_sampling_probabilities( - self._reset_term.recipe_ids, - self._reset_term.variant_ids, - self._reset_term.target_cube_ids, - self._reset_term.source_side_ids, - self._progress_monitor.target_weights(), - deployment_probability, - ) + probabilities = self.sampler.probabilities() if fixed_source_side_id is not None: if fixed_source_side_id not in (0, 1): raise ValueError("fixed_source_side_id must be 0 (left) or 1 (right).") - probabilities *= self._reset_term.source_side_ids == fixed_source_side_id - probabilities /= probabilities.sum() + probabilities.mul_(self._reset_term.source_side_ids == fixed_source_side_id) + probabilities.div_(probabilities.sum()) if ids.numel(): - self._reset_term.row_ids[ids] = torch.multinomial(probabilities, ids.numel(), replacement=True) + self._reset_term.row_ids[ids] = self.sampler.sample(probabilities, ids.numel()) - cumulative_progress = self._progress_successes.sum().float() / self._attempts.sum().clamp_min(1) - total_success = self._final_successes.sum().float() / self._attempts.sum().clamp_min(1) + history_size = self.success_monitor.success_size + history_successes = self.success_monitor.success_buf.sum(dim=1) entropy = -(probabilities * probabilities.clamp_min(torch.finfo(probabilities.dtype).tiny).log()).sum() - entropy /= math.log(probabilities.numel()) metrics: dict[str, torch.Tensor] = { - "batch_progress_rate": batch_progress, - "batch_success_rate": batch_success, - "batch_transfer_count": ( - command.transfer_counts[completed_ids].float().mean() - if completed_ids.numel() - else torch.zeros((), dtype=torch.float32, device=env.device) - ), - "batch_left_to_right_transfers": ( - command.direction_transfer_counts[completed_ids, 0].float().mean() - if completed_ids.numel() - else torch.zeros((), dtype=torch.float32, device=env.device) - ), - "batch_right_to_left_transfers": ( - command.direction_transfer_counts[completed_ids, 1].float().mean() - if completed_ids.numel() - else torch.zeros((), dtype=torch.float32, device=env.device) - ), - "deployment_probability": deployment_probability, - "deployment_transfer_success_rate": deployment_success, - "row_coverage": row_coverage, - "overall_progress_rate": total_progress, - "cumulative_progress_rate": cumulative_progress, - "overall_success_rate": total_success, - "sampling_entropy": entropy, + "batch_success_rate": succeeded.float().sum() / max(completed_ids.numel(), 1), + "batch_transfer_count": command.transfer_counts[completed_ids].float().sum() + / max(completed_ids.numel(), 1), + "batch_left_to_right_transfers": command.direction_transfer_counts[completed_ids, 0].float().sum() + / max(completed_ids.numel(), 1), + "batch_right_to_left_transfers": command.direction_transfer_counts[completed_ids, 1].float().sum() + / max(completed_ids.numel(), 1), + "row_coverage": (history_size > 0).float().mean(), + "overall_success_rate": history_successes.sum() / history_size.sum().clamp_min(1), + "sampling_entropy": entropy / math.log(probabilities.numel()), } for recipe in ConveyorResetRecipe: mask = self._reset_term.recipe_ids == int(recipe) - recipe_attempts = self._attempts[mask].sum() metrics[f"recipe_{recipe.name.lower()}_probability"] = probabilities[mask].sum() - recipe_history_size = history_size[mask].sum() - metrics[f"recipe_{recipe.name.lower()}_progress_rate"] = history_success_count[ - mask - ].sum() / recipe_history_size.clamp_min(1) - metrics[f"recipe_{recipe.name.lower()}_success_rate"] = self._final_successes[ + metrics[f"recipe_{recipe.name.lower()}_success_rate"] = history_successes[mask].sum() / history_size[ mask - ].sum().float() / recipe_attempts.clamp_min(1) + ].sum().clamp_min(1) for side_id, side_name in ((0, "left_to_right"), (1, "right_to_left")): mask = self._reset_term.source_side_ids == side_id - attempts = self._attempts[mask].sum() - direction_history_size = history_size[mask].sum() metrics[f"direction_{side_name}_probability"] = probabilities[mask].sum() - metrics[f"direction_{side_name}_progress_rate"] = history_success_count[ + metrics[f"direction_{side_name}_success_rate"] = history_successes[mask].sum() / history_size[ mask - ].sum() / direction_history_size.clamp_min(1) - metrics[f"direction_{side_name}_success_rate"] = self._final_successes[ - mask - ].sum().float() / attempts.clamp_min(1) - for recipe_name, variant_id, mask in self._diagnostic_variant_rows: - attempts = self._attempts[mask].sum() - variant_history_size = history_size[mask].sum() - prefix = f"recipe_{recipe_name}_variant_{variant_id}" - metrics[f"{prefix}_probability"] = probabilities[mask].sum() - metrics[f"{prefix}_progress_rate"] = history_success_count[mask].sum() / variant_history_size.clamp_min(1) - metrics[f"{prefix}_success_rate"] = self._final_successes[mask].sum().float() / attempts.clamp_min(1) + ].sum().clamp_min(1) return metrics - - def get_state(self) -> dict[str, torch.Tensor]: - """Return curriculum evidence for checkpointing.""" - history_success_count = self._progress_monitor.success_buf.sum(dim=1).long() - return { - "attempts": self._attempts.clone(), - "progress_successes": self._progress_successes.clone(), - "final_successes": self._final_successes.clone(), - "progress_history": self._progress_monitor.success_buf.bool().clone(), - "history_pointer": self._progress_monitor.success_pointer.clone(), - "history_size": self._progress_monitor.success_size.clone(), - "history_success_count": history_success_count, - "rolling_progress_rates": self._progress_monitor.success_rate.clone(), - "deployment_history": self._deployment_monitor.success_buf.clone(), - "deployment_history_pointer": self._deployment_monitor.success_pointer.clone(), - "deployment_history_size": self._deployment_monitor.success_size.clone(), - "deployment_success_rates": self._deployment_monitor.success_rate.clone(), - } - - def set_state(self, state: dict[str, torch.Tensor]) -> None: - """Restore curriculum evidence from a checkpoint.""" - targets = { - "attempts": self._attempts, - "progress_successes": self._progress_successes, - "final_successes": self._final_successes, - "progress_history": self._progress_monitor.success_buf, - "history_pointer": self._progress_monitor.success_pointer, - "history_size": self._progress_monitor.success_size, - "rolling_progress_rates": self._progress_monitor.success_rate, - "deployment_history": self._deployment_monitor.success_buf, - "deployment_history_pointer": self._deployment_monitor.success_pointer, - "deployment_history_size": self._deployment_monitor.success_size, - "deployment_success_rates": self._deployment_monitor.success_rate, - } - for name, target in targets.items(): - if name not in state or state[name].shape != target.shape: - raise ValueError(f"Conveyor curriculum checkpoint has invalid '{name}'.") - if "history_success_count" not in state or state["history_success_count"].shape != self._attempts.shape: - raise ValueError("Conveyor curriculum checkpoint has invalid 'history_success_count'.") - history_len = self._progress_monitor.success_buf.shape[1] - if bool(torch.any((state["history_pointer"] < 0) | (state["history_pointer"] >= history_len))): - raise ValueError("Conveyor curriculum checkpoint has invalid history pointers.") - if bool(torch.any((state["history_size"] < 0) | (state["history_size"] > history_len))): - raise ValueError("Conveyor curriculum checkpoint has invalid history sizes.") - if bool( - torch.any((state["deployment_history_pointer"] < 0) | (state["deployment_history_pointer"] >= history_len)) - ): - raise ValueError("Conveyor curriculum checkpoint has invalid deployment history pointers.") - if bool(torch.any((state["deployment_history_size"] < 0) | (state["deployment_history_size"] > history_len))): - raise ValueError("Conveyor curriculum checkpoint has invalid deployment history sizes.") - if bool( - torch.any((state["history_success_count"] < 0) | (state["history_success_count"] > state["history_size"])) - ): - raise ValueError("Conveyor curriculum checkpoint has invalid rolling success counts.") - for name, target in targets.items(): - target.copy_(state[name].to(device=target.device, dtype=target.dtype)) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py index ee7b50c88d23..8a676881d395 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Progress utilities and sparse completion rewards for conveyor transfer.""" +"""Sparse completion rewards and acquisition checks for conveyor transfer.""" from __future__ import annotations @@ -15,7 +15,7 @@ from .cube_state import cube_values from .kinematics import end_effector_pose -from .reset_events import CUBE_REST_Z, TRANSFER_X, side_inner_y +from .reset_events import CUBE_REST_Z if TYPE_CHECKING: from isaaclab.assets import Articulation @@ -24,57 +24,6 @@ from .commands import ConveyorTransferCommand -def transfer_potential( - cube_positions: torch.Tensor, - tool_positions: torch.Tensor, - finger_positions: torch.Tensor, - source_side_ids: torch.Tensor, -) -> torch.Tensor: - """Return a monotonic pickup-to-release shaping potential.""" - source_y = side_inner_y(source_side_ids) - target_y = side_inner_y(1 - source_side_ids) - target_position = torch.stack( - ( - torch.full_like(source_y, TRANSFER_X), - target_y, - torch.full_like(source_y, CUBE_REST_Z), - ), - dim=1, - ) - tool_distance = torch.linalg.vector_norm(tool_positions - cube_positions, dim=1) - reach = torch.exp(-12.0 * tool_distance) - gripper_closure = torch.clamp((0.04 - torch.amin(finger_positions, dim=1)) / 0.021, min=0.0, max=1.0) - grasp = gripper_closure * torch.exp(-25.0 * tool_distance) - lift = torch.clamp((cube_positions[:, 2] - CUBE_REST_Z) / 0.14, min=0.0, max=1.0) - direction_denominator = (target_y - source_y).clamp(min=-1.0, max=1.0) - crossing = (cube_positions[:, 1] - source_y) / direction_denominator - crossing = torch.clamp(crossing, min=0.0, max=1.0) - target_distance = torch.linalg.vector_norm(cube_positions - target_position, dim=1) - target = torch.exp(-14.0 * target_distance) - transport = crossing * torch.maximum(torch.clamp(2.0 * lift, max=1.0), target) - released = (torch.amin(finger_positions, dim=1) > 0.027).float() * target - return 0.5 * reach + 0.75 * grasp + 1.25 * lift + 2.0 * transport + 2.0 * target + released - - -def current_transfer_potential( - env: ManagerBasedRLEnv, - command_name: str = "transfer", - command: ConveyorTransferCommand | None = None, -) -> torch.Tensor: - """Gather current task state and evaluate the shaping potential.""" - if command is None: - command = env.command_manager.get_term(command_name) - positions = cube_values(env, "root_pos_w") - index = command.target_cube_ids.view(env.num_envs, 1, 1).expand(-1, 1, 3) - active_position = torch.gather(positions, 1, index).squeeze(1) - env.scene.env_origins - tool_position, _ = end_effector_pose(env) - tool_position = tool_position - env.scene.env_origins - robot: Articulation = env.scene["robot"] - finger_ids, _ = robot.find_joints("panda_finger_joint[1-2]", preserve_order=True) - finger_positions = robot.data.joint_pos.torch[:, finger_ids] - return transfer_potential(active_position, tool_position, finger_positions, command.source_side_ids) - - def transfer_success_reward( env: ManagerBasedRLEnv, command_name: str = "transfer", diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py index 9cdef6827970..0cdb30758665 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py @@ -10,11 +10,6 @@ import gymnasium as gym import pytest import torch -from rsl_rl.runners import OnPolicyRunner - -from isaaclab.utils import to_dict - -from isaaclab_rl.rsl_rl import RslRlVecEnvWrapper import isaaclab_tasks # noqa: F401 from isaaclab_tasks.contrib.conveyor_franka import mdp @@ -25,10 +20,6 @@ BELT_OUTER_STRAIGHT_Y, ) from isaaclab_tasks.contrib.conveyor_franka.mdp.commands import select_next_transfer_cube -from isaaclab_tasks.contrib.conveyor_franka.mdp.curriculums import ( - deployment_probability_from_progress, - reset_sampling_probabilities, -) from isaaclab_tasks.contrib.conveyor_franka.mdp.reset_events import ( CUBE_COUNT, ConveyorResetRecipe, @@ -38,8 +29,7 @@ franka_tool_position, reset_variant_counts, ) -from isaaclab_tasks.contrib.conveyor_franka.mdp.rewards import transfer_potential -from isaaclab_tasks.utils.parse_cfg import load_cfg_from_registry, parse_env_cfg +from isaaclab_tasks.utils.parse_cfg import parse_env_cfg _BASE = "IsaacContrib-Conveyor-Racetrack-Transfer-v0" @@ -166,48 +156,38 @@ def test_checkpoint_interface_and_invalid_action_recovery(): cfg.validate() -def test_curriculum_checkpoint_restores_progress_and_deployment_outcomes(tmp_path): - """Actual RSL-RL saves preserve reset evidence; phase progress alone cannot advance deployment.""" +def test_success_guided_sampling_records_completed_selected_episodes(): + """Real resets credit the previous row once and practice intermediate-success starts more often.""" with closing(_environment(_BASE)) as env: - reset_cfg = env.event_manager.get_term_cfg("reset_from_state_table") - reset_cfg.params.update( - fixed_recipe=int(ConveyorResetRecipe.BELT), fixed_variant_id=mdp.BELT_DEPLOYMENT_VARIANT - ) env.reset() command = env.command_manager.get_term("transfer") - # Supply partial-progress outcomes at the curriculum's episode boundary. - env.episode_length_buf.fill_(5) - command.progress_ever_success.fill_(True) - env.reset() - log = env.extras["log"] - assert log["Curriculum/reset_sampling/overall_progress_rate"] == 1 - assert log["Curriculum/reset_sampling/deployment_transfer_success_rate"] == 0 - torch.testing.assert_close(log["Curriculum/reset_sampling/deployment_probability"], torch.tensor(0.35)) - env.episode_length_buf.fill_(5) - command.ever_success[0] = True - env.reset() - - agent = load_cfg_from_registry(_BASE, "rsl_rl_cfg_entry_point") - agent.device = "cpu" - agent.actor.hidden_dims = agent.critic.hidden_dims = [16] - runner = OnPolicyRunner(RslRlVecEnvWrapper(env), to_dict(agent), log_dir=str(tmp_path), device=agent.device) curriculum = env.curriculum_manager.cfg.reset_sampling.func - expected = curriculum.get_state() - checkpoint = tmp_path / "model.pt" - runner.save(str(checkpoint), infos={"purpose": "resume"}) - assert "conveyor_reset_curriculum" in torch.load(checkpoint, weights_only=False) - env.episode_length_buf.fill_(5) + monitor = curriculum.success_monitor + table = env.event_manager.get_term_cfg("reset_from_state_table").func + # Repeated startup resets have no episode to credit. env.reset() - assert curriculum.get_state()["attempts"].sum() > expected["attempts"].sum() - assert runner.load(str(checkpoint), map_location="cpu") == {"purpose": "resume"} - for name, value in expected.items(): - torch.testing.assert_close(curriculum.get_state()[name], value) - # A policy-only load must not replace a fine-tuning run's curriculum. - env.episode_length_buf.fill_(5) - env.reset() - attempts = curriculum.get_state()["attempts"].sum() - runner.load(str(checkpoint), load_cfg={"actor": True}, map_location="cpu") - assert curriculum.get_state()["attempts"].sum() == attempts + assert monitor.success_size.sum() == 0 + for succeeded in (True, False): + table.row_ids[:] = torch.tensor([0, 1]) + env.episode_length_buf[:] = 5 + command.ever_success[:] = torch.tensor([succeeded, True]) + untouched_row = table.row_ids[1].clone() + env.reset(env_ids=torch.tensor([0])) + assert table.row_ids[1] == untouched_row + assert monitor.success_size[1] == 0 + assert monitor.success_size.sum() == monitor.success_size[0] == 2 + assert monitor.success_rate[0] == 0.5 + probabilities = curriculum.sampler.probabilities() + assert torch.isfinite(probabilities).all() and (probabilities > 0).all() + torch.testing.assert_close(probabilities.sum(), torch.tensor(1.0)) + assert probabilities[0] > probabilities[1] + # A genuine unsuccessful episode is counted; a zero-step manual reset is not. + table.row_ids[:] = torch.tensor([0, 1]) + command.ever_success[1] = False + env.reset(env_ids=torch.tensor([1])) + assert monitor.success_size[1] == 1 and monitor.success_rate[1] == 0 + env.reset(env_ids=torch.tensor([1])) + assert monitor.success_size.sum() == 3 def test_reset_bank_is_complete_and_physically_calibrated(): @@ -253,22 +233,8 @@ def test_reset_bank_is_complete_and_physically_calibrated(): ) -def test_transfer_shaping_and_binary_policy_likelihoods(): - """Release improves progress; remote closing earns no grasp credit; sampled gripper likelihoods remain finite.""" - cubes = torch.tensor( - [[0.52, 0.27, 0.06], [0.52, 0.27, 0.20], [0.52, 0, 0.20], [0.52, -0.27, 0.105], [0.52, -0.27, 0.06]] - ) - tool = cubes.clone() - tool[-1, 2] += 0.1 - fingers = torch.full((5, 2), 0.019) - fingers[-1] = 0.04 - potential = transfer_potential(cubes, tool, fingers, torch.zeros(5, dtype=torch.long)) - assert (potential[1:] > potential[:-1]).all() - cubes = torch.tensor([[0.52, 0.27, 0.06]] * 2) - tool = torch.tensor([[0.52, 0.27, 0.07], [0.52, 0.27, 0.20]]) - credit = transfer_potential(cubes, tool, torch.full((2, 2), 0.019), torch.zeros(2, dtype=torch.long)) - credit -= transfer_potential(cubes, tool, torch.full((2, 2), 0.04), torch.zeros(2, dtype=torch.long)) - assert credit[0] > 0.5 and credit[1] < 0.03 +def test_binary_policy_likelihoods(): + """The mixed distribution samples exact binary gripper actions with finite likelihoods and KL.""" distribution = ConveyorGaussianBernoulliDistribution(output_dim=8) distribution.update(torch.zeros(4096, 8)) samples, previous = distribution.sample(), tuple(p.clone() for p in distribution.params) @@ -279,31 +245,6 @@ def test_transfer_shaping_and_binary_policy_likelihoods(): assert torch.isfinite(divergence).all() and (divergence >= 0).all() -def test_curriculum_reserves_deployment_mass_and_balances_commands(): - """Adaptive weights cannot starve any recipe/cube/direction, or the moving-belt deployment starts.""" - rows = build_reset_rows() - recipe, variant, cube, side = torch.tensor( - [[r.recipe, r.variant_id, r.target_cube_id, r.source_side_id] for r in rows] - ).T - deployment = (recipe == ConveyorResetRecipe.BELT) & ( - variant == reset_variant_counts()[ConveyorResetRecipe.BELT] - 1 - ) - place = torch.where((recipe == ConveyorResetRecipe.PLACE) & (cube == 0) & (side == 0))[0] - weights = torch.ones(len(rows)) - weights[place[:2]] = torch.tensor([0.2, 0.8]) - probabilities = reset_sampling_probabilities(recipe, variant, cube, side, weights, deployment_probability=0.35) - torch.testing.assert_close(probabilities.sum(), torch.tensor(1.0)) - assert probabilities[place[0]] < probabilities[place[1]] - for r, c, s in {(r.recipe, r.target_cube_id, r.source_side_id) for r in rows}: - mask = (recipe == r) & (cube == c) & (side == s) & ~deployment - torch.testing.assert_close(probabilities[mask].sum(), torch.tensor(0.65 / (len(ConveyorResetRecipe) * 8))) - for s in (0, 1): - torch.testing.assert_close(probabilities[deployment & (side == s)].sum(), torch.tensor(0.35 / 2)) - for progress, coverage, expected in ((0.30, 1.0, 0.35), (0.625, 0.50, 0.625), (0.90, 1.0, 0.90)): - actual = deployment_probability_from_progress(torch.tensor(progress), torch.tensor(coverage)) - torch.testing.assert_close(actual, torch.tensor(expected)) - - def test_next_transfer_cube_is_random_among_eligible_alternatives(): """Continuing commands use the one-hot target instead of a cyclic identity shortcut.""" count = 4096 From 886a2c97b0af846508604d7b59f66483d2b68a8e Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 15:06:10 +0200 Subject: [PATCH 21/45] Keep experimental conveyor support Newton-only --- .../maximiliank-conveyor-support.added.rst | 2 +- .../isaaclab_contrib/conveyors/README.md | 14 +- .../isaaclab_contrib/conveyors/__init__.py | 4 +- .../isaaclab_contrib/conveyors/newton.py | 17 +- .../isaaclab_contrib/conveyors/physx.py | 442 ------------------ .../conveyors/surface_velocity.py | 59 +-- .../conveyors/test_surface_velocity_physx.py | 130 ------ .../conveyors/test_surface_velocity_spec.py | 4 +- 8 files changed, 17 insertions(+), 655 deletions(-) delete mode 100644 source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py delete mode 100644 source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py diff --git a/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst b/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst index 3dc15f179a03..c44fddcb77d1 100644 --- a/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst +++ b/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst @@ -1 +1 @@ -* Added experimental straight and curved conveyor controls with Newton force-model and native CPU PhysX adapters. +* Added experimental straight and curved conveyor controls using Newton's upstream force model. diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md b/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md index 89f3abf701a6..d6cb67eaa1b2 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md @@ -1,21 +1,17 @@ # Experimental conveyor support -These adapters stage conveyor integration in `isaaclab_contrib`; their API is +This adapter stages Newton conveyor integration in `isaaclab_contrib`; its API is experimental and is not exported from Isaac Lab core or the backend packages. - `surface_velocity.SurfaceVelocitySpec` describes a straight or curved surface - in the collision prim's local frame. `SurfaceVelocityView` describes the shared - control operations. + in the collision prim's local frame. - `newton.SurfaceVelocity` uses Newton's upstream `ConveyorForceModel`. The adapter owns replicated surface/body bindings and solver lifecycle integration, including hard resets and registration before CUDA graph capture. It supports the Newton MJWarp solver; other Newton solvers are outside this adapter's scope. -- `physx.SurfaceVelocity` uses native `PhysxSurfaceVelocityAPI` on CPU. - Author the API before simulation parsing with `apply_surface_velocity_api`. - This path requires the PhysX backend and Kit; GPU dynamics are unsupported. -Both adapters retain commanded speeds while disabled and restart their startup -ramp on a full reset. Newton also clears stale forces for selected worlds. -Newton uses the spec's traction parameters; PhysX uses authored contact materials. +The adapter retains commanded speeds while disabled, restarts its startup ramp +on a full reset, and clears stale forces for selected worlds. The surface spec +controls traction parameters. No meshes, assets, task registrations, or policy checkpoints are bundled here. diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/__init__.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/__init__.py index 6dddd7297fff..9ceb327848ad 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/__init__.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/__init__.py @@ -5,6 +5,6 @@ """Experimental conveyor support for contributed tasks. -Backend adapters are imported explicitly from :mod:`.newton` or :mod:`.physx` so -importing the surface descriptions does not load either optional backend. +The Newton adapter is imported explicitly from :mod:`.newton` so importing the +surface descriptions does not load the optional backend. """ diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py index a8ba32930cb2..0207335654f8 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py @@ -215,25 +215,10 @@ def _require_binding(self) -> _SurfaceVelocityBinding: raise RuntimeError("Surface velocity is not bound to an initialized Newton solver.") return binding - @property - def specs(self) -> tuple[SurfaceVelocitySpec, ...]: - """Authored surface descriptions in stable within-environment order.""" - return self._surface_specs - - @property - def surfaces_per_env(self) -> int: - """Number of authored surfaces in each replicated environment.""" - return len(self._surface_specs) - @property def num_surfaces(self) -> int: """Total number of resolved surfaces across all environments.""" - return self._num_envs * self.surfaces_per_env - - @property - def initialized(self) -> bool: - """Whether the driver is bound to the active Newton solver.""" - return self._binding is not None + return self._num_envs * len(self._surface_specs) @property def prim_paths(self) -> tuple[str, ...]: diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py deleted file mode 100644 index 3cae39e948db..000000000000 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/physx.py +++ /dev/null @@ -1,442 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""CPU-only native PhysX surface-velocity control. - -The module deliberately keeps PhysX schema imports behind authoring and binding calls. Its -geometry conversion and host-side control state therefore remain usable in import-light tests -and tooling that do not launch Kit. - -The task configuration rejects GPU dynamics: the supported native ``PhysxSurfaceVelocityAPI`` -path can lose conveyor contacts there. The force-driven Newton adapter is the scalable GPU path. -""" - -from __future__ import annotations - -import math -from collections.abc import Sequence -from dataclasses import dataclass -from typing import Any - -import numpy as np - -from .surface_velocity import SurfaceVelocitySpec, resolve_surface_velocity_paths - - -@dataclass(frozen=True, slots=True) -class PhysxSurfaceVelocityTwist: - """Local PhysX surface twist. - - Attributes: - linear_velocity: Local linear surface velocity [m/s]. - angular_velocity_deg: Local angular surface velocity [deg/s]. - """ - - linear_velocity: tuple[float, float, float] - angular_velocity_deg: tuple[float, float, float] - - -def compute_surface_velocity_twist( - spec: SurfaceVelocitySpec, velocity: float | None = None -) -> PhysxSurfaceVelocityTwist: - """Convert one backend-neutral belt command to a local PhysX surface twist. - - Straight belts map their signed speed to a normalized linear direction. Curved belts map - speed over radius to PhysX's degree-per-second angular convention. PhysX rotates the surface - field about the rigid-body origin, so ``-omega x pivot`` is included in the linear component - to move the instantaneous center to :attr:`SurfaceVelocitySpec.pivot_point`. - - Args: - spec: Authored conveyor intent in the collision prim's local frame. - velocity: Optional signed speed override [m/s]. Defaults to ``spec.velocity``. - - Returns: - Local linear and angular PhysX surface velocities. - - Raises: - ValueError: If the speed is not finite, the direction is zero, or a curved belt has no - positive radius. - """ - speed = spec.velocity if velocity is None else _finite_float("velocity", velocity) - direction = np.asarray(spec.direction, dtype=np.float64) - magnitude = float(np.linalg.norm(direction)) - if not math.isfinite(magnitude) or magnitude <= 1.0e-8: - raise ValueError(f"Conveyor direction must be finite and non-zero, got {spec.direction!r}.") - unit_direction = direction / magnitude - - if not spec.curved: - linear = unit_direction * speed - return PhysxSurfaceVelocityTwist(tuple(float(value) for value in linear), (0.0, 0.0, 0.0)) - - if spec.radius is None or not math.isfinite(spec.radius) or spec.radius <= 0.0: - raise ValueError("A curved PhysX conveyor requires a finite positive radius.") - angular_rad = unit_direction * (speed / spec.radius) - pivot = np.asarray(spec.pivot_point, dtype=np.float64) - linear = -np.cross(angular_rad, pivot) - angular_deg = np.degrees(angular_rad) - return PhysxSurfaceVelocityTwist( - tuple(float(value) for value in linear), tuple(float(value) for value in angular_deg) - ) - - -def apply_surface_velocity_api( - prim_or_path: Any, - spec: SurfaceVelocitySpec, - *, - velocity_scale: float = 0.0, - stage: Any | None = None, -) -> None: - """Apply and author a kinematic PhysX surface-velocity API on one belt prim. - - This function is intended for a task spawner, before PhysX parses the stage. It lazily imports - ``pxr.PhysxSchema`` and applies both the rigid-body and surface-velocity schemas. The rigid body - is made kinematic, surface velocities are authored in local space, and the initial command is - scaled explicitly. A zero default prevents pre-runtime motion during simulation warmup. - - Args: - prim_or_path: USD prim object or exact prim path. - spec: Conveyor intent used to author the local twist. - velocity_scale: Finite multiplier for the initial signed surface speed. - stage: Optional USD stage used when ``prim_or_path`` is a string. Defaults to the current - Isaac Lab stage. - - Raises: - RuntimeError: If the PhysX schema is unavailable or the target prim does not exist. - ValueError: If ``velocity_scale`` is not finite. - """ - scale = _finite_float("velocity_scale", velocity_scale) - twist = compute_surface_velocity_twist(spec, velocity=spec.velocity * scale) - binding = _PhysxSchemaSurfaceWriter((prim_or_path,), stage=stage, apply_api=True) - try: - binding.write(0, enabled=spec.enabled, twist=twist) - finally: - binding.close() - - -class SurfaceVelocity: - """Host-side CPU reference facade for native PhysX surface velocity. - - The facade binds exact environment-major paths whose schemas were already authored by - :func:`apply_surface_velocity_api`. Call :meth:`start` to register physics-rate updates, - or call :meth:`update` manually. Commands and enabled state survive full resets; a full reset - restarts the startup ramp. This facade authors USD attributes - on the host and is not a GPU conveyor implementation. - """ - - def __init__( - self, - num_envs: int, - surface_specs: Sequence[SurfaceVelocitySpec], - *, - env_path_format: str = "/World/envs/env_{}", - startup_duration_s: float = 1.0, - stage: Any | None = None, - ) -> None: - """Bind native surface attributes and initialize host-side control state. - - Args: - num_envs: Number of replicated environments. - surface_specs: Within-environment surface descriptions. - env_path_format: Exact replicated environment path format. - startup_duration_s: Duration of the global surface-speed ramp [s]. - stage: Optional USD stage used by the default schema writer. - """ - duration = _finite_float("startup_duration_s", startup_duration_s) - if duration <= 0.0: - raise ValueError(f"Conveyor startup_duration_s must be positive, got {startup_duration_s!r}.") - self._num_envs = num_envs - self._surface_specs = tuple(surface_specs) - self._surface_paths = resolve_surface_velocity_paths(num_envs, self._surface_specs, env_path_format) - self._surfaces_per_env = len(self._surface_specs) - self._startup_duration_s = duration - self._elapsed_time = 0.0 - self._velocity_scale = 0.0 - self._closed = False - self._callback_handle: Any | None = None - self._writer = _PhysxSchemaSurfaceWriter(self._surface_paths, stage=stage, apply_api=False) - - self._command_velocity = np.tile( - np.asarray([spec.velocity for spec in self._surface_specs], dtype=np.float32), self._num_envs - ) - self._enabled = np.tile( - np.asarray([spec.enabled for spec in self._surface_specs], dtype=np.bool_), self._num_envs - ) - self._last_authored: list[tuple[bool, PhysxSurfaceVelocityTwist] | None] = [None] * self.num_surfaces - self._flush(force=True) - - @property - def specs(self) -> tuple[SurfaceVelocitySpec, ...]: - """Return authored descriptions in stable within-environment order.""" - return self._surface_specs - - @property - def surfaces_per_env(self) -> int: - """Return the number of authored surfaces per environment.""" - return self._surfaces_per_env - - @property - def prim_paths(self) -> tuple[str, ...]: - """Return exact PhysX prim paths in environment-major order.""" - return self._surface_paths - - @property - def num_surfaces(self) -> int: - """Return the total number of bound conveyor surfaces.""" - return len(self._surface_paths) - - @property - def initialized(self) -> bool: - """Return whether the facade still owns live schema bindings.""" - return not self._closed - - def start(self) -> None: - """Register one lazy PhysX post-step callback; repeated calls are safe.""" - self._require_open() - if self._callback_handle is not None: - return - from isaaclab_physx.physics.physx_manager import IsaacEvents, PhysxManager - - self._callback_handle = PhysxManager.register_callback( - self.update, - IsaacEvents.POST_PHYSICS_STEP, - name="physx_surface_velocity", - ) - - def update(self, dt: float) -> None: - """Advance the startup ramp by one physics step. - - Args: - dt: Positive physics step duration [s]. - """ - self._require_open() - step = _finite_float("physics dt", dt) - if step <= 0.0: - raise ValueError(f"Conveyor physics dt must be positive, got {dt!r}.") - self._elapsed_time = min(self._startup_duration_s, self._elapsed_time + step) - self._velocity_scale = self._elapsed_time / self._startup_duration_s - self._flush() - - def set_velocities(self, velocities: Any, indices: Any = None) -> None: - """Set signed speeds [m/s], preserving commands while belts are disabled.""" - selected = self._resolve_indices(indices) - self._command_velocity[selected] = self._broadcast_finite(velocities, len(selected), "velocities") - self._flush(selected) - - def get_velocities(self, indices: Any = None, clone: bool = True) -> np.ndarray: - """Return effective speeds with disabled belts reported as zero.""" - return self._get_values(self._command_velocity * self._enabled, indices, clone) - - def get_commanded_velocities(self, indices: Any = None, clone: bool = True) -> np.ndarray: - """Return commanded speeds before applying the enabled mask.""" - return self._get_values(self._command_velocity, indices, clone) - - def set_enabled(self, flags: Any, indices: Any = None) -> None: - """Enable or disable belts without discarding their commands.""" - selected = self._resolve_indices(indices) - values = _as_numpy(flags) - if values.ndim == 0: - values = np.full(len(selected), values.item()) - else: - values = values.reshape(-1) - if values.size == 1: - values = np.full(len(selected), values.item()) - if values.size != len(selected) or not np.all(np.isin(values, (False, True, 0, 1))): - raise ValueError(f"Conveyor enabled flags must contain {len(selected)} boolean values.") - self._enabled[selected] = values.astype(np.bool_) - self._flush(selected) - - def get_enabled(self, indices: Any = None, clone: bool = True) -> np.ndarray: - """Return enabled flags as integer values.""" - return self._get_values(self._enabled.astype(np.int32), indices, clone) - - def reset(self, env_ids: Any = None) -> None: - """Restart the global startup ramp on a full reset. - - Velocity commands and enabled state are deliberately preserved, including across a full - hard-reset path. Partial resets do not disturb the global ramp used by other environments. - - Args: - env_ids: Environment indices to reset, or ``None`` for all environments. - """ - self._require_open() - ids = self._resolve_env_ids(env_ids) - if len(np.unique(ids)) == self._num_envs: - self._elapsed_time = 0.0 - self._velocity_scale = 0.0 - self._flush(force=True) - - def close(self) -> None: - """Deregister callbacks, disable authored motion, and release bindings safely.""" - if self._closed: - return - if self._callback_handle is not None: - self._callback_handle.deregister() - self._callback_handle = None - zero = PhysxSurfaceVelocityTwist((0.0, 0.0, 0.0), (0.0, 0.0, 0.0)) - for index in range(self.num_surfaces): - self._writer.write(index, enabled=False, twist=zero) - self._writer.close() - self._closed = True - - def _flush(self, indices: Any = None, *, force: bool = False) -> None: - """Author scaled effective commands for selected rows.""" - selected = self._resolve_indices(indices) - for index in selected: - enabled = bool(self._enabled[index]) - spec = self._surface_specs[index % self._surfaces_per_env] - speed = float(self._command_velocity[index]) * self._velocity_scale if enabled else 0.0 - twist = compute_surface_velocity_twist(spec, velocity=speed) - state = (enabled, twist) - if force or state != self._last_authored[index]: - self._writer.write(int(index), enabled=enabled, twist=twist) - self._last_authored[index] = state - - def _resolve_indices(self, indices: Any) -> np.ndarray: - """Normalize and validate a belt row selection.""" - self._require_open() - if indices is None: - return np.arange(self.num_surfaces, dtype=np.int64) - if isinstance(indices, slice): - return np.arange(self.num_surfaces, dtype=np.int64)[indices] - selected = _as_numpy(indices) - if selected.dtype == np.bool_: - if selected.ndim != 1 or selected.size != self.num_surfaces: - raise IndexError(f"Boolean surface indices must have length {self.num_surfaces}.") - return np.flatnonzero(selected).astype(np.int64) - if not np.issubdtype(selected.dtype, np.integer): - raise IndexError(f"Conveyor indices must be integers, got {indices!r}.") - selected = selected.astype(np.int64, copy=False).reshape(-1) - if np.any((selected < 0) | (selected >= self.num_surfaces)): - raise IndexError(f"Conveyor surface indices are out of range: {selected.tolist()}.") - return selected - - def _resolve_env_ids(self, env_ids: Any) -> np.ndarray: - """Normalize and validate an environment selection.""" - if env_ids is None: - return np.arange(self._num_envs, dtype=np.int64) - ids = _as_numpy(env_ids) - if not np.issubdtype(ids.dtype, np.integer): - raise IndexError(f"Conveyor reset environment indices must be integers, got {env_ids!r}.") - ids = ids.astype(np.int64, copy=False).reshape(-1) - if np.any((ids < 0) | (ids >= self._num_envs)): - raise IndexError(f"Conveyor reset environment indices are out of range: {ids.tolist()}.") - return ids - - @staticmethod - def _broadcast_finite(values: Any, count: int, name: str) -> np.ndarray: - """Return a finite float32 vector with scalar broadcasting.""" - try: - result = np.asarray(_as_numpy(values), dtype=np.float32) - except (TypeError, ValueError, OverflowError) as exc: - raise ValueError(f"Conveyor {name} must contain finite numeric values.") from exc - if result.ndim == 0: - result = np.full(count, result.item(), dtype=np.float32) - else: - result = result.reshape(-1) - if result.size == 1: - result = np.full(count, result.item(), dtype=np.float32) - if result.size != count or not np.all(np.isfinite(result)): - raise ValueError(f"Conveyor {name} must contain one or {count} finite values.") - return result - - def _get_values(self, source: np.ndarray, indices: Any, clone: bool) -> np.ndarray: - """Return a complete buffer or a selected copy.""" - self._require_open() - if indices is None: - return source.copy() if clone else source - return source[self._resolve_indices(indices)].copy() - - def _require_open(self) -> None: - """Fail predictably after backend resources have been released.""" - if self._closed: - raise RuntimeError("The PhysX conveyor surface facade is closed.") - - -class _PhysxSchemaSurfaceWriter: - """Cached USD attribute writer with all schema imports kept lazy.""" - - def __init__(self, prims_or_paths: Sequence[Any], *, stage: Any | None, apply_api: bool) -> None: - """Resolve prims, optionally apply schemas, and cache surface attributes.""" - from pxr import Gf, PhysxSchema, UsdPhysics - - if stage is None and any(isinstance(value, str) for value in prims_or_paths): - from isaaclab.sim.utils.stage import get_current_stage - - stage = get_current_stage() - self._gf = Gf - self._attributes: list[tuple[Any, Any, Any]] = [] - for value in prims_or_paths: - prim = stage.GetPrimAtPath(value) if isinstance(value, str) else value - if prim is None or not prim.IsValid(): - raise RuntimeError(f"Cannot bind PhysX conveyor surface: prim {value!r} does not exist.") - - rigid_body = UsdPhysics.RigidBodyAPI(prim) - if apply_api and not prim.HasAPI(UsdPhysics.RigidBodyAPI): - rigid_body = UsdPhysics.RigidBodyAPI.Apply(prim) - elif not prim.HasAPI(UsdPhysics.RigidBodyAPI): - raise RuntimeError(f"PhysX conveyor prim {prim.GetPath()} has no authored RigidBodyAPI.") - if apply_api: - rigid_body.CreateRigidBodyEnabledAttr().Set(True) - rigid_body.CreateKinematicEnabledAttr().Set(True) - elif rigid_body.GetKinematicEnabledAttr().Get() is not True: - raise RuntimeError(f"PhysX conveyor prim {prim.GetPath()} must be an authored kinematic rigid body.") - - if prim.HasAPI(PhysxSchema.PhysxSurfaceVelocityAPI): - surface_api = PhysxSchema.PhysxSurfaceVelocityAPI(prim) - elif apply_api: - surface_api = PhysxSchema.PhysxSurfaceVelocityAPI.Apply(prim) - else: - raise RuntimeError( - f"PhysX conveyor prim {prim.GetPath()} has no authored PhysxSurfaceVelocityAPI; " - "call apply_surface_velocity_api from its spawner before simulation starts." - ) - surface_api.CreateSurfaceVelocityLocalSpaceAttr().Set(True) - self._attributes.append( - ( - surface_api.CreateSurfaceVelocityEnabledAttr(), - surface_api.CreateSurfaceVelocityAttr(), - surface_api.CreateSurfaceAngularVelocityAttr(), - ) - ) - - def write(self, index: int, *, enabled: bool, twist: PhysxSurfaceVelocityTwist) -> None: - """Author one cached local-space surface twist.""" - enabled_attr, linear_attr, angular_attr = self._attributes[index] - # PhysX caches the contact-modification flag when a surface-velocity - # shape is parsed. Cycle it around command changes, matching the - # official Isaac Conveyor node, so live updates are picked up without - # leaving a stale contact modifier attached to the shape. - enabled_attr.Set(False) - linear_attr.Set(self._gf.Vec3f(*twist.linear_velocity)) - angular_attr.Set(self._gf.Vec3f(*twist.angular_velocity_deg)) - enabled_attr.Set(enabled) - - def close(self) -> None: - """Release cached schema attribute handles.""" - self._attributes.clear() - - -def _finite_float(name: str, value: Any) -> float: - """Coerce one finite scalar with a stable validation error.""" - try: - result = float(value) - except (TypeError, ValueError, OverflowError) as exc: - raise ValueError(f"Conveyor {name} must be finite, got {value!r}.") from exc - if not math.isfinite(result): - raise ValueError(f"Conveyor {name} must be finite, got {value!r}.") - return result - - -def _as_numpy(values: Any) -> np.ndarray: - """Move supported tensor-like values to a host NumPy array without importing their libraries.""" - if isinstance(values, np.ndarray): - return values - if hasattr(values, "detach"): - values = values.detach() - if hasattr(values, "cpu"): - values = values.cpu() - if hasattr(values, "numpy"): - return np.asarray(values.numpy()) - return np.asarray(values) diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py index b528cc9bf668..a88036e59294 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py @@ -3,14 +3,14 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Backend-neutral surface-velocity descriptions and control interface.""" +"""Import-light descriptions of prescribed conveyor surface velocities.""" from __future__ import annotations import math from collections.abc import Sequence from dataclasses import dataclass -from typing import Any, Protocol, runtime_checkable +from typing import Any _ENV_REGEX_NS = "{ENV_REGEX_NS}" @@ -62,10 +62,8 @@ def _validate_vector(name: str, value: tuple[float, ...], length: int, *, nonzer class SurfaceVelocitySpec: """Persistent intent for one collision surface with prescribed tangential velocity. - The fields follow the authored conveyor model proposed for Isaac Sim while remaining independent of - Kit, OpenUSD, and any physics backend. Directions, surface normals, and the optional pivot point are - expressed in the collision prim's local frame. Runtime state such as commanded speeds and applied - forces intentionally does not belong in this description. + Directions, surface normals, and the optional pivot point are expressed in the collision prim's + local frame. Runtime state such as commanded speeds and applied forces belongs to the adapter. ``prim_path`` may contain Isaac Lab's ``{ENV_REGEX_NS}`` placeholder. A backend resolves that template to every replicated collision prim while preserving one deterministic belt index per environment. An @@ -77,10 +75,8 @@ class SurfaceVelocitySpec: enabled: Whether the surface initially transports contacting bodies. direction: Local travel direction, or local rotation axis for a curved belt. curved: Whether the velocity field rotates about ``pivot_point``. - pivot_point: Local rotation center for a curved belt [m]. An OpenUSD adapter may map this point to the - world transform of a prim targeted by the proposed schema's pivot relationship; it is not copied - into a schema attribute. - radius: Optional centerline radius used by backends that cannot derive it from geometry [m]. + pivot_point: Local rotation center for a curved belt [m]. + radius: Centerline radius required for a curved belt [m]. surface_normal: Local outward normal of the carrying surface. contact_threshold: Minimum contact-normal alignment accepted for traction. friction_coefficient: Coulomb limit for synthetic surface traction. @@ -173,46 +169,3 @@ def resolve_surface_velocity_paths( if len(set(paths)) != len(paths): raise ValueError("Resolved conveyor prim paths must be unique.") return paths - - -@runtime_checkable -class SurfaceVelocityView(Protocol): - """Common tensorized control contract implemented by physics backends.""" - - @property - def prim_paths(self) -> tuple[str, ...]: - """Resolved collision prim paths in stable surface-index order.""" - ... - - @property - def num_surfaces(self) -> int: - """Number of resolved moving surfaces.""" - ... - - def set_velocities(self, velocities: Any, indices: Any = None) -> None: - """Set signed surface velocities [m/s] for selected surfaces.""" - ... - - def get_velocities(self, indices: Any = None, clone: bool = True) -> Any: - """Return effective surface velocities [m/s] for selected surfaces.""" - ... - - def get_commanded_velocities(self, indices: Any = None, clone: bool = True) -> Any: - """Return commanded surface velocities [m/s] before the enabled mask.""" - ... - - def set_enabled(self, flags: Any, indices: Any = None) -> None: - """Enable or disable selected surfaces without discarding their commands.""" - ... - - def get_enabled(self, indices: Any = None, clone: bool = True) -> Any: - """Return integer enabled flags for selected surfaces.""" - ... - - def reset(self, env_ids: Any = None) -> None: - """Clear runtime state for selected replicated environments.""" - ... - - def close(self) -> None: - """Release backend resources and callbacks.""" - ... diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py deleted file mode 100644 index a6f229031f58..000000000000 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py +++ /dev/null @@ -1,130 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Import-light tests for native PhysX surface velocity.""" - -from __future__ import annotations - -import math -from types import SimpleNamespace -from unittest.mock import Mock, call - -import numpy as np -import pytest -import torch - -import isaaclab_contrib.conveyors.physx as surface_module -from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec - - -class _FakeWriter: - """Record authored states without importing USD or PhysX schemas.""" - - def __init__(self) -> None: - self.writes: list[tuple[int, bool, surface_module.PhysxSurfaceVelocityTwist]] = [] - self.close_count = 0 - - def write(self, index: int, *, enabled: bool, twist: surface_module.PhysxSurfaceVelocityTwist) -> None: - self.writes.append((index, enabled, twist)) - - def close(self) -> None: - self.close_count += 1 - - -def _surface_spec(name: str = "Belt", **kwargs) -> SurfaceVelocitySpec: - """Return one replicated belt description for facade tests.""" - return SurfaceVelocitySpec(prim_path=f"{{ENV_REGEX_NS}}/{name}", velocity=0.4, **kwargs) - - -def test_twist_conversion_normalizes_straight_direction() -> None: - """Straight surface speed is independent of the authored direction magnitude.""" - spec = _surface_spec(direction=(3.0, 4.0, 0.0)) - - twist = surface_module.compute_surface_velocity_twist(spec, velocity=2.0) - - np.testing.assert_allclose(twist.linear_velocity, (1.2, 1.6, 0.0)) - assert twist.angular_velocity_deg == (0.0, 0.0, 0.0) - - -def test_twist_conversion_uses_degrees_and_compensates_curved_pivot() -> None: - """Curved belts rotate about their local pivot rather than the rigid-body origin.""" - spec = _surface_spec( - direction=(0.0, 0.0, 2.0), - curved=True, - radius=2.0, - pivot_point=(2.0, 0.0, 0.0), - ) - - twist = surface_module.compute_surface_velocity_twist(spec, velocity=math.pi) - - np.testing.assert_allclose(twist.angular_velocity_deg, (0.0, 0.0, 90.0), atol=1.0e-12) - np.testing.assert_allclose(twist.linear_velocity, (0.0, -math.pi, 0.0), atol=1.0e-12) - - -def test_curved_twist_requires_an_explicit_radius() -> None: - """A native angular rate cannot be inferred from unspecified task geometry.""" - spec = _surface_spec(curved=True) - - with pytest.raises(ValueError, match="positive radius"): - surface_module.compute_surface_velocity_twist(spec) - - -def test_facade_ramps_playback_and_preserves_commands_on_reset(monkeypatch: pytest.MonkeyPatch) -> None: - """Full resets restart playback without erasing policy-visible command state.""" - writer = _FakeWriter() - monkeypatch.setattr(surface_module, "_PhysxSchemaSurfaceWriter", lambda *args, **kwargs: writer) - facade = surface_module.SurfaceVelocity(2, (_surface_spec(),)) - - assert facade.prim_paths == ("/World/envs/env_0/Belt", "/World/envs/env_1/Belt") - assert facade.num_surfaces == 2 - assert [record[2].linear_velocity for record in writer.writes] == [(0.0, 0.0, 0.0)] * 2 - - facade.update(0.25) - np.testing.assert_allclose([record[2].linear_velocity[0] for record in writer.writes[-2:]], (0.1, 0.1)) - - facade.set_velocities(torch.tensor([0.8], requires_grad=True), indices=torch.tensor([0])) - facade.set_enabled(False, indices=[1]) - facade.update(0.25) - np.testing.assert_allclose(facade.get_commanded_velocities(), (0.8, 0.4)) - np.testing.assert_allclose(facade.get_velocities(), (0.8, 0.0)) - - writes_before_reset = list(writer.writes) - facade.reset(env_ids=torch.tensor([0])) - assert writer.writes == writes_before_reset - facade.reset(env_ids=[1, 0]) - np.testing.assert_allclose(facade.get_commanded_velocities(), (0.8, 0.4)) - assert facade.get_enabled().tolist() == [1, 0] - np.testing.assert_allclose(writer.writes[-2][2].linear_velocity, (0.0, 0.0, 0.0)) - - facade.close() - facade.close() - assert writer.close_count == 1 - assert [(index, enabled) for index, enabled, _ in writer.writes[-2:]] == [(0, False), (1, False)] - - -def test_authoring_helper_applies_kinematic_local_surface_schema(monkeypatch: pytest.MonkeyPatch) -> None: - """Author a real kinematic USD body; mock only the Kit-dependent PhysX API.""" - import pxr - from pxr import Gf, Usd, UsdPhysics - - stage = Usd.Stage.CreateInMemory() - prim = stage.DefinePrim("/World/Belt", "Xform") - surface_api = Mock() - # HasAPI needs a registered USD type. Substitute only the unavailable PhysX schema. - schema = SimpleNamespace(PhysxSurfaceVelocityAPI=UsdPhysics.CollisionAPI) - monkeypatch.setattr(UsdPhysics.CollisionAPI, "Apply", Mock(return_value=surface_api)) - monkeypatch.setattr(pxr, "PhysxSchema", schema, raising=False) - - surface_module.apply_surface_velocity_api(prim, _surface_spec(), velocity_scale=0.0) - - assert prim.HasAPI(UsdPhysics.RigidBodyAPI) - rigid_body = UsdPhysics.RigidBodyAPI(prim) - assert rigid_body.GetRigidBodyEnabledAttr().Get() is True - assert rigid_body.GetKinematicEnabledAttr().Get() is True - schema.PhysxSurfaceVelocityAPI.Apply.assert_called_once_with(prim) - surface_api.CreateSurfaceVelocityLocalSpaceAttr().Set.assert_called_once_with(True) - assert surface_api.CreateSurfaceVelocityEnabledAttr().Set.call_args_list == [call(False), call(True)] - surface_api.CreateSurfaceVelocityAttr().Set.assert_called_once_with(Gf.Vec3f(0.0)) - surface_api.CreateSurfaceAngularVelocityAttr().Set.assert_called_once_with(Gf.Vec3f(0.0)) diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py index e34fc0e1d78e..93b07e414069 100644 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Focused tests for backend-neutral surface-velocity descriptions.""" +"""Focused tests for import-light surface-velocity descriptions.""" from __future__ import annotations @@ -13,7 +13,7 @@ def test_surface_velocity_spec_normalizes_vectors() -> None: - """List inputs become immutable vectors before either backend consumes them.""" + """List inputs become immutable vectors before the adapter consumes them.""" spec = SurfaceVelocitySpec( prim_path="{ENV_REGEX_NS}/Belt", direction=[3, 4, 0], pivot_point=[1, 2, 3], surface_normal=[0, 0, 1] ) From 0232e233894b9dc2fef5a5f08c7221d811e633bb Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 15:09:16 +0200 Subject: [PATCH 22/45] Simplify calibrated conveyor resets and remove retired acquisition logic --- .../contrib/conveyor_franka/mdp/__init__.pyi | 2 - .../conveyor_franka/mdp/reset_events.py | 141 +++++------------- .../contrib/conveyor_franka/mdp/rewards.py | 36 +---- .../test/contrib/test_conveyor_franka_mdp.py | 3 +- 4 files changed, 41 insertions(+), 141 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi index 6cb67792f9b5..5fc03c8498f2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi @@ -30,7 +30,6 @@ __all__ = [ "gripper_joint_positions", "invalid_action", "nonfinite_scene_state", - "physical_cube_acquisition_mask", "select_next_transfer_cube", "subgoal_time_out", "target_cube_one_hot", @@ -67,7 +66,6 @@ from .rewards import ( action_term_l2, finite_action_rate_l2, finite_joint_velocity_l2, - physical_cube_acquisition_mask, terminal_failure, transfer_success_reward, ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py index 5e8995a48cb4..e0b41ba43354 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/reset_events.py @@ -8,7 +8,6 @@ from __future__ import annotations import math -from collections.abc import Sequence from dataclasses import dataclass from enum import IntEnum from typing import TYPE_CHECKING @@ -41,6 +40,7 @@ _GRASP_CLOSURE_FRACTIONS = (0.25, 0.50, 0.75, 0.90, 1.0) _OPEN_FINGER_POSITION = 0.040 _CLOSED_FINGER_POSITION = 0.019 +_MINIMUM_CUBE_SEPARATION = 0.055 BELT_DEPLOYMENT_VARIANT = len(_BELT_CURRICULUM_FRACTIONS) - 1 @@ -133,37 +133,16 @@ def _arm_position_variants( if recipe == ConveyorResetRecipe.GRASP: return (source_grasp,) * len(_GRASP_CLOSURE_FRACTIONS) if recipe == ConveyorResetRecipe.PREGRASP: - # Include the exact open-gripper acquisition pose. The previous last - # row stopped at 88% of the approach, leaving an approximately 1 cm - # gap between reset-driven approach learning and the already-held - # GRASP row. Dense near-contact rows make the physical close-and-lift - # transition learnable from the same sparse delivery objective. + # Include the open-gripper contact pose before the closure trajectory. return _interpolate_arm(source_pregrasp, source_grasp, (0.0, 0.50, 0.75, 0.92, 1.0)) if recipe == ConveyorResetRecipe.BELT: return _interpolate_arm(source_pregrasp, _HOME_ARM, _BELT_CURRICULUM_FRACTIONS) raise ValueError(f"Unsupported reset recipe: {recipe}.") -def _finger_position_variants(recipe: ConveyorResetRecipe) -> tuple[float, ...]: - """Return finger positions paired with a recipe's arm variants [m].""" - variant_count = len(_arm_position_variants(recipe, LEFT_SIDE)) - if recipe == ConveyorResetRecipe.GRASP: - return tuple( - _OPEN_FINGER_POSITION + fraction * (_CLOSED_FINGER_POSITION - _OPEN_FINGER_POSITION) - for fraction in _GRASP_CLOSURE_FRACTIONS - ) - if recipe in (ConveyorResetRecipe.LIFT, ConveyorResetRecipe.CARRY, ConveyorResetRecipe.PLACE): - return (_CLOSED_FINGER_POSITION,) * variant_count - return (_OPEN_FINGER_POSITION,) * variant_count - - -def reset_variant_counts() -> tuple[int, ...]: - """Return the number of trajectory variants in each reset recipe.""" - return tuple(len(_arm_position_variants(recipe, LEFT_SIDE)) for recipe in ConveyorResetRecipe) - - def build_reset_rows() -> tuple[ConveyorResetRow, ...]: """Build the complete identity, direction, and dense-trajectory cross product.""" + held_recipes = (ConveyorResetRecipe.LIFT, ConveyorResetRecipe.CARRY, ConveyorResetRecipe.PLACE) return tuple( ConveyorResetRow( recipe=recipe, @@ -171,9 +150,16 @@ def build_reset_rows() -> tuple[ConveyorResetRow, ...]: target_cube_id=cube_id, source_side_id=source_side, arm_positions=arm_positions, - finger_position=finger_position, + finger_position=( + _OPEN_FINGER_POSITION + + _GRASP_CLOSURE_FRACTIONS[variant_id] * (_CLOSED_FINGER_POSITION - _OPEN_FINGER_POSITION) + if recipe == ConveyorResetRecipe.GRASP + else _CLOSED_FINGER_POSITION + if recipe in held_recipes + else _OPEN_FINGER_POSITION + ), held=( - recipe in (ConveyorResetRecipe.LIFT, ConveyorResetRecipe.CARRY, ConveyorResetRecipe.PLACE) + recipe in held_recipes or (recipe == ConveyorResetRecipe.GRASP and variant_id == len(_GRASP_CLOSURE_FRACTIONS) - 1) ), belt_range_fraction=_BELT_CURRICULUM_FRACTIONS[variant_id] if recipe == ConveyorResetRecipe.BELT else 0.0, @@ -181,9 +167,7 @@ def build_reset_rows() -> tuple[ConveyorResetRow, ...]: for recipe in ConveyorResetRecipe for cube_id in range(CUBE_COUNT) for source_side in (LEFT_SIDE, RIGHT_SIDE) - for variant_id, (arm_positions, finger_position) in enumerate( - zip(_arm_position_variants(recipe, source_side), _finger_position_variants(recipe), strict=True) - ) + for variant_id, arm_positions in enumerate(_arm_position_variants(recipe, source_side)) ) @@ -259,20 +243,11 @@ def _balanced_cube_slots( its source-side inner slot, keeping it reachable without duplicating or emptying any deployment slot. """ - if target_cube_ids.ndim != 1 or source_side_ids.shape != target_cube_ids.shape: - raise ValueError("Target cube and source-side ids must be matching vectors.") - if bool(torch.any((target_cube_ids < 0) | (target_cube_ids >= CUBE_COUNT))): - raise ValueError("Target cube ids are out of range.") - if bool(torch.any((source_side_ids != LEFT_SIDE) & (source_side_ids != RIGHT_SIDE))): - raise ValueError("Source-side ids must be 0 (left) or 1 (right).") - count = target_cube_ids.numel() cube_slots = torch.arange(CUBE_COUNT, device=target_cube_ids.device).expand(count, -1).clone() source_inner_slots = 2 * source_side_ids - displaced_cube_ids = source_inner_slots - target_original_slots = target_cube_ids cube_slots.scatter_(1, target_cube_ids.unsqueeze(1), source_inner_slots.unsqueeze(1)) - cube_slots.scatter_(1, displaced_cube_ids.unsqueeze(1), target_original_slots.unsqueeze(1)) + cube_slots.scatter_(1, source_inner_slots.unsqueeze(1), target_cube_ids.unsqueeze(1)) cube_sides = torch.div(cube_slots, 2, rounding_mode="floor") on_outer_run = torch.remainder(cube_slots, 2).bool() @@ -289,30 +264,21 @@ def _sample_collision_free_active_x( source_side_ids: torch.Tensor, lower: torch.Tensor, upper: torch.Tensor, - minimum_separation: float = 0.055, - attempts: int = 16, ) -> torch.Tensor: """Sample active-cube positions without overlapping an inactive cube.""" - if base_x.ndim != 2 or base_x.shape[1] != CUBE_COUNT or cube_sides.shape != base_x.shape: - raise ValueError("base_x and cube_sides must have shape (N, CUBE_COUNT).") - count = base_x.shape[0] - expected_vector_shape = (count,) - if any(value.shape != expected_vector_shape for value in (target_cube_ids, source_side_ids, lower, upper)): - raise ValueError("Active-cube sampling controls must have shape (N,).") - if minimum_separation <= 0.0 or attempts < 1: - raise ValueError("Invalid collision-free active-cube sampling parameters.") - - cube_ids = torch.arange(CUBE_COUNT, device=base_x.device).expand(count, -1) + cube_ids = torch.arange(CUBE_COUNT, device=base_x.device) inactive_on_source = (cube_sides == source_side_ids.unsqueeze(1)) & (cube_ids != target_cube_ids.unsqueeze(1)) sampled = lower + torch.rand_like(lower) * (upper - lower) - for _ in range(attempts): + for _ in range(16): conflicts = torch.any( - (torch.abs(sampled.unsqueeze(1) - base_x) < minimum_separation) & inactive_on_source, dim=1 + (torch.abs(sampled.unsqueeze(1) - base_x) < _MINIMUM_CUBE_SEPARATION) & inactive_on_source, dim=1 ) replacement = lower + torch.rand_like(lower) * (upper - lower) sampled = torch.where(conflicts, replacement, sampled) - conflicts = torch.any((torch.abs(sampled.unsqueeze(1) - base_x) < minimum_separation) & inactive_on_source, dim=1) + conflicts = torch.any( + (torch.abs(sampled.unsqueeze(1) - base_x) < _MINIMUM_CUBE_SEPARATION) & inactive_on_source, dim=1 + ) fallback = torch.full_like(sampled, TRANSFER_X) return torch.where(conflicts, fallback, sampled) @@ -322,24 +288,18 @@ class ConveyorResetStateTable(ManagerTermBase): def __init__(self, cfg: EventTermCfg, env: ManagerBasedRLEnv): super().__init__(cfg, env) - self._rows = build_reset_rows() - self.recipe_ids = torch.tensor([row.recipe for row in self._rows], dtype=torch.long, device=env.device) - self.target_cube_ids = torch.tensor( - [row.target_cube_id for row in self._rows], dtype=torch.long, device=env.device - ) - self.variant_ids = torch.tensor([row.variant_id for row in self._rows], dtype=torch.long, device=env.device) - self.source_side_ids = torch.tensor( - [row.source_side_id for row in self._rows], dtype=torch.long, device=env.device - ) - self._arm_positions = torch.tensor( - [row.arm_positions for row in self._rows], dtype=torch.float32, device=env.device - ) + rows = build_reset_rows() + self.recipe_ids = torch.tensor([row.recipe for row in rows], dtype=torch.long, device=env.device) + self.target_cube_ids = torch.tensor([row.target_cube_id for row in rows], dtype=torch.long, device=env.device) + self.variant_ids = torch.tensor([row.variant_id for row in rows], dtype=torch.long, device=env.device) + self.source_side_ids = torch.tensor([row.source_side_id for row in rows], dtype=torch.long, device=env.device) + self._arm_positions = torch.tensor([row.arm_positions for row in rows], dtype=torch.float32, device=env.device) self._finger_positions = torch.tensor( - [row.finger_position for row in self._rows], dtype=torch.float32, device=env.device + [row.finger_position for row in rows], dtype=torch.float32, device=env.device ) - self.held_rows = torch.tensor([row.held for row in self._rows], dtype=torch.bool, device=env.device) + self.held_rows = torch.tensor([row.held for row in rows], dtype=torch.bool, device=env.device) self._belt_range_fractions = torch.tensor( - [row.belt_range_fraction for row in self._rows], dtype=torch.float32, device=env.device + [row.belt_range_fraction for row in rows], dtype=torch.float32, device=env.device ) self._robot: Articulation = env.scene["robot"] self._cubes: tuple[RigidObject, ...] = tuple(env.scene[f"cube_{cube_id}"] for cube_id in range(CUBE_COUNT)) @@ -352,12 +312,7 @@ def __init__(self, cfg: EventTermCfg, env: ManagerBasedRLEnv): @property def row_count(self) -> int: """Number of immutable physical reset rows.""" - return len(self._rows) - - @property - def recipe_names(self) -> tuple[str, ...]: - """Stable reset recipe labels.""" - return tuple(recipe.name.lower() for recipe in ConveyorResetRecipe) + return self.recipe_ids.numel() def _filtered_rows( self, @@ -406,24 +361,15 @@ def __call__( if cube_position_noise < 0.0 or arm_joint_noise < 0.0: raise ValueError("Reset randomization ranges must be non-negative.") - if ( - fixed_recipe is None - and fixed_variant_id is None - and fixed_target_cube_id is None - and fixed_source_side_id is None - ): - row_ids = self.row_ids[env_ids] - else: - candidates = self._filtered_rows( - fixed_recipe, - fixed_variant_id, - fixed_target_cube_id, - fixed_source_side_id, - ) + fixed_values = (fixed_recipe, fixed_variant_id, fixed_target_cube_id, fixed_source_side_id) + if any(value is not None for value in fixed_values): + candidates = self._filtered_rows(*fixed_values) if candidates.numel() == 0: raise RuntimeError("No conveyor reset rows match the fixed reset controls.") row_ids = candidates[torch.randint(candidates.numel(), (env_ids.numel(),), device=self.device)] self.row_ids[env_ids] = row_ids + else: + row_ids = self.row_ids[env_ids] recipes = self.recipe_ids[row_ids] target_cube_ids = self.target_cube_ids[row_ids] @@ -440,8 +386,7 @@ def __call__( joint_positions = self._robot.data.default_joint_pos.torch[env_ids].clone() joint_velocities = torch.zeros_like(joint_positions) joint_positions[:, self._arm_joint_ids] = arm_positions - finger_positions = self._finger_positions[row_ids].to(dtype=joint_positions.dtype).unsqueeze(1).expand(-1, 2) - joint_positions[:, self._finger_joint_ids] = finger_positions + joint_positions[:, self._finger_joint_ids] = self._finger_positions[row_ids, None] self._robot.actuators.target_command.set_position_index(value=joint_positions, env_ids=env_ids) self._robot.actuators.target_command.set_velocity_index(value=joint_velocities, env_ids=env_ids) self._robot.write_joint_position_to_sim_index(position=joint_positions, env_ids=env_ids) @@ -454,15 +399,10 @@ def __call__( if cube_position_noise > 0.0: base_x += (2.0 * torch.rand_like(base_x) - 1.0) * cube_position_noise - active_lower = torch.full((count,), TRANSFER_X, dtype=arm_positions.dtype, device=self.device) - active_upper = active_lower.clone() belt_rows = recipes == int(ConveyorResetRecipe.BELT) - if bool(torch.any(belt_rows)): - range_fraction = self._belt_range_fractions[row_ids] - range_lower = TRANSFER_X + range_fraction * (belt_start_x_range[0] - TRANSFER_X) - range_upper = TRANSFER_X + range_fraction * (belt_start_x_range[1] - TRANSFER_X) - active_lower[belt_rows] = range_lower[belt_rows] - active_upper[belt_rows] = range_upper[belt_rows] + range_fraction = self._belt_range_fractions[row_ids] + active_lower = TRANSFER_X + range_fraction * (belt_start_x_range[0] - TRANSFER_X) + active_upper = TRANSFER_X + range_fraction * (belt_start_x_range[1] - TRANSFER_X) active_x = _sample_collision_free_active_x( base_x, cube_sides, @@ -507,9 +447,6 @@ def __call__( cube.write_root_pose_to_sim_index(root_pose=root_pose, env_ids=env_ids) cube.write_root_velocity_to_sim_index(root_velocity=root_velocity, env_ids=env_ids) - def reset(self, env_ids: Sequence[int] | None = None) -> None: - """Keep the immutable reset table across environment resets.""" - def select_next_transfer_cube( cube_positions: torch.Tensor, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py index 8a676881d395..17e70c37d94a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/rewards.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Sparse completion rewards and acquisition checks for conveyor transfer.""" +"""Sparse completion rewards and finite action penalties for conveyor transfer.""" from __future__ import annotations @@ -13,16 +13,10 @@ from isaaclab.managers import SceneEntityCfg -from .cube_state import cube_values -from .kinematics import end_effector_pose -from .reset_events import CUBE_REST_Z - if TYPE_CHECKING: from isaaclab.assets import Articulation from isaaclab.envs import ManagerBasedRLEnv - from .commands import ConveyorTransferCommand - def transfer_success_reward( env: ManagerBasedRLEnv, @@ -63,34 +57,6 @@ def finite_action_rate_l2( return torch.sum(torch.square(action - previous_action), dim=1) -def physical_cube_acquisition_mask( - env: ManagerBasedRLEnv, - command_name: str = "transfer", - command: ConveyorTransferCommand | None = None, - minimum_lift: float = 0.025, - maximum_tool_distance: float = 0.075, - maximum_finger_position: float = 0.030, -) -> torch.Tensor: - """Return physically closed, lifted, tool-local commanded-cube grasps.""" - if minimum_lift <= 0.0 or maximum_tool_distance <= 0.0 or maximum_finger_position <= 0.0: - raise ValueError("Physical acquisition thresholds must be positive.") - if command is None: - command = env.command_manager.get_term(command_name) - positions = cube_values(env, "root_pos_w") - index = command.target_cube_ids.view(env.num_envs, 1, 1).expand(-1, 1, 3) - active_position = torch.gather(positions, 1, index).squeeze(1) - tool_position, _ = end_effector_pose(env) - robot: Articulation = env.scene["robot"] - finger_ids, _ = robot.find_joints("panda_finger_joint[1-2]", preserve_order=True) - finger_positions = robot.data.joint_pos.torch[:, finger_ids] - local_cube_z = active_position[:, 2] - env.scene.env_origins[:, 2] - return ( - (local_cube_z >= CUBE_REST_Z + minimum_lift) - & (torch.linalg.vector_norm(active_position - tool_position, dim=1) <= maximum_tool_distance) - & (torch.amax(finger_positions, dim=1) <= maximum_finger_position) - ) - - def finite_joint_velocity_l2( env: ManagerBasedRLEnv, asset_cfg: SceneEntityCfg = SceneEntityCfg("robot", joint_names=["panda_joint[1-7]"]), diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py index 0cdb30758665..8ad1fef8cb30 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py @@ -27,7 +27,6 @@ _sample_collision_free_active_x, build_reset_rows, franka_tool_position, - reset_variant_counts, ) from isaaclab_tasks.utils.parse_cfg import parse_env_cfg @@ -193,7 +192,7 @@ def test_success_guided_sampling_records_completed_selected_episodes(): def test_reset_bank_is_complete_and_physically_calibrated(): """Every command/phase is represented once; tool anchors and held metadata agree with physical poses.""" rows = build_reset_rows() - counts = reset_variant_counts() + counts = (1, 4, 6, 4, 5, 5, 7) keys = {(r.recipe, r.variant_id, r.target_cube_id, r.source_side_id) for r in rows} assert len(keys) == len(rows) == sum(counts) * 8 assert all( From 75be9a0fa99c84bf78fbbc00bd796e1a74f0dcee Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 15:12:35 +0200 Subject: [PATCH 23/45] Simplify conveyor scene geometry configuration --- .../conveyor_franka_env_cfg.py | 172 +++++------------- .../conveyor_franka/conveyor_geometry.py | 14 +- .../conveyor_franka/franka_robot_cfg.py | 16 -- 3 files changed, 56 insertions(+), 146 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py index ef7169240dad..a3d522e56d1a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py @@ -30,6 +30,8 @@ from isaaclab.utils.configclass import configclass from isaaclab.visualizers import VisualizerCfg +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec + from . import mdp from .conveyor_geometry import ( BELT_COLOR, @@ -42,13 +44,13 @@ CuboidSpec, MeshSpec, belt_collision_geometry_specs, + belt_collision_section_specs, belt_mesh_spec, guard_mesh_specs, ) from .franka_robot_cfg import FRANKA_PANDA_CONVEYOR_CFG from .mdp.terminations import invalid_action as invalid_policy_action -_DYNAMIC_PROPERTIES = sim_utils.UsdPhysicsRigidBodyCfg() _CONTACT_GAP = 0.01 _CUBE_CONTACT_MARGIN = 0.003 _MUJOCO_SOLIMP = (0.9, 0.95, 0.001, 0.5, 2.0) @@ -338,108 +340,49 @@ def _spawn_shape_with_display_color( return prim -@sim_utils.clone -def _spawn_hidden_collision_mesh( - prim_path: str, - cfg: sim_utils.MeshCustomCfg, - translation: tuple[float, float, float] | None = None, - orientation: tuple[float, float, float, float] | None = None, - **kwargs, -): - """Spawn a collision-only custom mesh and hide it from all visualizers.""" - prim = sim_utils.spawn_mesh_custom(prim_path, cfg, translation, orientation, **kwargs) - sim_utils.set_prim_visibility(prim, False) - return prim - - def _static_cuboid( prim_path: str, size: tuple[float, float, float], pos: tuple[float, float, float], color: tuple[float, float, float], - rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), - friction: float = 0.7, - roughness: float = 0.75, - metallic: float = 0.0, -) -> AssetBaseCfg: - """Build a static colliding cuboid configuration.""" - spawn = sim_utils.CuboidCfg( - size=size, - collision_props=_collision_properties(), - physics_material=RigidBodyMaterialBaseCfg( - static_friction=friction, - dynamic_friction=friction, - restitution=0.0, - ), - visual_material=sim_utils.PreviewSurfaceCfg( - diffuse_color=color, - roughness=roughness, - metallic=metallic, - ), - ) - spawn.func = _spawn_shape_with_display_color - return AssetBaseCfg( - prim_path=prim_path, - init_state=AssetBaseCfg.InitialStateCfg(pos=pos, rot=rot), - spawn=spawn, - ) - - -def _visual_mesh( - prim_path: str, - spec: MeshSpec, - color: tuple[float, float, float], - roughness: float, - metallic: float, ) -> AssetBaseCfg: - """Build a non-colliding custom mesh used only for rendering.""" + """Build a colliding table component.""" return AssetBaseCfg( prim_path=prim_path, - spawn=sim_utils.MeshCustomCfg( - vertices=spec.vertices, - faces=spec.faces, - visual_material=sim_utils.PreviewSurfaceCfg( - diffuse_color=color, - roughness=roughness, - metallic=metallic, - ), + init_state=AssetBaseCfg.InitialStateCfg(pos=pos), + spawn=sim_utils.CuboidCfg( + func=_spawn_shape_with_display_color, + size=size, + collision_props=_collision_properties(), + physics_material=RigidBodyMaterialBaseCfg(static_friction=0.7, dynamic_friction=0.7, restitution=0.0), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=color, roughness=0.75, metallic=0.0), ), ) -def _collision_material(friction: float) -> NewtonMaterialPropertiesCfg: - """Build a contact material for surfaces using the task-wide raw MuJoCo response.""" - return NewtonMaterialPropertiesCfg( - static_friction=friction, - dynamic_friction=friction, - restitution=0.0, - ) - - -def _hidden_collision_geometry( +def _conveyor_asset( prim_path: str, spec: MeshSpec | CuboidSpec, - friction: float, - mujoco_priority: int, + *, + visual_material: sim_utils.PreviewSurfaceCfg | None = None, + colliding: bool = True, ) -> AssetBaseCfg: - """Build a hidden collider, using native cuboids for straight sections.""" - properties = _collision_properties(mujoco_priority=mujoco_priority) - material = _collision_material(friction) + """Build conveyor geometry with freely sliding contacts and optional rendering.""" if isinstance(spec, CuboidSpec): - spawn = sim_utils.CuboidCfg( - size=spec.size, visible=False, collision_props=properties, physics_material=material - ) + spawn = sim_utils.CuboidCfg(size=spec.size) position = spec.position else: - spawn = sim_utils.MeshCustomCfg( - vertices=spec.vertices, - faces=spec.faces, - visible=False, - collision_props=properties, - physics_material=material, - ) - spawn.func = _spawn_hidden_collision_mesh + spawn = sim_utils.MeshCustomCfg(vertices=spec.vertices, faces=spec.faces) position = (0.0, 0.0, 0.0) + spawn.visible = visual_material is not None + spawn.visual_material = visual_material + if colliding: + spawn.collision_props = _collision_properties(mujoco_priority=1) + # Override cube/finger grasp friction at the belts and guide rails. MuJoCo + # requires positive friction; the force model supplies belt traction. + spawn.physics_material = NewtonMaterialPropertiesCfg( + static_friction=1.1e-5, dynamic_friction=1.1e-5, restitution=0.0 + ) return AssetBaseCfg( prim_path=prim_path, init_state=AssetBaseCfg.InitialStateCfg(pos=position), @@ -454,20 +397,15 @@ def _cube( ) -> RigidObjectCfg: """Build one numbered dynamic transfer cube.""" spawn = sim_utils.CuboidCfg( + func=_spawn_shape_with_display_color, size=(0.04, 0.04, 0.04), + rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), + mass_props=sim_utils.MassCfg(mass=0.05), + collision_props=_collision_properties(contact_margin=_CUBE_CONTACT_MARGIN), + # Belt priority overrides friction only for belt/cube contacts. + physics_material=NewtonMaterialPropertiesCfg(static_friction=0.8, dynamic_friction=0.6, restitution=0.0), visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=color, roughness=0.75), ) - spawn.rigid_props = _DYNAMIC_PROPERTIES - spawn.mass_props = sim_utils.MassCfg(mass=0.05) - spawn.collision_props = _collision_properties(contact_margin=_CUBE_CONTACT_MARGIN) - spawn.physics_material = NewtonMaterialPropertiesCfg( - # The belt's higher MuJoCo contact priority overrides this friction - # only for belt/cube pairs, leaving physical finger/cube friction. - static_friction=0.8, - dynamic_friction=0.6, - restitution=0.0, - ) - spawn.func = _spawn_shape_with_display_color return RigidObjectCfg( prim_path=f"{{ENV_REGEX_NS}}/{name}", init_state=RigidObjectCfg.InitialStateCfg(pos=pos), @@ -520,12 +458,11 @@ def __post_init__(self) -> None: setattr( self, f"conveyor_{side.lower()}_belt_visual", - _visual_mesh( + _conveyor_asset( prim_path=f"{{ENV_REGEX_NS}}/{belt_spec.name}", spec=belt_spec, - color=BELT_COLOR, - roughness=0.9, - metallic=0.0, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=BELT_COLOR, roughness=0.9, metallic=0.0), + colliding=False, ), ) @@ -534,14 +471,9 @@ def __post_init__(self) -> None: setattr( self, f"conveyor_{side.lower()}_{section_key}_collision", - _hidden_collision_geometry( + _conveyor_asset( prim_path=f"{{ENV_REGEX_NS}}/{spec.name}", spec=spec, - # MuJoCo requires a tiny positive value even though the force driver, - # rather than solver friction, supplies the belt motion. - friction=1.1e-5, - # Override cube friction only for collision-section/cube pairs. - mujoco_priority=1, ), ) @@ -549,30 +481,22 @@ def __post_init__(self) -> None: boundary = "inner" if spec.name.endswith("Inner") else "outer" setattr( self, - f"guard_{side.lower()}_{boundary}_visual", - _visual_mesh( - prim_path=f"{{ENV_REGEX_NS}}/{spec.name}Visual", - spec=spec, - color=GUARD_COLOR, - roughness=0.3, - metallic=0.8, - ), - ) - setattr( - self, - f"guard_{side.lower()}_{boundary}_collision", - _hidden_collision_geometry( + f"guard_{side.lower()}_{boundary}", + _conveyor_asset( prim_path=f"{{ENV_REGEX_NS}}/{spec.name}Collision", spec=spec, - # The compact turns need freely sliding guide contacts; - # tangential rail friction can wedge a cube against the - # wall even though its belt drive remains valid. - friction=1.1e-5, - # Override the cube's grasp friction only for rail contacts. - mujoco_priority=1, + visual_material=sim_utils.PreviewSurfaceCfg( + diffuse_color=GUARD_COLOR, roughness=0.3, metallic=0.8 + ), ), ) + def build_conveyor_belt_specs(self, **kwargs) -> tuple[SurfaceVelocitySpec, ...]: + """Describe the velocity field on each authored collision section.""" + return tuple( + section.belt for side in ("Left", "Right") for section in belt_collision_section_specs(side, **kwargs) + ) + @configclass class ConveyorFrankaEnvCfg(ManagerBasedRLEnvCfg): diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py index 555cc08ed755..eca495f9563e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py @@ -19,6 +19,8 @@ """Brushed-metal color used by Newton's conveyor example.""" PARCEL_COLOR = (0.72, 0.55, 0.35) +"""Cardboard color used by Newton's conveyor example.""" + CUBE_COLORS = ( (0.15, 0.35, 0.90), (0.90, 0.20, 0.15), @@ -26,7 +28,6 @@ PARCEL_COLOR, ) """Stable sRGB colors for the four numbered transfer cubes.""" -"""Cardboard color used by Newton's conveyor example.""" BELT_CENTER_X = 0.58 BELT_CENTER_Y = 0.51 @@ -155,11 +156,12 @@ def _racetrack_prism_mesh( outer_offset = lateral_offset + half_width inner_offset = lateral_offset - half_width - outer_top = [(x + nx * outer_offset, y + ny * outer_offset, z_max) for x, y, nx, ny in centerline] - inner_top = [(x + nx * inner_offset, y + ny * inner_offset, z_max) for x, y, nx, ny in centerline] - outer_bottom = [(x + nx * outer_offset, y + ny * outer_offset, z_min) for x, y, nx, ny in centerline] - inner_bottom = [(x + nx * inner_offset, y + ny * inner_offset, z_min) for x, y, nx, ny in centerline] - points = tuple(inner_top + outer_top + inner_bottom + outer_bottom) + points = tuple( + (x + nx * offset, y + ny * offset, z) + for z in (z_max, z_min) + for offset in (inner_offset, outer_offset) + for x, y, nx, ny in centerline + ) # Racetrack centerlines are clockwise; reverse the shared counter-clockwise winding. faces = tuple((a, c, b) for a, b, c in _prism_faces(len(centerline), closed=True)) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py index 12a0adba8338..aaa4e4b01ce5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py @@ -6,7 +6,6 @@ """Task-calibrated Franka configuration for conveyor manipulation.""" from isaaclab_newton.sim.schemas import MujocoJointCfg -from isaaclab_physx.sim.schemas import PhysxArticulationCfg from isaaclab.actuators import ImplicitActuatorCfg @@ -52,18 +51,3 @@ ), } """Menagerie Franka with explicit manipulation gains and solver-native gravity compensation.""" - - -FRANKA_PANDA_CONVEYOR_PHYSX_CFG = FRANKA_PANDA_CONVEYOR_CFG.copy() -# PhysX does not consume MuJoCo's actuator-gravity-compensation attribute. Disabling -# gravity on the robot is the closest solver-native equivalent and keeps the trained -# position-policy contract unchanged without adding a task-side effort loop. -FRANKA_PANDA_CONVEYOR_PHYSX_CFG.spawn.rigid_props.disable_gravity = True -FRANKA_PANDA_CONVEYOR_PHYSX_CFG.spawn.joint_drive_props = None -# Contact-rich manipulation benefits from resolving the articulation for more than -# the generic asset defaults, especially with the deliberately stiff trained gains. -for _properties in FRANKA_PANDA_CONVEYOR_PHYSX_CFG.spawn.articulation_props: - if isinstance(_properties, PhysxArticulationCfg): - _properties.solver_position_iteration_count = 32 - _properties.solver_velocity_iteration_count = 4 -"""PhysX variant with the same joints, gains, action ordering, and gravity-compensated policy contract.""" From 17994b603e52298ddd2e9f9b6695e7aeb52b6c13 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 15:12:35 +0200 Subject: [PATCH 24/45] Simplify conveyor scene geometry configuration --- .../conveyor_franka_env_cfg.py | 172 +++++------------- .../conveyor_franka/conveyor_geometry.py | 14 +- .../conveyor_franka/franka_robot_cfg.py | 16 -- 3 files changed, 56 insertions(+), 146 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py index ef7169240dad..a3d522e56d1a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py @@ -30,6 +30,8 @@ from isaaclab.utils.configclass import configclass from isaaclab.visualizers import VisualizerCfg +from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec + from . import mdp from .conveyor_geometry import ( BELT_COLOR, @@ -42,13 +44,13 @@ CuboidSpec, MeshSpec, belt_collision_geometry_specs, + belt_collision_section_specs, belt_mesh_spec, guard_mesh_specs, ) from .franka_robot_cfg import FRANKA_PANDA_CONVEYOR_CFG from .mdp.terminations import invalid_action as invalid_policy_action -_DYNAMIC_PROPERTIES = sim_utils.UsdPhysicsRigidBodyCfg() _CONTACT_GAP = 0.01 _CUBE_CONTACT_MARGIN = 0.003 _MUJOCO_SOLIMP = (0.9, 0.95, 0.001, 0.5, 2.0) @@ -338,108 +340,49 @@ def _spawn_shape_with_display_color( return prim -@sim_utils.clone -def _spawn_hidden_collision_mesh( - prim_path: str, - cfg: sim_utils.MeshCustomCfg, - translation: tuple[float, float, float] | None = None, - orientation: tuple[float, float, float, float] | None = None, - **kwargs, -): - """Spawn a collision-only custom mesh and hide it from all visualizers.""" - prim = sim_utils.spawn_mesh_custom(prim_path, cfg, translation, orientation, **kwargs) - sim_utils.set_prim_visibility(prim, False) - return prim - - def _static_cuboid( prim_path: str, size: tuple[float, float, float], pos: tuple[float, float, float], color: tuple[float, float, float], - rot: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), - friction: float = 0.7, - roughness: float = 0.75, - metallic: float = 0.0, -) -> AssetBaseCfg: - """Build a static colliding cuboid configuration.""" - spawn = sim_utils.CuboidCfg( - size=size, - collision_props=_collision_properties(), - physics_material=RigidBodyMaterialBaseCfg( - static_friction=friction, - dynamic_friction=friction, - restitution=0.0, - ), - visual_material=sim_utils.PreviewSurfaceCfg( - diffuse_color=color, - roughness=roughness, - metallic=metallic, - ), - ) - spawn.func = _spawn_shape_with_display_color - return AssetBaseCfg( - prim_path=prim_path, - init_state=AssetBaseCfg.InitialStateCfg(pos=pos, rot=rot), - spawn=spawn, - ) - - -def _visual_mesh( - prim_path: str, - spec: MeshSpec, - color: tuple[float, float, float], - roughness: float, - metallic: float, ) -> AssetBaseCfg: - """Build a non-colliding custom mesh used only for rendering.""" + """Build a colliding table component.""" return AssetBaseCfg( prim_path=prim_path, - spawn=sim_utils.MeshCustomCfg( - vertices=spec.vertices, - faces=spec.faces, - visual_material=sim_utils.PreviewSurfaceCfg( - diffuse_color=color, - roughness=roughness, - metallic=metallic, - ), + init_state=AssetBaseCfg.InitialStateCfg(pos=pos), + spawn=sim_utils.CuboidCfg( + func=_spawn_shape_with_display_color, + size=size, + collision_props=_collision_properties(), + physics_material=RigidBodyMaterialBaseCfg(static_friction=0.7, dynamic_friction=0.7, restitution=0.0), + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=color, roughness=0.75, metallic=0.0), ), ) -def _collision_material(friction: float) -> NewtonMaterialPropertiesCfg: - """Build a contact material for surfaces using the task-wide raw MuJoCo response.""" - return NewtonMaterialPropertiesCfg( - static_friction=friction, - dynamic_friction=friction, - restitution=0.0, - ) - - -def _hidden_collision_geometry( +def _conveyor_asset( prim_path: str, spec: MeshSpec | CuboidSpec, - friction: float, - mujoco_priority: int, + *, + visual_material: sim_utils.PreviewSurfaceCfg | None = None, + colliding: bool = True, ) -> AssetBaseCfg: - """Build a hidden collider, using native cuboids for straight sections.""" - properties = _collision_properties(mujoco_priority=mujoco_priority) - material = _collision_material(friction) + """Build conveyor geometry with freely sliding contacts and optional rendering.""" if isinstance(spec, CuboidSpec): - spawn = sim_utils.CuboidCfg( - size=spec.size, visible=False, collision_props=properties, physics_material=material - ) + spawn = sim_utils.CuboidCfg(size=spec.size) position = spec.position else: - spawn = sim_utils.MeshCustomCfg( - vertices=spec.vertices, - faces=spec.faces, - visible=False, - collision_props=properties, - physics_material=material, - ) - spawn.func = _spawn_hidden_collision_mesh + spawn = sim_utils.MeshCustomCfg(vertices=spec.vertices, faces=spec.faces) position = (0.0, 0.0, 0.0) + spawn.visible = visual_material is not None + spawn.visual_material = visual_material + if colliding: + spawn.collision_props = _collision_properties(mujoco_priority=1) + # Override cube/finger grasp friction at the belts and guide rails. MuJoCo + # requires positive friction; the force model supplies belt traction. + spawn.physics_material = NewtonMaterialPropertiesCfg( + static_friction=1.1e-5, dynamic_friction=1.1e-5, restitution=0.0 + ) return AssetBaseCfg( prim_path=prim_path, init_state=AssetBaseCfg.InitialStateCfg(pos=position), @@ -454,20 +397,15 @@ def _cube( ) -> RigidObjectCfg: """Build one numbered dynamic transfer cube.""" spawn = sim_utils.CuboidCfg( + func=_spawn_shape_with_display_color, size=(0.04, 0.04, 0.04), + rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), + mass_props=sim_utils.MassCfg(mass=0.05), + collision_props=_collision_properties(contact_margin=_CUBE_CONTACT_MARGIN), + # Belt priority overrides friction only for belt/cube contacts. + physics_material=NewtonMaterialPropertiesCfg(static_friction=0.8, dynamic_friction=0.6, restitution=0.0), visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=color, roughness=0.75), ) - spawn.rigid_props = _DYNAMIC_PROPERTIES - spawn.mass_props = sim_utils.MassCfg(mass=0.05) - spawn.collision_props = _collision_properties(contact_margin=_CUBE_CONTACT_MARGIN) - spawn.physics_material = NewtonMaterialPropertiesCfg( - # The belt's higher MuJoCo contact priority overrides this friction - # only for belt/cube pairs, leaving physical finger/cube friction. - static_friction=0.8, - dynamic_friction=0.6, - restitution=0.0, - ) - spawn.func = _spawn_shape_with_display_color return RigidObjectCfg( prim_path=f"{{ENV_REGEX_NS}}/{name}", init_state=RigidObjectCfg.InitialStateCfg(pos=pos), @@ -520,12 +458,11 @@ def __post_init__(self) -> None: setattr( self, f"conveyor_{side.lower()}_belt_visual", - _visual_mesh( + _conveyor_asset( prim_path=f"{{ENV_REGEX_NS}}/{belt_spec.name}", spec=belt_spec, - color=BELT_COLOR, - roughness=0.9, - metallic=0.0, + visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=BELT_COLOR, roughness=0.9, metallic=0.0), + colliding=False, ), ) @@ -534,14 +471,9 @@ def __post_init__(self) -> None: setattr( self, f"conveyor_{side.lower()}_{section_key}_collision", - _hidden_collision_geometry( + _conveyor_asset( prim_path=f"{{ENV_REGEX_NS}}/{spec.name}", spec=spec, - # MuJoCo requires a tiny positive value even though the force driver, - # rather than solver friction, supplies the belt motion. - friction=1.1e-5, - # Override cube friction only for collision-section/cube pairs. - mujoco_priority=1, ), ) @@ -549,30 +481,22 @@ def __post_init__(self) -> None: boundary = "inner" if spec.name.endswith("Inner") else "outer" setattr( self, - f"guard_{side.lower()}_{boundary}_visual", - _visual_mesh( - prim_path=f"{{ENV_REGEX_NS}}/{spec.name}Visual", - spec=spec, - color=GUARD_COLOR, - roughness=0.3, - metallic=0.8, - ), - ) - setattr( - self, - f"guard_{side.lower()}_{boundary}_collision", - _hidden_collision_geometry( + f"guard_{side.lower()}_{boundary}", + _conveyor_asset( prim_path=f"{{ENV_REGEX_NS}}/{spec.name}Collision", spec=spec, - # The compact turns need freely sliding guide contacts; - # tangential rail friction can wedge a cube against the - # wall even though its belt drive remains valid. - friction=1.1e-5, - # Override the cube's grasp friction only for rail contacts. - mujoco_priority=1, + visual_material=sim_utils.PreviewSurfaceCfg( + diffuse_color=GUARD_COLOR, roughness=0.3, metallic=0.8 + ), ), ) + def build_conveyor_belt_specs(self, **kwargs) -> tuple[SurfaceVelocitySpec, ...]: + """Describe the velocity field on each authored collision section.""" + return tuple( + section.belt for side in ("Left", "Right") for section in belt_collision_section_specs(side, **kwargs) + ) + @configclass class ConveyorFrankaEnvCfg(ManagerBasedRLEnvCfg): diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py index 555cc08ed755..eca495f9563e 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py @@ -19,6 +19,8 @@ """Brushed-metal color used by Newton's conveyor example.""" PARCEL_COLOR = (0.72, 0.55, 0.35) +"""Cardboard color used by Newton's conveyor example.""" + CUBE_COLORS = ( (0.15, 0.35, 0.90), (0.90, 0.20, 0.15), @@ -26,7 +28,6 @@ PARCEL_COLOR, ) """Stable sRGB colors for the four numbered transfer cubes.""" -"""Cardboard color used by Newton's conveyor example.""" BELT_CENTER_X = 0.58 BELT_CENTER_Y = 0.51 @@ -155,11 +156,12 @@ def _racetrack_prism_mesh( outer_offset = lateral_offset + half_width inner_offset = lateral_offset - half_width - outer_top = [(x + nx * outer_offset, y + ny * outer_offset, z_max) for x, y, nx, ny in centerline] - inner_top = [(x + nx * inner_offset, y + ny * inner_offset, z_max) for x, y, nx, ny in centerline] - outer_bottom = [(x + nx * outer_offset, y + ny * outer_offset, z_min) for x, y, nx, ny in centerline] - inner_bottom = [(x + nx * inner_offset, y + ny * inner_offset, z_min) for x, y, nx, ny in centerline] - points = tuple(inner_top + outer_top + inner_bottom + outer_bottom) + points = tuple( + (x + nx * offset, y + ny * offset, z) + for z in (z_max, z_min) + for offset in (inner_offset, outer_offset) + for x, y, nx, ny in centerline + ) # Racetrack centerlines are clockwise; reverse the shared counter-clockwise winding. faces = tuple((a, c, b) for a, b, c in _prism_faces(len(centerline), closed=True)) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py index 12a0adba8338..aaa4e4b01ce5 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/franka_robot_cfg.py @@ -6,7 +6,6 @@ """Task-calibrated Franka configuration for conveyor manipulation.""" from isaaclab_newton.sim.schemas import MujocoJointCfg -from isaaclab_physx.sim.schemas import PhysxArticulationCfg from isaaclab.actuators import ImplicitActuatorCfg @@ -52,18 +51,3 @@ ), } """Menagerie Franka with explicit manipulation gains and solver-native gravity compensation.""" - - -FRANKA_PANDA_CONVEYOR_PHYSX_CFG = FRANKA_PANDA_CONVEYOR_CFG.copy() -# PhysX does not consume MuJoCo's actuator-gravity-compensation attribute. Disabling -# gravity on the robot is the closest solver-native equivalent and keeps the trained -# position-policy contract unchanged without adding a task-side effort loop. -FRANKA_PANDA_CONVEYOR_PHYSX_CFG.spawn.rigid_props.disable_gravity = True -FRANKA_PANDA_CONVEYOR_PHYSX_CFG.spawn.joint_drive_props = None -# Contact-rich manipulation benefits from resolving the articulation for more than -# the generic asset defaults, especially with the deliberately stiff trained gains. -for _properties in FRANKA_PANDA_CONVEYOR_PHYSX_CFG.spawn.articulation_props: - if isinstance(_properties, PhysxArticulationCfg): - _properties.solver_position_iteration_count = 32 - _properties.solver_velocity_iteration_count = 4 -"""PhysX variant with the same joints, gains, action ordering, and gravity-compensated policy contract.""" From e7050b2b7ef89846348ec6d4715b945aa2e1cc74 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 15:13:26 +0200 Subject: [PATCH 25/45] Extract optional PhysX support from the Newton conveyor task --- .../source/_static/css/environment-browser.js | 1 - .../maximiliank-conveyor-racetrack.added.rst | 2 +- .../contrib/conveyor_franka/README.md | 45 +-- .../contrib/conveyor_franka/__init__.py | 15 +- .../conveyor_franka/conveyor_franka_env.py | 89 ++--- .../conveyor_franka_physx_env_cfg.py | 303 ------------------ source/isaaclab_tasks/pyproject.toml | 2 - .../contrib/test_contrib_environments_kit.py | 2 +- .../contrib/test_conveyor_franka_physx_cfg.py | 48 --- source/isaaclab_tasks/test/env_test_utils.py | 8 +- uv.lock | 2 - 11 files changed, 31 insertions(+), 486 deletions(-) delete mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py delete mode 100644 source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py diff --git a/docs/source/_static/css/environment-browser.js b/docs/source/_static/css/environment-browser.js index 5965673413ff..944c71b1c257 100644 --- a/docs/source/_static/css/environment-browser.js +++ b/docs/source/_static/css/environment-browser.js @@ -61,7 +61,6 @@ ["IsaacContrib-AutoMate-Disassembly-Direct", "rl_games", "", "", "", "environment-previews/automate_disassembly.jpg"], ["IsaacContrib-Cartpole-Camera-Showcase-Direct", "skrl", "", "", "box_box,box_discrete,box_multidiscrete,dict_box,dict_discrete,dict_multidiscrete,tuple_box,tuple_discrete,tuple_multidiscrete"], ["IsaacContrib-Cartpole-Showcase-Direct", "skrl", "isaacsim_physx,newton_kamino,newton_mjwarp,ovphysx", "", "box_box,box_discrete,box_multidiscrete,dict_box,dict_discrete,dict_multidiscrete,discrete_box,discrete_discrete,discrete_multidiscrete,multidiscrete_box,multidiscrete_discrete,multidiscrete_multidiscrete,tuple_box,tuple_discrete,tuple_multidiscrete", "environment-previews/cartpole.jpg"], - ["IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0", "rsl_rl", "", "", "", "./tasks/conveyor/racetrack_transfer.jpg"], ["IsaacContrib-Conveyor-Racetrack-Transfer-v0", "rsl_rl", "", "", "", "./tasks/conveyor/racetrack_transfer.jpg"], ["IsaacContrib-Deploy-GearAssembly-Rizon4s-Grav", "rsl_rl", "", "", "", "environment-previews/rizon4s_gear_assembly.jpg"], ["IsaacContrib-Deploy-GearAssembly-Rizon4s-Grav-ROS-Inference", "rsl_rl", "", "", "", "environment-previews/rizon4s_gear_assembly.jpg"], diff --git a/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst index 5c44c350b4f9..971fa697b75c 100644 --- a/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst +++ b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst @@ -1 +1 @@ -* Added four-cube conveyor racetrack transfer with Newton and native CPU PhysX backends, a shared pretrained policy, reset curriculum, and seeded evaluation. +* Added Newton four-cube conveyor racetrack transfer with a pretrained policy, reset curriculum, and seeded evaluation. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md index 6f93e7e7ef22..f5a5b94d9043 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md @@ -6,29 +6,11 @@ with 120 Hz physics and a 0.35 m/s belt speed. ![Racetrack transfer](../../../../../docs/source/_static/tasks/conveyor/racetrack_transfer.jpg) -## Backend support +Newton traction uses the upstream `ConveyorForceModel`, including its MuJoCo contact +reporting. `isaaclab_contrib.conveyors.newton` adapts replicated surfaces, parcel selection, +controls, and reset lifecycle; the task owns its geometry, commands, and reset recipes. -The racetrack task runs on Newton GPU. `IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0` provides a CPU-only -native PhysX reference for the original four-cube racetrack task. - -The PhysX task rejects CUDA during configuration validation. In the supported Isaac Sim runtime, -enabling the native surface-velocity contact-modification path under GPU dynamics can drop the belt -contacts and let packages pass through the conveyor. CPU PhysX preserves those contacts. Use the -Newton task whenever GPU simulation or vectorized throughput is required. - -The two backends deliberately share the policy tensor contract, so an RSL-RL checkpoint can be -loaded by either task without reshaping or reordering tensors. Their contact and actuator dynamics -are not numerically identical; validate task behavior when transferring a policy between them. - -Surface-velocity intent and the tensorized control contract live in -`isaaclab_contrib.conveyors.surface_velocity`. Newton traction uses the complete upstream -`ConveyorForceModel` from the pinned Newton release, including its MuJoCo contact reporting. -`isaaclab_contrib.conveyors.newton` adapts replicated surfaces, parcel selection, controls, and -reset lifecycle; it contains no separate traction solver. -PhysX schema authoring and live attribute control live in `isaaclab_contrib.conveyors.physx`. -The task package owns only the racetrack geometry, backend lifecycle selection, and task-level commands. - -## Original racetrack task +## Run Newton is kitless and supports the lightweight GL viewer: @@ -42,8 +24,6 @@ DISPLAY=:1 uv run isaaclab play --rl_library rsl_rl \ The `pretrained` selector downloads the RSL-RL policy published specifically for the Newton MJWarp backend. The Newton checkpoint is published on Isaac Dev. While the public mirror is syncing, use a local checkpoint path if the download is unavailable. -The PhysX task resolves a different backend-specific artifact name, so transferring this Newton policy -to PhysX currently requires the explicit local checkpoint path shown below. Training uses the same task ID and defaults to 256 environments: @@ -78,20 +58,3 @@ uv run python -m isaaclab_tasks.contrib.conveyor_franka.evaluate \ The report counts completed transfers in both directions, safety failures, non-finite observations, and transfers per simulated environment minute. Repeat with different seeds when comparing policies; intermediate-reset training reward alone does not establish deployment reliability. - -## PhysX CPU playback - -The native PhysX variant requires an Isaac Sim-enabled launch and an explicit CPU device. One -environment is the default and recommended interactive configuration: - -```bash -DISPLAY=:1 uv run isaaclab play --rl_library rsl_rl \ - --task IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0 \ - --checkpoint /path/to/model.pt \ - --num_envs 1 --device cpu --viz kit --real-time \ - agent.device=cpu -``` - -Overriding the task to CUDA is an error by design; keep the explicit `--device cpu` in launch -commands for clarity. The native surface-velocity backend stages commands through USD, while the -Newton backend keeps its batched state, contact processing, and force application on the GPU. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py index bcb810f13ff1..d7d4cf571772 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/__init__.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Four-cube racetrack transfer with Newton and native CPU PhysX backends.""" +"""Four-cube racetrack transfer with Newton.""" import gymnasium as gym @@ -18,16 +18,3 @@ "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:ConveyorFrankaPPORunnerCfg", }, ) - -gym.register( - # The native PhysxSurfaceVelocityAPI path is intentionally CPU-only. Keep - # that execution contract visible in the public task ID so a CUDA launch is - # never mistaken for a supported configuration. - id="IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0", - entry_point=f"{__name__}.conveyor_franka_env:ConveyorFrankaEnv", - disable_env_checker=True, - kwargs={ - "env_cfg_entry_point": f"{__name__}.conveyor_franka_physx_env_cfg:ConveyorFrankaPhysxEnvCfg", - "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:ConveyorFrankaPPORunnerCfg", - }, -) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py index 1fe1b2bdffa6..440c749c7100 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Manager-based environment that installs the selected conveyor physics adapter.""" +"""Manager-based conveyor environment with Newton contact forces.""" from __future__ import annotations @@ -11,95 +11,46 @@ from isaaclab.envs import ManagerBasedRLEnv -from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocityView +from isaaclab_contrib.conveyors.newton import SurfaceVelocity from .conveyor_franka_env_cfg import ConveyorFrankaEnvCfg -from .conveyor_geometry import belt_collision_section_specs from .conveyor_goal_selector import ConveyorGoalSelector class ConveyorFrankaEnv(ManagerBasedRLEnv): - """Manager-based environment with backend-native conveyor surfaces.""" + """Manager-based environment with Newton conveyor surfaces.""" cfg: ConveyorFrankaEnvCfg def __init__(self, cfg: ConveyorFrankaEnvCfg, render_mode: str | None = None, **kwargs): - self._conveyor_driver: SurfaceVelocityView | None = None + self._conveyor_driver: SurfaceVelocity | None = None super().__init__(cfg, render_mode=render_mode, **kwargs) - self._goal_selector: ConveyorGoalSelector | None = None self._setup_goal_selector() def _init_sim(self) -> None: - """Install the conveyor adapter at the lifecycle point required by its backend.""" - belt_spec_kwargs = { - "velocity": self.cfg.conveyor_force.speed, - "friction_coefficient": self.cfg.conveyor_force.friction, - "contact_threshold": self.cfg.conveyor_force.normal_threshold, - } - spec_builder = getattr(self.cfg.scene, "build_conveyor_belt_specs", None) - if spec_builder is None: - belt_specs = tuple( - section.belt - for side in ("Left", "Right") - for section in belt_collision_section_specs(side, **belt_spec_kwargs) - ) - else: - belt_specs = tuple(spec_builder(**belt_spec_kwargs)) - env_path_format = self.cfg.scene.clone_cfg.clone_template - - # Newton needs solved-contact attributes and graph callbacks registered - # before the first reset finalizes and captures the solver. PhysX belt - # schemas, by contrast, are authored by the scene spawners and its live - # command adapter is attached only after PhysX has parsed that scene. - from isaaclab_newton.physics import NewtonCfg - - if isinstance(self.cfg.sim.physics, NewtonCfg): - from isaaclab_contrib.conveyors.newton import SurfaceVelocity as NewtonSurfaceVelocity - - driver = NewtonSurfaceVelocity( - num_envs=self.cfg.scene.num_envs, - surface_specs=belt_specs, - startup_duration_s=self.cfg.conveyor_force.startup_duration_s, - env_path_format=env_path_format, - body_pattern=self.cfg.conveyor_force.transported_body_pattern, - body_count_per_env=self.cfg.conveyor_force.transported_body_count_per_env, - ) - self._conveyor_driver = driver - try: - super()._init_sim() - except Exception: - driver.close() - self._conveyor_driver = None - raise - return - - from isaaclab_physx.physics import PhysxCfg - - from isaaclab_contrib.conveyors.physx import SurfaceVelocity as PhysxSurfaceVelocity - - if not isinstance(self.cfg.sim.physics, PhysxCfg): - raise ValueError(f"Unsupported conveyor physics backend: {type(self.cfg.sim.physics).__name__}.") - - configure_conveyor = getattr(self.cfg.scene, "configure_conveyor", None) - if configure_conveyor is not None: - configure_conveyor(friction_coefficient=self.cfg.conveyor_force.friction) - super()._init_sim() - driver = PhysxSurfaceVelocity( + """Register solved-contact hooks before the first reset captures the solver.""" + driver = SurfaceVelocity( num_envs=self.cfg.scene.num_envs, - surface_specs=belt_specs, - env_path_format=env_path_format, + surface_specs=self.cfg.scene.build_conveyor_belt_specs( + velocity=self.cfg.conveyor_force.speed, + friction_coefficient=self.cfg.conveyor_force.friction, + contact_threshold=self.cfg.conveyor_force.normal_threshold, + ), startup_duration_s=self.cfg.conveyor_force.startup_duration_s, - stage=self.sim.stage, + env_path_format=self.cfg.scene.clone_cfg.clone_template, + body_pattern=self.cfg.conveyor_force.transported_body_pattern, + body_count_per_env=self.cfg.conveyor_force.transported_body_count_per_env, ) + self._conveyor_driver = driver try: - driver.start() + super()._init_sim() except Exception: driver.close() + self._conveyor_driver = None raise - self._conveyor_driver = driver @property - def conveyor_belt(self) -> SurfaceVelocityView: + def conveyor_belt(self) -> SurfaceVelocity: """Tensorized conveyor control view for this environment.""" driver = self._conveyor_driver if driver is None: @@ -116,8 +67,8 @@ def _setup_goal_selector(self) -> None: continue visible_env_ids = visualizer.get_visualized_env_ids() env_id = visible_env_ids[0] if visible_env_ids else 0 - self._goal_selector = ConveyorGoalSelector(self, env_id) - register_callback(self._goal_selector.render, position="panel") + selector = ConveyorGoalSelector(self, env_id) + register_callback(selector.render, position="panel") return def _reset_idx(self, env_ids: Sequence[int] | slice): diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py deleted file mode 100644 index 6dbbc07a21fb..000000000000 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_physx_env_cfg.py +++ /dev/null @@ -1,303 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""CPU-only native-PhysX configuration for conveyor-Franka policy playback.""" - -from __future__ import annotations - -import functools -from dataclasses import replace - -from isaaclab_physx.physics import PhysxCfg -from isaaclab_physx.sim.schemas import PhysxCollisionCfg, PhysxRigidBodyCfg, PhysxSDFMeshCfg -from isaaclab_physx.sim.spawners.materials import PhysxRigidBodyMaterialCfg - -import isaaclab.sim as sim_utils -from isaaclab.assets import AssetBaseCfg -from isaaclab.sim import SimulationCfg -from isaaclab.sim.schemas import CollisionFragment, UsdPhysicsCollisionCfg -from isaaclab.utils.configclass import configclass - -from isaaclab_contrib.conveyors.physx import apply_surface_velocity_api -from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec - -from .conveyor_franka_env_cfg import ( - _CONTACT_GAP, - _CUBE_CONTACT_MARGIN, - _ROBOT_INITIAL_STATE, - ConveyorFrankaEnvCfg, - ConveyorFrankaSceneCfg, - _spawn_hidden_collision_mesh, - _validate_common_config, -) -from .conveyor_geometry import ( - ConveyorSectionSpec, - CuboidSpec, - MeshSpec, - belt_collision_section_specs, - guard_mesh_specs, -) -from .franka_robot_cfg import FRANKA_PANDA_CONVEYOR_PHYSX_CFG - -_PHYSX_KINEMATIC_PROPERTIES = [ - sim_utils.UsdPhysicsRigidBodyCfg(rigid_body_enabled=True, kinematic_enabled=True), - PhysxRigidBodyCfg(disable_gravity=True), -] - - -def _physx_collision_properties(contact_offset: float = 0.005) -> list[CollisionFragment]: - """Build the standard collision and PhysX offset fragments for one collider.""" - return [ - UsdPhysicsCollisionCfg(collision_enabled=True), - PhysxCollisionCfg(contact_offset=contact_offset, rest_offset=0.0), - ] - - -def _physx_material(friction: float) -> PhysxRigidBodyMaterialCfg: - """Build a deterministic PhysX material for a conveyor-task collision surface.""" - return PhysxRigidBodyMaterialCfg( - static_friction=friction, - dynamic_friction=friction, - restitution=0.0, - friction_combine_mode="min", - restitution_combine_mode="min", - ) - - -def _pivot_centered_section(section: ConveyorSectionSpec) -> tuple[ConveyorSectionSpec, tuple[float, float, float]]: - """Express a curved section around its rigid-body origin for native angular surface velocity.""" - if not section.belt.curved: - geometry = section.geometry - if not isinstance(geometry, CuboidSpec): - raise TypeError("Straight PhysX conveyor sections must use native cuboids.") - return section, geometry.position - - geometry = section.geometry - if not isinstance(geometry, MeshSpec): - raise TypeError("Curved PhysX conveyor sections must use closed meshes.") - pivot = section.belt.pivot_point - local_geometry = MeshSpec( - name=geometry.name, - vertices=tuple( - (vertex[0] - pivot[0], vertex[1] - pivot[1], vertex[2] - pivot[2]) for vertex in geometry.vertices - ), - faces=geometry.faces, - ) - local_belt = replace(section.belt, pivot_point=(0.0, 0.0, 0.0)) - return ConveyorSectionSpec(geometry=local_geometry, belt=local_belt), pivot - - -def physx_belt_section_specs( - side: str, - *, - velocity: float = 0.0, - friction_coefficient: float = 0.5, - contact_threshold: float = 0.997, -) -> tuple[tuple[ConveyorSectionSpec, tuple[float, float, float]], ...]: - """Return pivot-centered sections sharing the exact runtime PhysX semantics.""" - return tuple( - _pivot_centered_section(section) - for section in belt_collision_section_specs( - side, - velocity=velocity, - friction_coefficient=friction_coefficient, - contact_threshold=contact_threshold, - ) - ) - - -@sim_utils.clone -def _spawn_physx_conveyor( - prim_path: str, - cfg: sim_utils.MeshCustomCfg | sim_utils.CuboidCfg, - translation: tuple[float, float, float] | None = None, - orientation: tuple[float, float, float, float] | None = None, - *, - belt_spec: SurfaceVelocitySpec, - **kwargs, -): - """Spawn a hidden belt section and author native surface velocity before PhysX parsing.""" - spawn = sim_utils.spawn_mesh_custom if isinstance(cfg, sim_utils.MeshCustomCfg) else sim_utils.spawn_cuboid - prim = spawn(prim_path, cfg, translation, orientation, **kwargs) - sim_utils.set_prim_visibility(prim, False) - apply_surface_velocity_api(prim, belt_spec, velocity_scale=0.0) - return prim - - -def _physx_conveyor_collision( - prim_path: str, - section: ConveyorSectionSpec, - root_position: tuple[float, float, float], - friction: float, -) -> AssetBaseCfg: - """Build one native-velocity belt section with analytic or watertight-SDF collision.""" - geometry = section.geometry - if isinstance(geometry, CuboidSpec): - spawn = sim_utils.CuboidCfg( - size=geometry.size, - visible=False, - rigid_props=_PHYSX_KINEMATIC_PROPERTIES, - collision_props=_physx_collision_properties(contact_offset=_CONTACT_GAP), - physics_material=_physx_material(friction), - ) - else: - spawn = sim_utils.MeshCustomCfg( - vertices=geometry.vertices, - faces=geometry.faces, - visible=False, - rigid_props=_PHYSX_KINEMATIC_PROPERTIES, - collision_props=_physx_collision_properties(contact_offset=_CONTACT_GAP), - mesh_collision_props=PhysxSDFMeshCfg(sdf_resolution=128, sdf_subgrid_resolution=6), - physics_material=_physx_material(friction), - ) - spawn.func = functools.partial(_spawn_physx_conveyor, belt_spec=section.belt) - return AssetBaseCfg( - prim_path=prim_path, - init_state=AssetBaseCfg.InitialStateCfg(pos=root_position), - spawn=spawn, - ) - - -def _physx_guard_collision(prim_path: str, spec: MeshSpec) -> AssetBaseCfg: - """Build one freely sliding static guide collider.""" - spawn = sim_utils.MeshCustomCfg( - vertices=spec.vertices, - faces=spec.faces, - visible=False, - collision_props=_physx_collision_properties(), - physics_material=_physx_material(1.1e-5), - ) - spawn.func = _spawn_hidden_collision_mesh - return AssetBaseCfg(prim_path=prim_path, spawn=spawn) - - -@configclass -class ConveyorFrankaPhysxSceneCfg(ConveyorFrankaSceneCfg): - """PhysX scene preserving the Newton task's names, layout, and tensor contracts.""" - - robot = FRANKA_PANDA_CONVEYOR_PHYSX_CFG.replace( - prim_path="{ENV_REGEX_NS}/Robot", - init_state=_ROBOT_INITIAL_STATE, - ) - - def __post_init__(self) -> None: - """Reuse the racetrack layout and visuals, replacing only backend physics properties.""" - super().__post_init__() - for support in (self.tabletop, self.table_pedestal): - support.spawn.collision_props = _physx_collision_properties() - support.spawn.physics_material = _physx_material(0.7) - for cube_id in range(4): - cube = getattr(self, f"cube_{cube_id}") - cube.spawn.collision_props = _physx_collision_properties(contact_offset=_CUBE_CONTACT_MARGIN) - cube.spawn.physics_material = PhysxRigidBodyMaterialCfg( - static_friction=0.8, - dynamic_friction=0.6, - restitution=0.0, - restitution_combine_mode="min", - ) - for side in ("Left", "Right"): - section_keys = ("top_straight", "bottom_straight", "right_turn", "left_turn") - for section_key, (section, root_position) in zip(section_keys, physx_belt_section_specs(side), strict=True): - setattr( - self, - f"conveyor_{side.lower()}_{section_key}_collision", - _physx_conveyor_collision( - prim_path=f"{{ENV_REGEX_NS}}/{section.geometry.name}", - section=section, - root_position=root_position, - friction=0.5, - ), - ) - - for guard in guard_mesh_specs(side): - boundary = "inner" if guard.name.endswith("Inner") else "outer" - setattr( - self, - f"guard_{side.lower()}_{boundary}_collision", - _physx_guard_collision(f"{{ENV_REGEX_NS}}/{guard.name}Collision", guard), - ) - - def build_conveyor_belt_specs( - self, - *, - velocity: float, - friction_coefficient: float, - contact_threshold: float, - ) -> tuple[SurfaceVelocitySpec, ...]: - """Return the same pivot-local belt descriptions used by the PhysX spawners.""" - return tuple( - section.belt - for side in ("Left", "Right") - for section, _ in physx_belt_section_specs( - side, - velocity=velocity, - friction_coefficient=friction_coefficient, - contact_threshold=contact_threshold, - ) - ) - - def configure_conveyor(self, *, friction_coefficient: float) -> None: - """Propagate a final command-line friction override into every belt material before spawning.""" - for side in ("left", "right"): - for section_key in ("top_straight", "bottom_straight", "right_turn", "left_turn"): - asset = getattr(self, f"conveyor_{side}_{section_key}_collision") - material = asset.spawn.physics_material - material.static_friction = friction_coefficient - material.dynamic_friction = friction_coefficient - - -@configclass -class ConveyorFrankaPhysxEnvCfg(ConveyorFrankaEnvCfg): - """Checkpoint-compatible CPU reference using native PhysX surface velocity. - - The supported and tested native ``PhysxSurfaceVelocityAPI`` path is CPU-only. Use - :class:`ConveyorFrankaEnvCfg` for scalable GPU simulation with Newton. - """ - - scene: ConveyorFrankaPhysxSceneCfg = ConveyorFrankaPhysxSceneCfg( - # USD surface-velocity commands are authored on the host. One environment - # is the useful default for this CPU reference; callers may opt into a - # small replicated batch explicitly. - num_envs=1, - env_spacing=3.0, - replicate_physics=True, - ) - sim: SimulationCfg = SimulationCfg( - # The supported GPU contact-modification path drops contacts for shapes - # with PhysxSurfaceVelocityAPI enabled. CPU PhysX preserves the authored - # API and its belt contacts; fail validation on an accidental CUDA - # override instead of letting cubes tunnel silently. - device="cpu", - dt=1.0 / 120.0, - render_interval=2, - physics=PhysxCfg( - solver_type=1, - solve_articulation_contact_last=True, - max_position_iteration_count=64, - max_velocity_iteration_count=16, - bounce_threshold_velocity=0.2, - friction_offset_threshold=0.01, - friction_correlation_distance=0.00625, - enable_ccd=True, - ), - use_fabric=True, - use_newton_actuators=True, - ) - - def validate_config(self) -> None: - """Validate PhysX selection and the shared checkpoint-facing policy contract.""" - _validate_common_config(self) - if not isinstance(self.sim.physics, PhysxCfg): - raise ValueError("The PhysX conveyor task requires the native Isaac Sim PhysX backend.") - if self.sim.device != "cpu": - raise ValueError( - "The native PhysX conveyor is CPU-only because enabled PhysxSurfaceVelocityAPI shapes can lose " - "contacts under GPU dynamics. Run this task with '--device cpu'; use the Newton task for GPU " - "simulation." - ) - if self.scene.robot.spawn.joint_drive_props is not None: - raise ValueError("The PhysX Franka must not author MuJoCo-only joint-drive properties.") - if self.scene.robot.spawn.rigid_props.disable_gravity is not True: - raise ValueError("The PhysX Franka must preserve the trained gravity-compensated policy contract.") diff --git a/source/isaaclab_tasks/pyproject.toml b/source/isaaclab_tasks/pyproject.toml index 14e3cee3c565..cb4e47d3f8b6 100644 --- a/source/isaaclab_tasks/pyproject.toml +++ b/source/isaaclab_tasks/pyproject.toml @@ -22,7 +22,6 @@ dependencies = [ "isaaclab_assets", "isaaclab_contrib", "isaaclab_newton", - "isaaclab_physx", ] [tool.uv.sources] @@ -30,7 +29,6 @@ isaaclab = { path = "../isaaclab", editable = true } isaaclab_assets = { path = "../isaaclab_assets", editable = true } isaaclab_contrib = { path = "../isaaclab_contrib", editable = true } isaaclab_newton = { path = "../isaaclab_newton", editable = true } -isaaclab_physx = { path = "../isaaclab_physx", editable = true } [project.urls] Homepage = "https://github.com/isaac-sim/IsaacLab" diff --git a/source/isaaclab_tasks/test/contrib/test_contrib_environments_kit.py b/source/isaaclab_tasks/test/contrib/test_contrib_environments_kit.py index ee50f506f538..6372b740647b 100644 --- a/source/isaaclab_tasks/test/contrib/test_contrib_environments_kit.py +++ b/source/isaaclab_tasks/test/contrib/test_contrib_environments_kit.py @@ -30,4 +30,4 @@ @pytest.mark.parametrize("task_name", contrib_environment_params("kit")) def test_contrib_environments_kit(task_name): - _run_environments(task_name, device=None, num_envs=num_envs(task_name)) + _run_environments(task_name, device="cuda", num_envs=num_envs(task_name)) diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py deleted file mode 100644 index 3d9394f8b1f3..000000000000 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Native PhysX conveyor device and authored geometry contracts.""" - -import pytest -from isaaclab_physx.sim.schemas import PhysxSDFMeshCfg - -from isaaclab.sim.schemas import UsdPhysicsRigidBodyCfg - -from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_env_cfg import ConveyorFrankaEnvCfg -from isaaclab_tasks.contrib.conveyor_franka.conveyor_franka_physx_env_cfg import physx_belt_section_specs -from isaaclab_tasks.contrib.conveyor_franka.conveyor_geometry import BELT_TURN_RADIUS -from isaaclab_tasks.utils.parse_cfg import parse_env_cfg - - -def test_physx_preserves_cpu_policy_and_pivot_conventions(): - """Unspecified parser devices retain CPU; native twists/materials preserve the trained interface.""" - cfg = parse_env_cfg("IsaacContrib-Conveyor-Racetrack-Transfer-PhysX-CPU-v0", device=None, num_envs=2) - cfg.validate() - base = ConveyorFrankaEnvCfg() - assert cfg.sim.device == "cpu" and cfg.scene.num_envs == 2 - assert cfg.actions == base.actions and cfg.observations == base.observations - assert cfg.sim.dt == base.sim.dt == 1 / 120 and cfg.decimation == base.decimation == 2 - cfg.scene.configure_conveyor(friction_coefficient=0.42) - specs = cfg.scene.build_conveyor_belt_specs(velocity=0.21, friction_coefficient=0.42, contact_threshold=0.95) - assert len(specs) == 8 - assert all(s.velocity == 0.21 and s.friction_coefficient == 0.42 and s.contact_threshold == 0.95 for s in specs) - for side in ("Left", "Right"): - for section, root in physx_belt_section_specs(side)[2:]: - assert section.belt.curved - assert section.belt.pivot_point == (0, 0, 0) and section.belt.radius == BELT_TURN_RADIUS - assert root[2] == 0 - for key in ("top_straight", "bottom_straight", "right_turn", "left_turn"): - spawn = getattr(cfg.scene, f"conveyor_{side.lower()}_{key}_collision").spawn - assert spawn.physics_material.static_friction == spawn.physics_material.dynamic_friction == 0.42 - assert next( - props for props in spawn.rigid_props if isinstance(props, UsdPhysicsRigidBodyCfg) - ).kinematic_enabled - assert isinstance( - getattr(cfg.scene, f"conveyor_{side.lower()}_right_turn_collision").spawn.mesh_collision_props, - PhysxSDFMeshCfg, - ) - cfg.sim.device = "cuda:0" - with pytest.raises(ValueError, match="CPU-only.*--device cpu"): - cfg.validate() diff --git a/source/isaaclab_tasks/test/env_test_utils.py b/source/isaaclab_tasks/test/env_test_utils.py index f2bf3c31f2c6..63fad921d720 100644 --- a/source/isaaclab_tasks/test/env_test_utils.py +++ b/source/isaaclab_tasks/test/env_test_utils.py @@ -231,7 +231,7 @@ def _configure_osc_smoke_actions(env, actions: torch.Tensor) -> None: def _run_environments( task_name, - device: str | None, + device, num_envs, num_steps=20, multi_agent=False, @@ -243,7 +243,7 @@ def _run_environments( Args: task_name: Name of the environment. - device: Device override (e.g., 'cuda'), or None to preserve the task default. + device: Device to use (e.g., 'cuda'). num_envs: Number of environments. num_steps: Number of simulation steps. multi_agent: Whether the environment is multi-agent. @@ -284,7 +284,7 @@ def _run_environments( def _check_random_actions( task_name: str, - device: str | None, + device: str, num_envs: int, num_steps: int = 20, multi_agent: bool = False, @@ -296,7 +296,7 @@ def _check_random_actions( Args: task_name: Name of the environment. - device: Device override (e.g., 'cuda'), or None to preserve the task default. + device: Device to use (e.g., 'cuda'). num_envs: Number of environments. num_steps: Number of simulation steps. multi_agent: Whether the environment is multi-agent. diff --git a/uv.lock b/uv.lock index bb7ec7ce02cf..30d7370a07b4 100644 --- a/uv.lock +++ b/uv.lock @@ -2169,7 +2169,6 @@ dependencies = [ { name = "isaaclab-assets" }, { name = "isaaclab-contrib" }, { name = "isaaclab-newton" }, - { name = "isaaclab-physx" }, ] [package.metadata] @@ -2178,7 +2177,6 @@ requires-dist = [ { name = "isaaclab-assets", editable = "source/isaaclab_assets" }, { name = "isaaclab-contrib", editable = "source/isaaclab_contrib" }, { name = "isaaclab-newton", editable = "source/isaaclab_newton" }, - { name = "isaaclab-physx", editable = "source/isaaclab_physx" }, ] [[package]] From 5bc02eb32a3ec89695319291a28d262263502f95 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 15:18:18 +0200 Subject: [PATCH 26/45] Remove unused conveyor command reset metadata --- .../isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py index 549fb7950462..b223693c4c6a 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py @@ -57,7 +57,7 @@ def __init__(self, cfg: ConveyorTransferCommandCfg, env: ManagerBasedRLEnv) -> N super().__init__(cfg, env) reset_term = env.event_manager.get_term_cfg(cfg.reset_event_name).func - required_reset_fields = ("row_ids", "recipe_ids", "target_cube_ids", "source_side_ids", "held_rows") + required_reset_fields = ("row_ids", "target_cube_ids", "source_side_ids", "held_rows") if not all(hasattr(reset_term, name) for name in required_reset_fields): raise RuntimeError("ConveyorTransferCommand requires ConveyorResetStateTable reset metadata.") self._reset_term = reset_term @@ -66,7 +66,6 @@ def __init__(self, cfg: ConveyorTransferCommandCfg, env: ManagerBasedRLEnv) -> N self.target_cube_ids = torch.zeros(self.num_envs, dtype=torch.long, device=self.device) self.source_side_ids = torch.zeros_like(self.target_cube_ids) - self.recipe_ids = torch.zeros_like(self.target_cube_ids) self.held_cube_ids = torch.full_like(self.target_cube_ids, -1) self.subgoal_start_steps = torch.zeros_like(self.target_cube_ids) self.transfer_counts = torch.zeros_like(self.target_cube_ids) @@ -197,7 +196,6 @@ def _resample_command(self, env_ids: Sequence[int]) -> None: rows = self._reset_term.row_ids[ids] target_cube_ids = self._reset_term.target_cube_ids[rows] source_side_ids = self._reset_term.source_side_ids[rows] - self.recipe_ids[ids] = self._reset_term.recipe_ids[rows] held_rows = self._reset_term.held_rows[rows] self.transfer_counts[ids] = 0 self.direction_transfer_counts[ids] = 0 From 17bea8bd3c9d3d62cf3b276c20e5347c73da6e7b Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 15:21:52 +0200 Subject: [PATCH 27/45] Simplify warehouse presentation and keep grasp checks local --- .../conveyor_franka_asset_env_cfg.py | 76 +++++-------------- .../conveyor_franka_warehouse_env.py | 7 +- .../contrib/conveyor_franka/mdp/sorting.py | 19 ++++- .../test/contrib/test_conveyor_franka_mdp.py | 14 +--- 4 files changed, 41 insertions(+), 75 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py index 1c7f375b4a2f..3bd6fce50581 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py @@ -29,7 +29,7 @@ from .conveyor_franka_env_cfg import ( ConveyorFrankaEnvCfg, ConveyorFrankaSceneCfg, - _hidden_collision_geometry, + _conveyor_asset, _spawn_shape_with_display_color, ) from .conveyor_geometry import ( @@ -70,7 +70,6 @@ _PHYSICS_SCHEMA_PREFIXES = ("Physics", "Physx", "Newton", "Mujoco") -_PHYSICS_SCHEMA_NAMES = frozenset(("IsaacConveyorAPI",)) _PRESENTATION_ASSETS = Path(__file__).parent / "assets" @@ -178,17 +177,11 @@ class _ParcelCuboidCfg(sim_utils.CuboidCfg): def _is_physics_schema(schema_name: str) -> bool: """Return whether an applied schema can add physics ownership to a visual asset.""" - return schema_name in _PHYSICS_SCHEMA_NAMES or schema_name.startswith(_PHYSICS_SCHEMA_PREFIXES) + return schema_name == "IsaacConveyorAPI" or schema_name.startswith(_PHYSICS_SCHEMA_PREFIXES) def _make_usd_subtree_visual_only(root_prim) -> None: - """Author a render-only override for a referenced USD subtree. - - The source asset remains untouched. All edits are stronger opinions in the - task stage and fail closed if a physics schema cannot be removed. - """ - # Import USD only when a stage exists. Besides respecting Kit startup - # ordering, this keeps import-light task discovery kitless. + """Remove physics ownership with stage overrides, preserving the source asset.""" from pxr import Sdf, Usd, UsdPhysics children = tuple(Usd.PrimRange(root_prim, Usd.TraverseInstanceProxies())) @@ -200,14 +193,14 @@ def _make_usd_subtree_visual_only(root_prim) -> None: ) with Sdf.ChangeBlock(): - for child in children: - if child.GetTypeName().startswith("OmniGraph") or child.IsA(UsdPhysics.Scene): - child.SetActive(False) - for child in children: if not child.IsValid() or not child.IsActive(): continue - if child.IsA(UsdPhysics.Joint): + if ( + child.GetTypeName().startswith("OmniGraph") + or child.IsA(UsdPhysics.Scene) + or child.IsA(UsdPhysics.Joint) + ): child.SetActive(False) continue for schema_name in tuple(child.GetAppliedSchemas()): @@ -235,24 +228,11 @@ def _spawn_visual_only_usd( """Spawn a USD asset and strip its authored physics metadata.""" prim = sim_utils.spawn_from_usd(prim_path, cfg, translation, orientation, **kwargs) - # Presentation assets may contain nested dynamic props (the packing table, - # for example, carries an authored container rigid body). Merely disabling - # collision still exposes those bodies and joints to a backend parser. - # Author local API deletions so the entire referenced hierarchy is a pure - # render layer and cannot alter either Newton's model or PhysX's scene. + # Nested rigid bodies and joints must also stay outside the physics model. _make_usd_subtree_visual_only(prim) return prim -@configclass -class _VisualOnlyUsdFileCfg(sim_utils.UsdFileCfg): - """USD reference whose composed subtree is guaranteed to remain render-only.""" - - func: Callable = _spawn_visual_only_usd - # Recursive schema overrides cannot be authored on USD instance proxies. - make_uninstanceable: bool = True - - @configclass class _ElevatedGroundPlaneCfg(TerrainImporterCfg): """Ground plane that reports the elevated workspace as its environment origin.""" @@ -270,19 +250,13 @@ def _visual_usd_asset( position: tuple[float, float, float], scale: tuple[float, float, float], rotation: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), + spawn_func: Callable = _spawn_authored_visual, ) -> AssetBaseCfg: """Build one visual asset whose authored colliders are explicitly disabled.""" - spawn = _VisualOnlyUsdFileCfg( - usd_path=usd_path, - scale=scale, - ) return AssetBaseCfg( prim_path=prim_path, init_state=AssetBaseCfg.InitialStateCfg(pos=position, rot=rotation), - # Physics remains owned by the task's lightweight, hidden belt and rail - # proxies. The spawn callback strips authored physics metadata to keep - # the visual geometry entirely non-authoritative. - spawn=spawn, + spawn=sim_utils.UsdFileCfg(func=spawn_func, usd_path=usd_path, scale=scale, make_uninstanceable=True), ) @@ -294,17 +268,12 @@ def __post_init__(self) -> None: """Dress the workcell and extend its returns around the fixed manipulation sections.""" super().__post_init__() - # Raise every inherited env-scoped task component as one rigid - # workspace. The environment reports the same offset as its origin, so - # observations, resets, rewards, and the pretrained policy retain their - # original local coordinates. + # Matching asset and environment-origin offsets preserve the policy's local coordinates. for asset in vars(self).values(): if isinstance(asset, AssetBaseCfg) and asset.prim_path.startswith("{ENV_REGEX_NS}/"): x, y, z = asset.init_state.pos asset.init_state.pos = (x, y, z + _WORKSPACE_ELEVATION) - # The ground is global rather than env-scoped and remains at the USD - # convention's default elevation. self.ground = _ElevatedGroundPlaneCfg( prim_path="/World/GroundPlane", terrain_type="plane", @@ -316,14 +285,13 @@ def __post_init__(self) -> None: right_x = BELT_CENTER_X + BELT_HALF_STRAIGHT straight_scale = (_A09_X_SCALE, _ASSET_XY_SCALE, _ASSET_Z_SCALE) - # The Franka is fixed at the elevated workspace origin and does not need - # support collision. Replace the temporary plinth with the purpose-built - # Thor table: its mount stays at the robot base and its feet land on z=0. + # The visual table supports the fixed robot base and reaches the ground. self.tabletop = _visual_usd_asset( prim_path="{ENV_REGEX_NS}/RobotThorTableVisual", usd_path=_THOR_TABLE_ASSET_PATH, position=(0.0, 0.0, _WORKSPACE_ELEVATION), scale=(_THOR_TABLE_SCALE,) * 3, + spawn_func=_spawn_visual_only_usd, ) self.table_pedestal = None @@ -331,12 +299,10 @@ def __post_init__(self) -> None: side_key = side.lower() center_y = BELT_CENTER_Y if side == "Left" else -BELT_CENTER_Y - # The Digital Twin pieces already render their belt, frame, and - # guides, so remove only the procedural render geometry. Hidden - # belt and guide collision assets created above stay authoritative. + # Authored modules replace the procedural visuals; task colliders remain authoritative. setattr(self, f"conveyor_{side_key}_belt_visual", None) - setattr(self, f"guard_{side_key}_inner_visual", None) - setattr(self, f"guard_{side_key}_outer_visual", None) + for boundary in ("inner", "outer"): + setattr(self, f"guard_{side_key}_{boundary}", None) run = "bottom" if side == "Left" else "top" y_position = center_y + (-BELT_TURN_RADIUS if side == "Left" else BELT_TURN_RADIUS) @@ -350,7 +316,6 @@ def __post_init__(self) -> None: scale=straight_scale, ), ) - getattr(self, f"conveyor_{side_key}_{run}_a09_visual").spawn.func = _spawn_authored_visual self.warehouse_visual = _visual_usd_asset( prim_path="{ENV_REGEX_NS}/WarehouseVisual", @@ -358,7 +323,6 @@ def __post_init__(self) -> None: position=(0.0, 0.0, 0.0), scale=(1.0, 1.0, 1.0), ) - self.warehouse_visual.spawn.func = _spawn_authored_visual for cube_id in range(4): cube = getattr(self, f"cube_{cube_id}") cube.spawn = _ParcelCuboidCfg(**vars(cube.spawn)) @@ -383,14 +347,12 @@ def _configure_route_assets( for key in ("top_straight", "bottom_straight", "right_turn", "left_turn"): setattr(self, f"conveyor_{side.lower()}_{key}_collision", None) for index, section in enumerate(warehouse_belt_sections(side)): - asset = _hidden_collision_geometry(section.belt.prim_path, section.geometry, 1.1e-5, 1) + asset = _conveyor_asset(section.belt.prim_path, section.geometry) x, y, z = asset.init_state.pos asset.init_state.pos = (x, y, z + _WORKSPACE_ELEVATION) setattr(self, f"warehouse_{side.lower()}_section_{index}", asset) - for boundary in ("inner", "outer"): - setattr(self, f"guard_{side.lower()}_{boundary}_collision", None) for guard in warehouse_guard_meshes(side): - asset = _hidden_collision_geometry(f"{{ENV_REGEX_NS}}/{guard.name}Collision", guard, 1.1e-5, 1) + asset = _conveyor_asset(f"{{ENV_REGEX_NS}}/{guard.name}Collision", guard) asset.init_state.pos = (0.0, 0.0, _WORKSPACE_ELEVATION) setattr(self, f"warehouse_{guard.name}_collision", asset) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py index b94500cdcd3b..18eff8a2aaa2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py @@ -15,6 +15,7 @@ from .conveyor_cube_pool import ConveyorCubePool from .conveyor_franka_env import ConveyorFrankaEnv +from .conveyor_warehouse_geometry import warehouse_parcel_positions if TYPE_CHECKING: from pxr import Usd @@ -63,6 +64,7 @@ def load_managers(self) -> None: """Create slot identities before command, reward, and observation terms inspect cubes.""" assets = tuple(self.scene[f"cube_{i}"] for i in range(self.cfg.conveyor_force.transported_body_count_per_env)) self.conveyor_cube_pool = ConveyorCubePool(assets, self.num_envs, self.device) + self._parcel_start_positions = torch.tensor(warehouse_parcel_positions(), device=self.device) super().load_managers() @staticmethod @@ -129,21 +131,18 @@ def _animate_warehouse(self) -> None: def _reset_idx(self, env_ids) -> None: """Shuffle a mixed batch across the authored feeds in selected environments.""" - from .conveyor_warehouse_geometry import warehouse_parcel_positions - if isinstance(env_ids, slice): env_ids = self.scene._ALL_INDICES[env_ids] pool = self.conveyor_cube_pool pool.reset(env_ids) super()._reset_idx(env_ids) - positions = torch.tensor(warehouse_parcel_positions(), device=self.device) if self.cfg.commands.transfer.randomize_arrivals: assignments = torch.rand((len(env_ids), len(pool.assets)), device=self.device).argsort(dim=1) else: assignments = torch.arange(len(pool.assets), device=self.device).expand(len(env_ids), -1) for cube_id, cube in enumerate(pool.assets): pose = cube.data.root_pose_w.torch[env_ids].clone() - pose[:, :3] = positions[assignments[:, cube_id]] + self.scene.env_origins[env_ids] + pose[:, :3] = self._parcel_start_positions[assignments[:, cube_id]] + self.scene.env_origins[env_ids] pose[:, 3:] = pose.new_tensor((0.0, 0.0, 0.0, 1.0)) cube.write_root_pose_to_sim_index(root_pose=pose, env_ids=env_ids) cube.write_root_velocity_to_sim_index(root_velocity=pose.new_zeros((len(env_ids), 6)), env_ids=env_ids) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py index 2cc58460b3bf..1383611224fb 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py @@ -16,7 +16,8 @@ from .commands import ConveyorTransferCommand, ConveyorTransferCommandCfg from .cube_state import cube_values -from .rewards import physical_cube_acquisition_mask +from .kinematics import end_effector_pose +from .reset_events import CUBE_REST_Z if TYPE_CHECKING: from ..conveyor_franka_warehouse_env import ConveyorFrankaWarehouseEnv @@ -66,15 +67,27 @@ def _resample_command(self, env_ids: Sequence[int]) -> None: self.held_cube_ids[env_ids] = -1 self.pending_success[env_ids] = False + def _physical_cube_acquisition_mask(self, active_position_w: torch.Tensor) -> torch.Tensor: + """Pin a lifted, closed-gripper parcel near the tool while the arm transfers it.""" + tool_position, _ = end_effector_pose(self._env) + finger_positions = self._robot.data.joint_pos.torch[:, self._finger_joint_ids] + local_cube_z = active_position_w[:, 2] - self._env.scene.env_origins[:, 2] + return ( + (local_cube_z >= CUBE_REST_Z + 0.025) + & (torch.linalg.vector_norm(active_position_w - tool_position, dim=1) <= 0.075) + & (torch.amax(finger_positions, dim=1) <= 0.030) + ) + def _update_command(self) -> None: """Publish slot assignments before the manager computes the next policy observation.""" env = self._env pool = env.conveyor_cube_pool - positions = cube_values(env, "root_pos_w", all_cubes=True) - env.scene.env_origins[:, None] + positions_w = cube_values(env, "root_pos_w", all_cubes=True) + positions = positions_w - env.scene.env_origins[:, None] local = env._in_workcell(positions) rows = torch.arange(self.num_envs, device=self.device) physical_target = pool.slot_ids[rows, self.target_cube_ids] - held = physical_cube_acquisition_mask(env, command=self) & self.has_target + held = self._physical_cube_acquisition_mask(positions_w[rows, physical_target]) & self.has_target self.has_target &= ~self.pending_success & (local[rows, physical_target] | held) self.pending_success.zero_() wrong_lane = (positions[..., 1] < 0).long() != self.parcel_destinations diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py index aa723b5e97d8..c2f95e46c952 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py @@ -276,29 +276,21 @@ def test_sorter_dispatch_inventory_and_selected_resets(): assert pool.slot_ids[0, 0] == 0 assert command.metrics["sorted_parcels"].tolist() == [3, 1] torch.testing.assert_close(pool.assets[5].data.root_pos_w.torch[0] - env.scene.env_origins[0], positions[0, 5]) - # Move the assigned parcel into a real closed-gripper grasp, even outside the pickup lane. + # Carry the assigned parcel above the workcell; a remote held target must retain its slot. robot = env.scene["robot"] fingers, _ = robot.find_joints("panda_finger_joint[1-2]", preserve_order=True) joints = robot.data.joint_pos.torch.clone() joints[:, fingers] = 0.019 robot.write_joint_position_to_sim_index(position=joints) arms, _ = robot.find_joints("panda_joint[1-7]", preserve_order=True) - lift = next( - r - for r in build_reset_rows() - if r.recipe == ConveyorResetRecipe.LIFT - and r.variant_id == 3 - and r.target_cube_id == 0 - and r.source_side_id == 0 - ) - joints[:, arms] = torch.tensor(lift.arm_positions) + joints[:, arms] = robot.data.default_joint_pos.torch[:, arms] robot.write_joint_position_to_sim_index(position=joints) env.sim.forward() env.scene.update(env.step_dt) tool, _ = end_effector_pose(env) positions[0, 5] = tool[0] - env.scene.env_origins[0] _place(env, positions) - assert mdp.physical_cube_acquisition_mask(env)[0] + assert positions[0, 5, 2] > 0.4 command._update_command() assert pool.slot_ids[0, slot] == 5 and command.has_target[0] positions[0, 5] = torch.tensor([0.8, 0.27, 0.06]) From 0f8f361f2dc4da59130014be4dc9599b5dec09aa Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 15:22:22 +0200 Subject: [PATCH 28/45] Keep conveyor stack tracking out of the warehouse review --- tools/conveyor_stack.json | 59 ----------- tools/conveyor_stack.md | 53 ---------- tools/conveyor_stack.py | 209 -------------------------------------- 3 files changed, 321 deletions(-) delete mode 100644 tools/conveyor_stack.json delete mode 100644 tools/conveyor_stack.md delete mode 100644 tools/conveyor_stack.py diff --git a/tools/conveyor_stack.json b/tools/conveyor_stack.json deleted file mode 100644 index 7b67a4729b81..000000000000 --- a/tools/conveyor_stack.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "repository": "isaac-sim/IsaacLab", - "base": "develop", - "integration_pr": 6952, - "integration_branch": "maximiliank/conveyor-franka-env", - "components": [ - { - "pr": 8326, - "name": "Custom mesh spawning" - }, - { - "pr": 8327, - "name": "Experimental conveyor support in Contrib" - }, - { - "pr": 8331, - "name": "Racetrack transfer task" - }, - { - "pr": 8332, - "name": "Warehouse sorting task" - } - ], - "snapshot": { - "base": "dd2f470fcad5873980e9bc4acffb043238aca41c", - "components": [ - { - "pr": 8326, - "name": "Custom mesh spawning", - "head": "54613ef14cf291c87f2f3eb2a9386befb5333a1f", - "merged": false - }, - { - "pr": 8327, - "name": "Experimental conveyor support in Contrib", - "head": "de3d752c5030c7992db78a76ad9ab82d6c80164d", - "merged": false - }, - { - "pr": 8331, - "name": "Racetrack transfer task", - "head": "e1dda73810c5a1cb3d90513a4680bd408ef9fd0f", - "merged": false - }, - { - "pr": 8332, - "name": "Warehouse sorting task", - "head": "4fa636eae3a0bab51f0dda35322fe0d60e5d13a3", - "merged": false - } - ], - "checks": [ - "18 focused conveyor checks and full formatting/changelog gates passed", - "Fresh Newton GPU PPO: 256 environments, CUDA graph, two updates, finite losses and saved checkpoints", - "Selective-reset success bookkeeping checked with two regression mutations" - ], - "checked_at": "2026-10-23T10:34:35.524366+00:00" - } -} diff --git a/tools/conveyor_stack.md b/tools/conveyor_stack.md deleted file mode 100644 index 7fba63ab522f..000000000000 --- a/tools/conveyor_stack.md +++ /dev/null @@ -1,53 +0,0 @@ -# Conveyor integration draft - -PR #6952 is the combined validation draft. Review and merge #8326 (meshes), -#8327 (Contrib conveyor support), #8331 (racetrack transfer), and #8332 -(warehouse sorting) individually. Do not merge the integration draft or its -temporary tracking tools. - -After pushing a change to any component PR, run from this integration checkout: - -```bash -uv run --no-project python tools/conveyor_stack.py --check --push -``` - -To also check the trained policy on CPU: - -```bash -uv run --no-project python tools/conveyor_stack.py --check --push \ - --checkpoint /path/to/model.pt -``` - -The script fetches current `develop` and each current PR head, then merges them -in dependency order in a temporary worktree. Merged PRs are supplied by -`develop` instead of being merged again. Closed, unmerged components require -an explicit change to the list in `conveyor_stack.json`. - -Each test file runs in its own process. The refresh covers mesh authoring, -adapter controls/lifecycle, both task contracts, selected batch resets, sorting, -geometry, asset normalization, and the lock file. Optional policy evaluation -requires completed transfers with zero safety resets or non-finite -observations. Formatting also runs before publication. - -On success, the script publishes a freshly rebuilt branch with a lease against -concurrent updates and rewrites #6952's description with the exact included -SHAs and completed checks. The same snapshot is committed in -`tools/conveyor_stack.json`. A merge conflict or failed check stops publication. - -This runs on demand. Component pushes do not automatically refresh #6952. -GitHub CI runs on the published integration commit; expensive checks can be -requested with the repository's usual `run-ci` comment. - -The refresh uses the configured `fork` remote for publication, authenticated -`gh`, and the current checkout's uv environment. Local edits and the current -checkout are preserved; tests use only the published component heads. To -inspect a refreshed result without changing an existing worktree: - -```bash -git fetch fork maximiliank/conveyor-franka-env -git worktree add --detach /tmp/conveyor-preview FETCH_HEAD -``` - -When a prerequisite merges, rebase its dependent drafts onto current -`develop`, update their review-only comparison links, and refresh the -integration again. diff --git a/tools/conveyor_stack.py b/tools/conveyor_stack.py deleted file mode 100644 index 76fae07ca48c..000000000000 --- a/tools/conveyor_stack.py +++ /dev/null @@ -1,209 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Rebuild the conveyor integration draft from the current component PR heads.""" - -from __future__ import annotations - -import argparse -import json -import os -import shutil -import subprocess -import tempfile -from datetime import datetime, timezone -from pathlib import Path - - -def run(args: list[str], cwd: Path, *, env: dict[str, str] | None = None, capture: bool = False) -> str: - """Run an argument vector and stop immediately on failure.""" - result = subprocess.run(args, cwd=cwd, env=env, check=True, text=True, capture_output=capture) - return result.stdout.strip() if capture else "" - - -def main() -> None: - """Fetch, merge, validate, and optionally publish the current stack.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--check", action="store_true", help="Run focused CPU tests before publishing.") - parser.add_argument("--push", action="store_true", help="Update the integration branch and draft description.") - parser.add_argument("--checkpoint", type=Path, help="Also evaluate this trained policy for 360 CPU steps.") - args = parser.parse_args() - if args.push and not args.check: - parser.error("--push requires --check") - root = Path(__file__).resolve().parents[1] - manifest = json.loads((root / "tools/conveyor_stack.json").read_text()) - repository = manifest["repository"] - branch = manifest["integration_branch"] - if branch in {"main", "develop"} or branch.startswith("release/"): - parser.error("The integration target must be a feature branch.") - git = ["git", "-c", "http.version=HTTP/1.1"] - expected = run(git + ["ls-remote", "fork", f"refs/heads/{branch}"], root, capture=True).split()[0] - components = [] - refs = [f"+refs/heads/{manifest['base']}:refs/conveyor-stack/base"] - for component in manifest["components"]: - number = int(component["pr"]) - data = json.loads(run(["gh", "api", f"repos/{repository}/pulls/{number}"], root, capture=True)) - if data["state"] != "open" and not data["merged"]: - raise RuntimeError(f"PR #{number} was closed without merging; update the component list.") - components.append({**component, "head": data["head"]["sha"], "merged": data["merged"]}) - if not data["merged"]: - refs.append(f"+refs/pull/{number}/head:refs/conveyor-stack/{number}") - run(git + ["fetch", "--no-tags", f"https://github.com/{repository}.git", *refs], root) - base = run(git + ["rev-parse", "refs/conveyor-stack/base"], root, capture=True) - with tempfile.TemporaryDirectory(prefix="conveyor-stack-") as directory: - checkout = Path(directory) / "checkout" - run(git + ["worktree", "add", "--detach", str(checkout), base], root) - try: - for component in components: - if component["merged"]: - continue - number = component["pr"] - head = run(git + ["rev-parse", f"refs/conveyor-stack/{number}"], root, capture=True) - if head != component["head"]: - raise RuntimeError(f"PR #{number} changed during fetch; rerun the refresh.") - run(git + ["merge", "--no-ff", "-m", f"Integrate #{number} at {head[:12]}", head], checkout) - for name in ("conveyor_stack.py", "conveyor_stack.json", "conveyor_stack.md"): - shutil.copyfile(root / "tools" / name, checkout / "tools" / name) - env = os.environ.copy() - env.pop("VIRTUAL_ENV", None) - env["UV_PROJECT_ENVIRONMENT"] = str(Path(directory) / ".venv") - env["ISAACLAB_TEST_DEVICES"] = "100" - env["PYTHONPATH"] = os.pathsep.join(str(path) for path in (checkout / "source").iterdir() if path.is_dir()) - uv = ["uv", "run", "--no-sync"] - run(["uv", "sync", "--frozen", "--extra", "dev", "--extra", "isaacsim"], checkout, env=env) - # Reuse prior acceptance only for the same installed Kit package version. - for pattern in ( - "lib/python*/site-packages/isaacsim/kit/EULA_ACCEPTED", - "Lib/site-packages/isaacsim/kit/EULA_ACCEPTED", - ): - for marker in (root / ".venv").glob(pattern): - target = Path(env["UV_PROJECT_ENVIRONMENT"]) / marker.relative_to(root / ".venv") - versions = marker.parents[2].glob("isaacsim_kernel-*.dist-info") - if target.parent.is_dir() and any( - (target.parents[2] / version.name).is_dir() for version in versions - ): - shutil.copyfile(marker, target) - run( - uv - + [ - "python", - "-c", - "import sys; from pathlib import Path; from isaaclab.paths import ISAACLAB_ROOT; " - "assert Path(ISAACLAB_ROOT).resolve() == Path.cwd().resolve(); " - "print(f'Validation interpreter: {sys.executable}; source root: {ISAACLAB_ROOT}')", - ], - checkout, - env=env, - ) - checks = [] - if args.check: - tests = [ - "source/isaaclab/test/sim/test_spawn_meshes.py", - "source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py", - "source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py", - "source/isaaclab_contrib/test/conveyors/test_surface_velocity_physx.py", - "source/isaaclab_tasks/test/contrib/test_conveyor_franka_geometry.py", - "source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py", - "source/isaaclab_tasks/test/contrib/test_conveyor_franka_physx_cfg.py", - "source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py", - ] - for test in tests: - run(uv + ["python", "-m", "pytest", test, "-q"], checkout, env=env) - run(["uv", "lock", "--check"], checkout, env=env) - checks.append("Focused CPU tests and lock consistency passed") - if args.checkpoint: - report = Path(directory) / "policy.json" - run( - uv - + [ - "python", - "-m", - "isaaclab_tasks.contrib.conveyor_franka.evaluate", - "--checkpoint", - str(args.checkpoint.resolve()), - "--num_envs", - "2", - "--device", - "cpu", - "--steps", - "360", - "--seed", - "0", - "--output", - str(report), - ], - checkout, - env=env, - ) - policy = json.loads(report.read_text()) - if policy["failures"] or policy["nonfinite_observations"] or not policy["completed_transfers"]: - raise RuntimeError(f"Policy evaluation did not meet the bounded transfer check: {policy}") - checks.append( - f"360-step policy evaluation: {policy['completed_transfers']} transfers, " - "zero safety resets/non-finite observations" - ) - manifest["snapshot"] = { - "base": base, - "components": components, - "checks": checks, - "checked_at": datetime.now(timezone.utc).isoformat(), - } - (checkout / "tools/conveyor_stack.json").write_text(json.dumps(manifest, indent=2) + "\n") - run( - git + ["add", "tools/conveyor_stack.py", "tools/conveyor_stack.json", "tools/conveyor_stack.md"], - checkout, - ) - run(uv + ["isaaclab", "-f"], checkout, env=env) - run(git + ["diff", "--check"], checkout) - run(git + ["commit", "-m", "Record the tested conveyor integration snapshot"], checkout) - head = run(git + ["rev-parse", "HEAD"], checkout, capture=True) - rows = "\n".join(f"| #{item['pr']} | {item['name']} | `{item['head'][:12]}` |" for item in components) - body = ( - "## Conveyor integration draft\n\n**Do not merge this PR.** " - "Review and merge the four component PRs individually.\n\n" - f"| PR | Scope | Included head |\n| --- | --- | --- |\n{rows}\n\n" - f"Base: `{base[:12]}`. Integration commit: `{head[:12]}`.\n\n" - + "\n".join(f"- {check}" for check in checks) - + "\n\nRefresh from the latest published PR heads with:\n\n" - "```bash\nuv run --no-project python tools/conveyor_stack.py --check --push\n```\n\n" - "Add `--checkpoint /path/to/model.pt` for bounded policy evaluation. " - "A failed test or merge conflict stops publication. The refresh uses a temporary checkout " - "and records exact SHAs in `tools/conveyor_stack.json`. " - "It runs on demand; it does not poll or refresh automatically.\n" - ) - if args.push: - run( - git - + [ - "push", - f"--force-with-lease=refs/heads/{branch}:{expected}", - "fork", - f"HEAD:refs/heads/{branch}", - ], - checkout, - ) - payload = Path(directory) / "description.json" - payload.write_text(json.dumps({"body": body})) - run( - [ - "gh", - "api", - "--method", - "PATCH", - f"repos/{repository}/pulls/{manifest['integration_pr']}", - "--input", - str(payload), - "--silent", - ], - root, - ) - else: - print(body) - finally: - run(git + ["worktree", "remove", "--force", str(checkout)], root) - - -if __name__ == "__main__": - main() From 4305eba923e425052552baae5af3cabebb2486a6 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 16:24:06 +0200 Subject: [PATCH 29/45] Narrow conveyor surface descriptions to authored task inputs --- .../conveyors/surface_velocity.py | 152 ++++++------------ .../conveyors/test_surface_velocity_spec.py | 54 +++---- 2 files changed, 70 insertions(+), 136 deletions(-) diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py index a88036e59294..ab4113d0a38a 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py @@ -10,69 +10,23 @@ import math from collections.abc import Sequence from dataclasses import dataclass -from typing import Any _ENV_REGEX_NS = "{ENV_REGEX_NS}" -def _validate_prim_path(value: str) -> None: - """Validate one exact USD prim path or supported replicated-path template.""" - if not isinstance(value, str) or not value: - raise ValueError("Surface prim_path must be a non-empty string.") - if value.startswith(f"{_ENV_REGEX_NS}/"): - path = value[len(_ENV_REGEX_NS) :] - elif value.startswith("/"): - path = value - else: - raise ValueError(f"Surface prim_path must be absolute or start with '{_ENV_REGEX_NS}/', got {value!r}.") - if "{" in path or "}" in path: - raise ValueError(f"Surface prim_path supports only a leading '{_ENV_REGEX_NS}' placeholder, got {value!r}.") - components = path[1:].split("/") - if not components or any(not component or component in {".", ".."} for component in components): - raise ValueError(f"Surface prim_path must identify a concrete prim without empty components, got {value!r}.") - if any(any(character.isspace() for character in component) for component in components): - raise ValueError(f"Surface prim_path components must not contain whitespace, got {value!r}.") - - -def _validate_scalar(name: str, value: Any) -> float: - """Return one finite float with consistent validation errors.""" - try: - result = float(value) - except (TypeError, ValueError, OverflowError) as exc: - raise ValueError(f"Surface {name} must be finite, got {value!r}.") from exc - if not math.isfinite(result): - raise ValueError(f"Surface {name} must be finite, got {value!r}.") - return result - - -def _validate_vector(name: str, value: tuple[float, ...], length: int, *, nonzero: bool = False) -> tuple[float, ...]: - """Return one finite vector with a stable tuple representation.""" - try: - result = tuple(float(component) for component in value) - except (TypeError, ValueError) as exc: - raise ValueError(f"Surface {name} must contain {length} finite values, got {value!r}.") from exc - if len(result) != length or not all(math.isfinite(component) for component in result): - raise ValueError(f"Surface {name} must contain {length} finite values, got {value!r}.") - if nonzero and math.sqrt(sum(component * component for component in result)) <= 1.0e-8: - raise ValueError(f"Surface {name} must be non-zero, got {value!r}.") - return result - - @dataclass(frozen=True, slots=True) class SurfaceVelocitySpec: """Persistent intent for one collision surface with prescribed tangential velocity. Directions, surface normals, and the optional pivot point are expressed in the collision prim's - local frame. Runtime state such as commanded speeds and applied forces belongs to the adapter. + local frame. Applied forces and reset state belong to the adapter. - ``prim_path`` may contain Isaac Lab's ``{ENV_REGEX_NS}`` placeholder. A backend resolves that template - to every replicated collision prim while preserving one deterministic belt index per environment. An - absolute path addresses one unreplicated surface; replicated environments should use the placeholder. + ``prim_path`` starts with Isaac Lab's ``{ENV_REGEX_NS}/`` placeholder. The adapter + resolves it using the scene's clone template, preserving environment-major surface order. Args: - prim_path: Collision prim path or replicated Isaac Lab prim-path template. - velocity: Initial signed centerline surface velocity [m/s]. - enabled: Whether the surface initially transports contacting bodies. + prim_path: Replicated collision-prim template with concrete descendant names. + velocity: Fixed signed centerline surface velocity [m/s]. direction: Local travel direction, or local rotation axis for a curved belt. curved: Whether the velocity field rotates about ``pivot_point``. pivot_point: Local rotation center for a curved belt [m]. @@ -84,7 +38,6 @@ class SurfaceVelocitySpec: prim_path: str velocity: float = 0.0 - enabled: bool = True direction: tuple[float, float, float] = (1.0, 0.0, 0.0) curved: bool = False pivot_point: tuple[float, float, float] = (0.0, 0.0, 0.0) @@ -94,34 +47,30 @@ class SurfaceVelocitySpec: friction_coefficient: float = 0.7 def __post_init__(self) -> None: - """Normalize immutable vectors and validate authored values.""" - _validate_prim_path(self.prim_path) - for name in ("enabled", "curved"): - if not isinstance(getattr(self, name), bool): - raise ValueError(f"Surface {name} must be a bool, got {getattr(self, name)!r}.") - - velocity = _validate_scalar("velocity", self.velocity) - contact_threshold = _validate_scalar("contact_threshold", self.contact_threshold) - friction_coefficient = _validate_scalar("friction_coefficient", self.friction_coefficient) - if not 0.0 <= contact_threshold <= 1.0: + """Reject invalid authored geometry and contact parameters.""" + if not self.prim_path.startswith(f"{_ENV_REGEX_NS}/") or not all( + part.isidentifier() for part in self.prim_path[len(_ENV_REGEX_NS) + 1 :].split("/") + ): + raise ValueError(f"Surface prim_path must start with '{_ENV_REGEX_NS}/' and name concrete prims.") + for name, vector in ( + ("direction", self.direction), + ("pivot_point", self.pivot_point), + ("surface_normal", self.surface_normal), + ): + if len(vector) != 3 or not all(math.isfinite(value) for value in vector): + raise ValueError(f"Surface {name} must contain three finite values.") + if name != "pivot_point" and math.hypot(*vector) <= 1.0e-8: + raise ValueError(f"Surface {name} must be non-zero.") + if not math.isfinite(self.velocity): + raise ValueError("Surface velocity must be finite.") + if not 0.0 <= self.contact_threshold <= 1.0: raise ValueError(f"Surface contact_threshold must be in [0, 1], got {self.contact_threshold!r}.") - if friction_coefficient < 0.0: - raise ValueError( - f"Surface friction_coefficient must be finite and non-negative, got {self.friction_coefficient!r}." - ) - radius = None if self.radius is None else _validate_scalar("radius", self.radius) - if radius is not None and radius <= 0.0: - raise ValueError(f"Surface radius must be finite and positive when provided, got {self.radius!r}.") - - object.__setattr__(self, "velocity", velocity) - object.__setattr__(self, "direction", _validate_vector("direction", self.direction, 3, nonzero=True)) - object.__setattr__(self, "pivot_point", _validate_vector("pivot_point", self.pivot_point, 3)) - object.__setattr__( - self, "surface_normal", _validate_vector("surface_normal", self.surface_normal, 3, nonzero=True) - ) - object.__setattr__(self, "radius", radius) - object.__setattr__(self, "contact_threshold", contact_threshold) - object.__setattr__(self, "friction_coefficient", friction_coefficient) + if not math.isfinite(self.friction_coefficient) or self.friction_coefficient < 0.0: + raise ValueError("Surface friction_coefficient must be finite and non-negative.") + if self.curved and self.radius is None: + raise ValueError("Curved surfaces require a positive radius.") + if self.radius is not None and (not math.isfinite(self.radius) or self.radius <= 0.0): + raise ValueError("Surface radius must be finite and positive.") def resolve_surface_velocity_paths( @@ -140,32 +89,23 @@ def resolve_surface_velocity_paths( Exact paths ordered by environment, then by ``surface_specs`` order. Raises: - ValueError: If inputs cannot produce one unique path per environment and belt. - TypeError: If a surface description is not a :class:`SurfaceVelocitySpec`. + ValueError: If surface ownership is ambiguous or the clone template is invalid. """ - if not isinstance(num_envs, int) or isinstance(num_envs, bool) or num_envs <= 0: - raise ValueError(f"Conveyor num_envs must be a positive integer, got {num_envs!r}.") - specs = tuple(surface_specs) - if not specs: + if num_envs < 1: + raise ValueError("Conveyor num_envs must be positive.") + if not surface_specs: raise ValueError("At least one SurfaceVelocitySpec is required.") - if not all(isinstance(spec, SurfaceVelocitySpec) for spec in specs): - raise TypeError("Every surface specification must be a SurfaceVelocitySpec.") - if not isinstance(env_path_format, str) or env_path_format.count("{}") != 1: - raise ValueError(f"Conveyor env_path_format must contain exactly one '{{}}', got {env_path_format!r}.") - try: - env_paths = tuple(env_path_format.format(env_id) for env_id in range(num_envs)) - except (IndexError, KeyError, ValueError) as exc: - raise ValueError(f"Invalid conveyor env_path_format: {env_path_format!r}.") from exc - if any(not path.startswith("/") or "{" in path or "}" in path for path in env_paths): - raise ValueError(f"Conveyor env_path_format must produce exact absolute paths, got {env_path_format!r}.") - - templates = tuple(spec.prim_path for spec in specs) - if len(set(templates)) != len(templates): - raise ValueError("Conveyor belt spec paths must be unique within an environment.") - if num_envs > 1 and any(_ENV_REGEX_NS not in path for path in templates): - raise ValueError("Replicated conveyors require every belt prim_path to use {ENV_REGEX_NS}.") - - paths = tuple(template.replace(_ENV_REGEX_NS, env_path) for env_path in env_paths for template in templates) - if len(set(paths)) != len(paths): - raise ValueError("Resolved conveyor prim paths must be unique.") - return paths + templates = tuple(spec.prim_path for spec in surface_specs) + ordered_templates = sorted(templates) + if any( + right == left or right.startswith(f"{left}/") for left, right in zip(ordered_templates, ordered_templates[1:]) + ): + raise ValueError("Conveyor prim paths must be unique and must not be ancestors of one another.") + if env_path_format.count("{}") != 1: + raise ValueError("Conveyor env_path_format must contain exactly one '{}'.") + env_paths = tuple(env_path_format.format(env_id) for env_id in range(num_envs)) + if any( + not path.startswith("/") or not all(part.isidentifier() for part in path[1:].split("/")) for path in env_paths + ): + raise ValueError("Conveyor env_path_format must produce concrete absolute prim paths.") + return tuple(template.replace(_ENV_REGEX_NS, env_path) for env_path in env_paths for template in templates) diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py index 93b07e414069..9e01c56f442f 100644 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_spec.py @@ -12,51 +12,45 @@ from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec, resolve_surface_velocity_paths -def test_surface_velocity_spec_normalizes_vectors() -> None: - """List inputs become immutable vectors before the adapter consumes them.""" - spec = SurfaceVelocitySpec( - prim_path="{ENV_REGEX_NS}/Belt", direction=[3, 4, 0], pivot_point=[1, 2, 3], surface_normal=[0, 0, 1] - ) - assert spec.direction == (3.0, 4.0, 0.0) - assert spec.pivot_point == (1.0, 2.0, 3.0) - assert spec.surface_normal == (0.0, 0.0, 1.0) - - @pytest.mark.parametrize( ("kwargs", "message"), [ ({"prim_path": "relative/Belt"}, "prim_path"), - ({"prim_path": "/"}, "prim_path"), - ({"prim_path": "/World/{ENV_REGEX_NS}/Belt"}, "prim_path"), - ({"prim_path": "/World/Belt", "velocity": None}, "velocity"), - ({"prim_path": "/World/Belt", "velocity": float("nan")}, "velocity"), - ({"prim_path": "/World/Belt", "enabled": 1}, "enabled"), - ({"prim_path": "/World/Belt", "curved": "yes"}, "curved"), - ({"prim_path": "/World/Belt", "direction": (0.0, 0.0, 0.0)}, "direction"), - ({"prim_path": "/World/Belt", "surface_normal": (0.0, 0.0)}, "surface_normal"), - ({"prim_path": "/World/Belt", "radius": 0.0}, "radius"), - ({"prim_path": "/World/Belt", "contact_threshold": 1.1}, "contact_threshold"), - ({"prim_path": "/World/Belt", "friction_coefficient": -0.1}, "friction_coefficient"), + ({"prim_path": "{ENV_REGEX_NS}/Belt.*"}, "prim_path"), + ({"velocity": float("nan")}, "velocity"), + ({"direction": (0.0, 0.0, 0.0)}, "direction"), + ({"surface_normal": (0.0, 0.0)}, "surface_normal"), + ({"pivot_point": (0.0, float("inf"), 0.0)}, "pivot_point"), + ({"curved": True}, "radius"), + ({"radius": 0.0}, "radius"), + ({"contact_threshold": 1.1}, "contact_threshold"), + ({"friction_coefficient": -0.1}, "friction_coefficient"), ], ) def test_surface_velocity_spec_rejects_invalid_authored_values(kwargs: dict, message: str) -> None: """Invalid persistent intent fails before any physics lifecycle is registered.""" + kwargs = {"prim_path": "{ENV_REGEX_NS}/Belt", **kwargs} with pytest.raises(ValueError, match=message): SurfaceVelocitySpec(**kwargs) def test_paths_are_resolved_in_environment_major_order() -> None: """Runtime rows stay deterministic across stage discovery ordering.""" - specs = (SurfaceVelocitySpec("{ENV_REGEX_NS}/BeltA"), SurfaceVelocitySpec("{ENV_REGEX_NS}/Nested/BeltB")) + specs = (SurfaceVelocitySpec("{ENV_REGEX_NS}/Nested/BeltA"), SurfaceVelocitySpec("{ENV_REGEX_NS}/BeltB")) - paths = resolve_surface_velocity_paths(2, specs) + paths = resolve_surface_velocity_paths(2, specs, "/Factory/cell_{}") assert paths == ( - "/World/envs/env_0/BeltA", - "/World/envs/env_0/Nested/BeltB", - "/World/envs/env_1/BeltA", - "/World/envs/env_1/Nested/BeltB", + "/Factory/cell_0/Nested/BeltA", + "/Factory/cell_0/BeltB", + "/Factory/cell_1/Nested/BeltA", + "/Factory/cell_1/BeltB", ) - with pytest.raises(ValueError, match="require every belt"): - resolve_surface_velocity_paths(2, (SurfaceVelocitySpec(prim_path="/World/Shared/Belt"),)) - assert resolve_surface_velocity_paths(1, (SurfaceVelocitySpec("/World/Shared/Belt"),)) == ("/World/Shared/Belt",) + + +@pytest.mark.parametrize("paths", [("Belt", "Belt"), ("Belt/Child", "Belt")]) +def test_paths_reject_ambiguous_surface_ownership(paths: tuple[str, str]) -> None: + """One collider cannot be assigned to duplicate or overlapping conveyor roots.""" + specs = tuple(SurfaceVelocitySpec(f"{{ENV_REGEX_NS}}/{path}") for path in paths) + with pytest.raises(ValueError, match="unique.*ancestors"): + resolve_surface_velocity_paths(1, specs) From a0d4a299ebb4a5a31f8a0a804c80552b9164d503 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 16:28:15 +0200 Subject: [PATCH 30/45] Limit Newton conveyors to authored surface velocities --- .../maximiliank-conveyor-support.added.rst | 2 +- .../isaaclab_contrib/conveyors/README.md | 6 +- .../isaaclab_contrib/conveyors/newton.py | 607 ++++-------------- .../conveyors/test_surface_velocity_newton.py | 222 ++----- 4 files changed, 189 insertions(+), 648 deletions(-) diff --git a/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst b/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst index c44fddcb77d1..6d71071aca0e 100644 --- a/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst +++ b/source/isaaclab_contrib/changelog.d/maximiliank-conveyor-support.added.rst @@ -1 +1 @@ -* Added experimental straight and curved conveyor controls using Newton's upstream force model. +* Added experimental straight and curved conveyor surfaces using Newton's upstream force model. diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md b/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md index d6cb67eaa1b2..10d23f25cff1 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/README.md @@ -10,8 +10,8 @@ experimental and is not exported from Isaac Lab core or the backend packages. including hard resets and registration before CUDA graph capture. It supports the Newton MJWarp solver; other Newton solvers are outside this adapter's scope. -The adapter retains commanded speeds while disabled, restarts its startup ramp -on a full reset, and clears stale forces for selected worlds. The surface spec -controls traction parameters. +Surface speeds and traction parameters come from the authored descriptions. +Full resets restart the startup ramp; episode resets clear stale forces for +only the selected worlds. No meshes, assets, task registrations, or policy checkpoints are bundled here. diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py index 0207335654f8..70748f6b216f 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py @@ -3,11 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Batched surface controls and lifecycle integration for Newton's conveyor force model. - -Newton owns contact classification and traction computation. This adapter resolves replicated -USD surfaces, selects transported bodies, and preserves controls across solver rebuilds. -""" +"""Bind authored conveyor surfaces to Newton's upstream force model and solver lifecycle.""" from __future__ import annotations @@ -16,6 +12,7 @@ from typing import Any import numpy as np +import torch import warp as wp from isaaclab_newton.physics import NewtonManager from newton.examples.basic.example_basic_conveyor_forces import ConveyorForceModel @@ -26,23 +23,7 @@ @wp.kernel -def _update_belt_velocities( - direction: wp.array[wp.vec3], - radius: wp.array[wp.float32], - effective_velocity: wp.array[wp.float32], - linear_velocity: wp.array[wp.vec3], - angular_velocity: wp.array[wp.vec3], -): - conveyor_id = wp.tid() - linear_velocity[conveyor_id] = direction[conveyor_id] * effective_velocity[conveyor_id] - angular_velocity[conveyor_id] = direction[conveyor_id] * (effective_velocity[conveyor_id] / radius[conveyor_id]) - - -@wp.kernel -def _filter_body_forces( - body_is_tracked: wp.array[wp.int32], - body_force: wp.array[wp.spatial_vector], -): +def _filter_body_forces(body_is_tracked: wp.array[wp.int32], body_force: wp.array[wp.spatial_vector]): body_id = wp.tid() if body_is_tracked[body_id] == 0: body_force[body_id] = wp.spatial_vector() @@ -56,31 +37,9 @@ def _advance_startup_scale( scale[0] = wp.min(1.0, elapsed[0] / duration) -@wp.kernel -def _update_effective_velocities( - commanded_velocity: wp.array[wp.float32], - enabled: wp.array[wp.int32], - effective_velocity: wp.array[wp.float32], -): - conveyor_id = wp.tid() - effective_velocity[conveyor_id] = commanded_velocity[conveyor_id] * wp.float32(enabled[conveyor_id]) - - -@wp.kernel -def _gather_values( - source: wp.array(dtype=Any), - indices: wp.array[wp.int32], - values: wp.array(dtype=Any), -): - output_id = wp.tid() - values[output_id] = source[indices[output_id]] - - @wp.kernel def _clear_selected_body_forces( - body_world: wp.array[wp.int32], - world_mask: wp.array[wp.bool], - body_force: wp.array[wp.spatial_vector], + body_world: wp.array[wp.int32], world_mask: wp.array[wp.bool], body_force: wp.array[wp.spatial_vector] ): body_id = wp.tid() world_id = body_world[body_id] @@ -88,66 +47,21 @@ def _clear_selected_body_forces( body_force[body_id] = wp.spatial_vector() -def _as_numpy(values: Any) -> np.ndarray: - """Convert supported tensor-like values to a host NumPy array.""" - if hasattr(values, "detach"): - return values.detach().cpu().numpy() - if hasattr(values, "numpy") and not isinstance(values, np.ndarray): - return values.numpy() - return np.asarray(values) - - -def _world_vector(transform_values: np.ndarray, local_vector: tuple[float, float, float]) -> wp.vec3: - """Rotate a local vector into world space and normalize it.""" - transform = wp.transform( - wp.vec3(*(float(value) for value in transform_values[:3])), - wp.quat(*(float(value) for value in transform_values[3:])), - ) - rotated = wp.transform_vector(transform, wp.vec3(*local_vector)) +def _world_vector(transform: wp.transform, vector: tuple[float, float, float]) -> wp.vec3: + """Rotate and normalize an authored direction or surface normal.""" + rotated = wp.transform_vector(transform, wp.vec3(*vector)) values = np.asarray([float(rotated[index]) for index in range(3)], dtype=np.float32) - norm = float(np.linalg.norm(values)) - if norm <= 1.0e-8: - raise ValueError(f"Conveyor direction or surface normal must be non-zero, got {local_vector}.") - values /= norm + values /= float(np.linalg.norm(values)) return wp.vec3(*(float(value) for value in values)) -def _world_point(transform_values: np.ndarray, local_point: tuple[float, float, float]) -> wp.vec3: - """Transform a local point into world space.""" - transform = wp.transform( - wp.vec3(*(float(value) for value in transform_values[:3])), - wp.quat(*(float(value) for value in transform_values[3:])), - ) - point = wp.transform_point(transform, wp.vec3(*local_point)) - return wp.vec3(*(float(point[index]) for index in range(3))) - - -def _shape_belongs_to_prim(shape_label: str, prim_path: str) -> bool: - """Return whether a Newton collision-shape label is the prim or one of its descendants.""" - return shape_label == prim_path or shape_label.startswith(f"{prim_path.rstrip('/')}/") - - -def _validate_newton_surface_specs(surface_specs: Sequence[SurfaceVelocitySpec]) -> None: - """Validate Newton-specific requirements before registering lifecycle callbacks.""" - prim_paths = [spec.prim_path for spec in surface_specs] - for index, path in enumerate(prim_paths): - for other in prim_paths[index + 1 :]: - if _shape_belongs_to_prim(path, other) or _shape_belongs_to_prim(other, path): - raise ValueError(f"Conveyor prim paths must not be ancestors of one another: {path!r}, {other!r}.") - for spec in surface_specs: - if spec.curved and spec.radius is None: - raise ValueError(f"Newton requires an explicit positive radius for curved belt {spec.prim_path!r}.") - - class SurfaceVelocity: - """Adapt Newton's upstream MuJoCo conveyor force model to the MJWarp lifecycle. + """Own replicated conveyor bindings from model initialization through graph teardown. - The driver is created after the simulation context but before its first - reset. It requests solved contact forces before model finalization, then - binds model-specific buffers after solver initialization and before CUDA - graph capture. A hard simulation reset transparently replaces that binding - with buffers for the re-finalized model. Resolved belt indices use deterministic - environment-major ordering across those rebuilds. + Register after the simulation context is created and before its first reset. + Speeds come from the authored surface descriptions. Newton computes traction + after each solver substep and applies it on the next one. Hard resets rebuild + the binding before CUDA graph capture; episode resets clear selected-world forces. """ def __init__( @@ -156,349 +70,157 @@ def __init__( surface_specs: Sequence[SurfaceVelocitySpec], *, body_pattern: str, - body_count_per_env: int | None = None, + body_count_per_env: int, startup_duration_s: float = 1.0, env_path_format: str = "/World/envs/env_{}", ) -> None: - """Register the force pipeline for the next Newton model initialization. + """Register model and solver callbacks for the authored surfaces. Args: num_envs: Number of replicated simulation environments. - surface_specs: Authored surface descriptions in stable within-environment order. - body_pattern: Regular expression selecting bodies that receive surface traction. - body_count_per_env: Expected selected body count per environment, or ``None``. - startup_duration_s: Duration of the initial traction ramp [s]. - env_path_format: Format string resolving one exact environment root from its integer world index. + surface_specs: Surface descriptions in within-environment order. + body_pattern: Regular expression selecting transported bodies. + body_count_per_env: Expected transported-body count. + startup_duration_s: Initial traction ramp duration [s]. + env_path_format: Clone-template format resolving environment roots. """ - surface_specs = tuple(surface_specs) - prim_paths = resolve_surface_velocity_paths(num_envs, surface_specs, env_path_format) - _validate_newton_surface_specs(surface_specs) + self._surface_specs = tuple(surface_specs) + self._prim_paths = resolve_surface_velocity_paths(num_envs, self._surface_specs, env_path_format) + self._body_pattern = re.compile(body_pattern) if not np.isfinite(startup_duration_s) or startup_duration_s <= 0.0: raise ValueError(f"Conveyor startup duration must be positive, got {startup_duration_s}.") - if not isinstance(body_pattern, str): - raise ValueError(f"body_pattern must be a regular-expression string, got {body_pattern!r}.") - try: - re.compile(body_pattern) - except re.error as exc: - raise ValueError(f"Invalid body pattern: {body_pattern!r}.") from exc - if body_count_per_env is not None and ( - not isinstance(body_count_per_env, int) or isinstance(body_count_per_env, bool) or body_count_per_env < 0 - ): - raise ValueError("body_count_per_env must be a non-negative integer or None.") - self._binding: _SurfaceVelocityBinding | None = None - self._closed = False self._num_envs = num_envs - self._surface_specs = surface_specs - self._binding_kwargs = { - "num_envs": num_envs, - "surface_specs": self._surface_specs, - "startup_duration_s": startup_duration_s, - "prim_paths": prim_paths, - "body_pattern": body_pattern, - "body_count_per_env": body_count_per_env, - } + self._body_count_per_env = body_count_per_env + self._startup_duration_s = startup_duration_s + self._model = None + self._conveyor: ConveyorForceModel | None = None + self._closed = False self._model_init_handle = NewtonManager.register_callback( - self._request_contact_forces, - PhysicsEvent.MODEL_INIT, - name="surface_velocity_contact_attribute", + self._request_contact_forces, PhysicsEvent.MODEL_INIT, name="surface_velocity_contact_attribute" ) - try: - NewtonManager.register_solver_init_callback(self._bind_solver) - except Exception: - self._model_init_handle.deregister() - raise - - def _require_binding(self) -> _SurfaceVelocityBinding: - """Return the current binding or fail before solver initialization.""" - binding = self._binding - if binding is None: - raise RuntimeError("Surface velocity is not bound to an initialized Newton solver.") - return binding - - @property - def num_surfaces(self) -> int: - """Total number of resolved surfaces across all environments.""" - return self._num_envs * len(self._surface_specs) - - @property - def prim_paths(self) -> tuple[str, ...]: - """Resolved Newton shape labels in environment-major belt order.""" - return self._require_binding().surface_paths - - def set_velocities(self, velocities: Any, indices: Any = None) -> None: - """Set signed surface speeds, preserving commands while surfaces are disabled.""" - self._require_binding().set_velocities(velocities, indices) - - def get_velocities(self, indices: Any = None, clone: bool = True) -> wp.array: - """Return effective surface speeds, with disabled surfaces reported as zero.""" - return self._require_binding().get_velocities(indices, clone) - - def get_commanded_velocities(self, indices: Any = None, clone: bool = True) -> wp.array: - """Return staged surface speeds without applying the enabled mask.""" - return self._require_binding().get_commanded_velocities(indices, clone) - - def set_enabled(self, flags: Any, indices: Any = None) -> None: - """Enable or disable selected surfaces without discarding their speed commands.""" - self._require_binding().set_enabled(flags, indices) - - def get_enabled(self, indices: Any = None, clone: bool = True) -> wp.array: - """Return integer enabled flags for selected surfaces.""" - return self._require_binding().get_enabled(indices, clone) - - def reset(self, env_ids: Any = None) -> None: - """Clear stale force state for selected environments.""" - self._require_binding().reset(env_ids) + NewtonManager.register_solver_init_callback(self._bind_solver) def _request_contact_forces(self, _event: Any) -> None: - """Request solved per-contact forces before the Newton model is finalized.""" + """Request solved contact forces before model finalization.""" NewtonManager.request_extended_contact_attribute("force") def _bind_solver(self, model: Any, contacts: Any) -> None: - """Bind graph callbacks and buffers to the newly initialized solver.""" - previous = self._binding - settings = None - if previous is not None: - settings = ( - previous._command_velocity_host.copy(), - previous._enabled_host.copy(), - ) - previous.close() - self._binding = None - - binding = _SurfaceVelocityBinding(model=model, contacts=contacts, **self._binding_kwargs) - if settings is not None: - velocities, enabled = settings - binding.set_velocities(velocities) - binding.set_enabled(enabled) - self._binding = binding - - def close(self) -> None: - """Deregister lifecycle and graph callbacks; repeated calls are safe.""" - if self._closed: - return - NewtonManager.unregister_solver_init_callback(self._bind_solver) - self._model_init_handle.deregister() - if self._binding is not None: - self._binding.close() - self._binding = None - self._closed = True - - -class _SurfaceVelocityBinding: - """Bind replicated surfaces and controls to Newton's conveyor force model.""" - - def __init__( - self, - model: Any, - contacts: Any, - num_envs: int, - surface_specs: Sequence[SurfaceVelocitySpec], - *, - body_pattern: str, - body_count_per_env: int | None = None, - startup_duration_s: float = 1.0, - prim_paths: tuple[str, ...], - ) -> None: - """Initialize the binding before Newton CUDA graph capture. - - Args: - model: Finalized Newton model owned by the active solver. - contacts: Contact buffer owned by the active solver. - num_envs: Number of replicated simulation environments. - surface_specs: Authored surface descriptions in stable within-environment order. - body_pattern: Regular expression selecting bodies that receive surface traction. - body_count_per_env: Expected selected body count per environment, or ``None``. - startup_duration_s: Duration of the initial traction ramp [s]. - prim_paths: Exact collision prim paths in environment-major order. - """ - self._surface_specs = tuple(surface_specs) - compiled_body_pattern = re.compile(body_pattern) - - if model is None or contacts is None: - raise RuntimeError("The conveyor driver requires an initialized Newton model and contact buffer.") - if contacts.force is None: - raise RuntimeError( - "Newton did not allocate per-contact force reporting. The conveyor driver must request the " - "'force' contact attribute before model finalization." - ) - if model.world_count != num_envs: - raise RuntimeError(f"Newton model has {model.world_count} worlds, expected {num_envs}.") - - self._model = model - self._device = model.device - self._num_envs = num_envs - self._startup_duration_s = startup_duration_s - self._closed = False + """Resolve the newly finalized model and register its graph callbacks.""" + if contacts is None or contacts.force is None: + raise RuntimeError("The conveyor requires solved per-contact forces allocated before model finalization.") + if model.world_count != self._num_envs: + raise RuntimeError(f"Newton model has {model.world_count} worlds, expected {self._num_envs}.") surfaces_per_env = len(self._surface_specs) - conveyor_count = num_envs * surfaces_per_env - conveyor_shapes = [-1] * conveyor_count - direction = [wp.vec3() for _ in range(conveyor_count)] - pivot_point = [wp.vec3() for _ in range(conveyor_count)] - radius = [1.0] * conveyor_count - surface_normal = [wp.vec3() for _ in range(conveyor_count)] - surface_paths = [""] * conveyor_count - - shape_body = model.shape_body.numpy() + surface_shapes = [-1] * len(self._prim_paths) shape_world = model.shape_world.numpy() - shape_transform = model.shape_transform.numpy() - seen_sections: set[tuple[int, int]] = set() + shape_body = model.shape_body.numpy() for shape_id, label in enumerate(model.shape_label): world_id = int(shape_world[shape_id]) - if not 0 <= world_id < num_envs: - continue - matching_specs = [ - index - for index in range(surfaces_per_env) - if _shape_belongs_to_prim(label, prim_paths[world_id * surfaces_per_env + index]) - ] - if not matching_specs: + if not 0 <= world_id < self._num_envs: continue - if len(matching_specs) > 1: - raise RuntimeError(f"Conveyor shape {label!r} matches more than one section specification.") - if int(shape_body[shape_id]) >= 0: - raise ValueError(f"Conveyor shape must be static: {label}") - - spec_id = matching_specs[0] - section_key = (world_id, spec_id) - if section_key in seen_sections: - raise RuntimeError( - f"World {world_id} contains multiple shapes matching conveyor section " - f"{self._surface_specs[spec_id].prim_path!r}." - ) - seen_sections.add(section_key) - - spec = self._surface_specs[spec_id] - conveyor_id = world_id * surfaces_per_env + spec_id - conveyor_shapes[conveyor_id] = shape_id - direction[conveyor_id] = _world_vector(shape_transform[shape_id], spec.direction) - pivot_point[conveyor_id] = _world_point(shape_transform[shape_id], spec.pivot_point) - radius[conveyor_id] = 1.0 if spec.radius is None else spec.radius - surface_normal[conveyor_id] = _world_vector(shape_transform[shape_id], spec.surface_normal) - surface_paths[conveyor_id] = label - - expected_sections = { - (world_id, spec_id) for world_id in range(num_envs) for spec_id in range(len(self._surface_specs)) - } - missing_sections = sorted(expected_sections - seen_sections) - if missing_sections: - details = ", ".join( - f"world {world_id}: {self._surface_specs[spec_id].prim_path}" - for world_id, spec_id in missing_sections[:8] - ) - raise RuntimeError(f"Missing {len(missing_sections)} conveyor collision sections ({details}).") + for spec_id in range(surfaces_per_env): + surface_id = world_id * surfaces_per_env + spec_id + path = self._prim_paths[surface_id] + if label != path and not label.startswith(f"{path}/"): + continue + if shape_body[shape_id] >= 0: + raise ValueError(f"Conveyor shape must be static: {label}") + if surface_shapes[surface_id] >= 0: + raise RuntimeError(f"Multiple collision shapes match conveyor surface {path!r}.") + surface_shapes[surface_id] = shape_id + missing = [path for path, shape_id in zip(self._prim_paths, surface_shapes, strict=True) if shape_id < 0] + if missing: + raise RuntimeError(f"Missing {len(missing)} conveyor collision sections: {missing[:8]}.") body_is_tracked = np.zeros(model.body_count, dtype=np.int32) - tracked_counts = np.zeros(num_envs, dtype=np.int32) + tracked_counts = np.zeros(self._num_envs, dtype=np.int32) body_world = model.body_world.numpy() for body_id, label in enumerate(model.body_label): - if compiled_body_pattern.search(label) is None: + if self._body_pattern.search(label) is None: continue world_id = int(body_world[body_id]) - if not 0 <= world_id < num_envs: + if not 0 <= world_id < self._num_envs: raise RuntimeError(f"Transported body {label!r} belongs to invalid world {world_id}.") body_is_tracked[body_id] = 1 tracked_counts[world_id] += 1 - - if body_count_per_env is not None: - bad_worlds = np.flatnonzero(tracked_counts != body_count_per_env) - if bad_worlds.size: - details = ", ".join(f"world {world_id}: {tracked_counts[world_id]}" for world_id in bad_worlds[:8]) - raise RuntimeError( - f"Body pattern {body_pattern!r} expected {body_count_per_env} bodies per world ({details})." - ) + if np.any(tracked_counts != self._body_count_per_env): + raise RuntimeError( + f"Body pattern {self._body_pattern.pattern!r} expected {self._body_count_per_env} bodies per world, " + f"got {tracked_counts.tolist()}." + ) if not np.any(body_is_tracked): - raise RuntimeError(f"Body pattern {body_pattern!r} matched no Newton bodies.") - - self._conveyor = ConveyorForceModel(model, solver_type="mujoco") - for conveyor_id, shape_id in enumerate(conveyor_shapes): - spec = self._surface_specs[conveyor_id % surfaces_per_env] + raise RuntimeError(f"Body pattern {self._body_pattern.pattern!r} matched no Newton bodies.") + + conveyor = ConveyorForceModel(model, solver_type="mujoco") + transforms = model.shape_transform.numpy() + for surface_id, shape_id in enumerate(surface_shapes): + spec = self._surface_specs[surface_id % surfaces_per_env] + values = transforms[shape_id] + transform = wp.transform(wp.vec3(*values[:3]), wp.quat(*values[3:])) + direction = np.asarray(_world_vector(transform, spec.direction), dtype=np.float32) + speed = np.float32(spec.velocity) parameters = { - "surface_normal": surface_normal[conveyor_id], + "surface_normal": _world_vector(transform, spec.surface_normal), "friction": spec.friction_coefficient, "threshold": spec.contact_threshold, } if spec.curved: - self._conveyor.add_pivot_belt(shape_id, pivot_point[conveyor_id], wp.vec3(), **parameters) + conveyor.add_pivot_belt( + shape_id, + wp.transform_point(transform, wp.vec3(*spec.pivot_point)), + wp.vec3(*(direction * (speed / np.float32(spec.radius)))), + **parameters, + ) else: - self._conveyor.add_constant_belt(shape_id, wp.vec3(), **parameters) - self._conveyor.finalize(contacts) - - self._surface_paths = tuple(surface_paths) - self._body_is_tracked = wp.array(body_is_tracked, dtype=wp.int32, device=self._device) - self._direction = wp.array(direction, dtype=wp.vec3, device=self._device) - self._radius = wp.array(radius, dtype=wp.float32, device=self._device) - - authored_velocity = np.asarray([spec.velocity for spec in self._surface_specs], dtype=np.float32) - authored_enabled = np.asarray([spec.enabled for spec in self._surface_specs], dtype=np.int32) - self._command_velocity_host = np.tile(authored_velocity, num_envs) - self._enabled_host = np.tile(authored_enabled, num_envs) - self._command_velocity = wp.array(self._command_velocity_host, dtype=wp.float32, device=self._device) - self._enabled = wp.array(self._enabled_host, dtype=wp.int32, device=self._device) - self._effective_velocity = wp.zeros(conveyor_count, dtype=wp.float32, device=self._device) - self._elapsed_time = wp.zeros(1, dtype=wp.float32, device=self._device) - self._conveyor.set_speed_scale(0.0) - - self._world_mask_host = np.zeros(num_envs, dtype=np.bool_) - self._world_mask = wp.zeros(num_envs, dtype=wp.bool, device=self._device) - self._refresh_effective_velocities() - - NewtonManager.register_state_force_callback(self.apply) - NewtonManager.register_post_solver_substep_callback(self.update) + conveyor.add_constant_belt(shape_id, wp.vec3(*(direction * speed)), **parameters) + conveyor.finalize(contacts) + conveyor.set_speed_scale(0.0) - @property - def surface_paths(self) -> tuple[str, ...]: - """Resolved Newton shape labels in conveyor-index order.""" - return self._surface_paths - - def set_velocities(self, velocities: Any, indices: Any = None) -> None: - """Set signed surface speeds, preserving commands while surfaces are disabled.""" - selected = self._resolve_indices(indices) - self._command_velocity_host[selected] = self._broadcast_1d(velocities, len(selected), "velocities") - self._command_velocity.assign(self._command_velocity_host) - self._refresh_effective_velocities() - - def get_velocities(self, indices: Any = None, clone: bool = True) -> wp.array: - """Return effective surface speeds, with disabled surfaces reported as zero.""" - return self._get_device_values(self._effective_velocity, indices, clone) - - def get_commanded_velocities(self, indices: Any = None, clone: bool = True) -> wp.array: - """Return staged surface speeds without applying the enabled mask.""" - return self._get_device_values(self._command_velocity, indices, clone) - - def set_enabled(self, flags: Any, indices: Any = None) -> None: - """Enable or disable selected surfaces without discarding their speed commands.""" - selected = self._resolve_indices(indices) - values = self._broadcast_1d(flags, len(selected), "enabled flags") - if not np.all(np.isin(values, (0.0, 1.0))): - raise ValueError(f"Surface enabled flags must contain {len(selected)} boolean values.") - self._enabled_host[selected] = values.astype(np.int32) - self._enabled.assign(self._enabled_host) - self._refresh_effective_velocities() - - def get_enabled(self, indices: Any = None, clone: bool = True) -> wp.array: - """Return integer enabled flags for selected surfaces.""" - return self._get_device_values(self._enabled, indices, clone) - - def reset(self, env_ids: Any = None) -> None: - """Clear stale force state for selected environments. + self._model = model + self._conveyor = conveyor + self._body_is_tracked = wp.array(body_is_tracked, dtype=wp.int32, device=model.device) + self._elapsed_time = wp.zeros(1, dtype=wp.float32, device=model.device) + self._world_mask_host = np.zeros(self._num_envs, dtype=np.bool_) + self._world_mask = wp.zeros(self._num_envs, dtype=wp.bool, device=model.device) + NewtonManager.register_state_force_callback(self._apply) + NewtonManager.register_post_solver_substep_callback(self._update) + + def _apply(self, state) -> None: + """Apply the wrench from the preceding solve.""" + self._conveyor.apply(state) - A full reset also restarts the global startup ramp. Partial vectorized - resets leave other environments' conveyor forces and startup state intact. + def _update(self, solver, contacts, state, dt: float) -> None: + """Compute the next wrench using Newton's upstream traction model.""" + wp.launch( + _advance_startup_scale, + dim=1, + inputs=[dt, self._startup_duration_s], + outputs=[self._elapsed_time, self._conveyor.global_velocity_scale], + device=self._model.device, + ) + self._conveyor.update(solver, contacts, state, dt) + wp.launch( + _filter_body_forces, + dim=self._model.body_count, + inputs=[self._body_is_tracked], + outputs=[self._conveyor.conveyor_body_f], + device=self._model.device, + ) - Args: - env_ids: Environment indices to reset, or ``None`` for every environment. - """ + def reset(self, env_ids: Sequence[int] | torch.Tensor | None = None) -> None: + """Clear selected-world forces; a full reset also restarts the startup ramp.""" + if self._conveyor is None: + raise RuntimeError("Surface velocity is not bound to an initialized Newton solver.") if env_ids is None: self._conveyor.conveyor_body_f.zero_() self._elapsed_time.zero_() self._conveyor.global_velocity_scale.zero_() return - - ids = _as_numpy(env_ids) + ids = env_ids.detach().cpu().numpy() if isinstance(env_ids, torch.Tensor) else np.asarray(env_ids) if not np.issubdtype(ids.dtype, np.integer): raise IndexError(f"Surface reset environment indices must be integers, got {env_ids!r}.") - ids = ids.astype(np.int64, copy=False).reshape(-1) + ids = ids.reshape(-1) if np.any((ids < 0) | (ids >= self._num_envs)): raise IndexError(f"Conveyor reset environment indices are out of range: {ids.tolist()}.") self._world_mask_host.fill(False) @@ -512,100 +234,17 @@ def reset(self, env_ids: Any = None) -> None: dim=self._model.body_count, inputs=[self._model.body_world, self._world_mask], outputs=[self._conveyor.conveyor_body_f], - device=self._device, + device=self._model.device, ) def close(self) -> None: - """Deregister Newton callbacks and release references held by the driver.""" + """Detach lifecycle and graph callbacks before the model is released.""" if self._closed: return - NewtonManager.unregister_state_force_callback(self.apply) - NewtonManager.unregister_post_solver_substep_callback(self.update) + NewtonManager.unregister_solver_init_callback(self._bind_solver) + self._model_init_handle.deregister() + NewtonManager.unregister_state_force_callback(self._apply) + NewtonManager.unregister_post_solver_substep_callback(self._update) + self._conveyor = None + self._model = None self._closed = True - - def apply(self, state) -> None: - """Apply the wrench computed from the preceding physics solve.""" - self._conveyor.apply(state) - - def update(self, solver, contacts, state, dt: float) -> None: - """Advance the startup ramp and let Newton compute the next conveyor wrench.""" - wp.launch( - _advance_startup_scale, - dim=1, - inputs=[dt, self._startup_duration_s], - outputs=[self._elapsed_time, self._conveyor.global_velocity_scale], - device=self._device, - ) - self._conveyor.update(solver, contacts, state, dt) - # Exclude robot links and other bodies outside the transported-body selection. - wp.launch( - _filter_body_forces, - dim=self._model.body_count, - inputs=[self._body_is_tracked], - outputs=[self._conveyor.conveyor_body_f], - device=self._device, - ) - - def _resolve_indices(self, indices: Any) -> np.ndarray: - """Normalize and validate a surface index selection.""" - if indices is None: - return np.arange(len(self._surface_paths), dtype=np.int64) - if isinstance(indices, slice): - return np.arange(len(self._surface_paths), dtype=np.int64)[indices] - selected = _as_numpy(indices) - if selected.dtype == np.bool_: - if selected.ndim != 1 or selected.size != len(self._surface_paths): - raise IndexError(f"Boolean surface indices must have length {len(self._surface_paths)}.") - return np.flatnonzero(selected).astype(np.int64) - if not np.issubdtype(selected.dtype, np.integer): - raise IndexError(f"Surface indices must be integers, got {indices!r}.") - selected = selected.astype(np.int64, copy=False).reshape(-1) - if np.any((selected < 0) | (selected >= len(self._surface_paths))): - raise IndexError(f"Conveyor surface indices are out of range: {selected.tolist()}.") - return selected - - def _refresh_effective_velocities(self) -> None: - """Apply the enabled mask at the one device-side command seam.""" - wp.launch( - _update_effective_velocities, - dim=len(self._surface_paths), - inputs=[self._command_velocity, self._enabled], - outputs=[self._effective_velocity], - device=self._device, - ) - wp.launch( - _update_belt_velocities, - dim=len(self._surface_paths), - inputs=[self._direction, self._radius, self._effective_velocity], - outputs=[self._conveyor.conv_const_vel, self._conveyor.conv_pivot_angvel], - device=self._device, - ) - - def _get_device_values(self, source: wp.array, indices: Any, clone: bool) -> wp.array: - """Clone a complete device buffer or gather a selected subset.""" - if indices is None: - return wp.clone(source) if clone else source - selected = self._resolve_indices(indices) - selected_device = wp.array(selected, dtype=wp.int32, device=self._device) - values = wp.empty(len(selected), dtype=source.dtype, device=self._device) - if len(selected) > 0: - wp.launch( - _gather_values, - dim=len(selected), - inputs=[source, selected_device], - outputs=[values], - device=self._device, - ) - return values - - @staticmethod - def _broadcast_1d(values: Any, count: int, name: str) -> np.ndarray: - """Broadcast one scalar or validate one value per selected surface.""" - array = np.asarray(_as_numpy(values), dtype=np.float32) - if not np.all(np.isfinite(array)): - raise ValueError(f"Conveyor {name} must contain only finite values.") - if array.ndim == 0 or array.size == 1: - return np.full(count, float(array.reshape(-1)[0]), dtype=np.float32) - if array.ndim != 1 or array.size != count: - raise ValueError(f"Conveyor {name} need one value or {count} values, got shape {array.shape}.") - return array diff --git a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py index a4030da85fa4..96d2c98f5ae0 100644 --- a/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py +++ b/source/isaaclab_contrib/test/conveyors/test_surface_velocity_newton.py @@ -3,7 +3,7 @@ # # SPDX-License-Identifier: BSD-3-Clause -"""Native CPU contracts for the Newton conveyor adapter.""" +"""Native transport, reset, and callback-lifetime contracts for the Newton adapter.""" import numpy as np import pytest @@ -17,187 +17,89 @@ from isaaclab_contrib.conveyors.surface_velocity import SurfaceVelocitySpec -def test_native_conveyor_controls_rebind_and_reset_independently(monkeypatch): - """Real contacts, models and callbacks preserve commands and discard only selected-world traction.""" +@pytest.mark.parametrize("device", test_devices(DeviceScope.CPU_AND_DEFAULT_CUDA)) +def test_transport_reset_rebind_and_close(device): + """Real straight/pivot traction survives hard resets and leaves no eager or captured callbacks.""" cfg = SimulationCfg( - device="cpu", + device=device, dt=1 / 120, - physics=NewtonCfg(solver_cfg=MJWarpSolverCfg(use_mujoco_contacts=False), use_cuda_graph=False), + physics=NewtonCfg( + solver_cfg=MJWarpSolverCfg(use_mujoco_contacts=False, integrator="implicitfast", cone="elliptic"), + use_cuda_graph=device != "cpu", + ), ) with build_simulation_context(sim_cfg=cfg) as sim: builder = sim.get_or_create_backend(NewtonBuilderCfg(physics_cfg=cfg.physics)) for world in range(2): builder.begin_world() root = f"/World/envs/env_{world}" - for name, y in (("B", 0.5), ("A", -0.5), ("Nested/BeltA", 2.0)): - label = f"{root}/{name}" if "/" in name else f"{root}/Belt{name}" + for name, y in (("B", 0.5), ("A", -0.5)): builder.add_shape_box( - -1, xform=wp.transform((0, y, 0), wp.quat_identity()), hx=1, hy=0.2, hz=0.1, label=label + -1, + xform=wp.transform((0, y, 0), wp.quat_identity()), + hx=1, + hy=0.35, + hz=0.1, + cfg=builder.ShapeConfig(mu=1.1e-5), + label=f"{root}/Belt{name}", ) - cube = builder.add_link(xform=wp.transform((0, -0.5, 0.21), wp.quat_identity()), label=f"{root}/Cube0") - builder.add_shape_box(cube, hx=0.1, hy=0.1, hz=0.1) - builder.add_articulation([builder.add_joint_free(cube)]) + for name, x, y in (("Cube", 0, -0.5 if world == 0 else 0.5), ("Untracked", -0.5, -0.5)): + body = builder.add_link(xform=wp.transform((x, y, 0.21), wp.quat_identity()), label=f"{root}/{name}") + builder.add_shape_box(body, hx=0.1, hy=0.1, hz=0.1, cfg=builder.ShapeConfig(mu=1.1e-5)) + builder.add_articulation([builder.add_joint_free(body)]) builder.end_world() - builder.add_shape_box(-1, hx=0.1, hy=0.1, hz=0.1, label="/World/props/BeltA") callbacks_before = set(NewtonManager._callbacks) - specs = ( - SurfaceVelocitySpec("{ENV_REGEX_NS}/BeltA", velocity=0.1), - SurfaceVelocitySpec("{ENV_REGEX_NS}/BeltB", velocity=-0.2, enabled=False), - ) - calls, bindings = [], [] - - def actuator(): - calls.append("actuator") - - def force(state): - calls.append("force") - - def substep(solver, contacts, state, dt): - calls.append("substep") - - def initialized(model, contacts): - bindings.append((model, contacts)) - - NewtonManager.register_post_actuator_callback(actuator) - observers = ( - (force, NewtonManager.register_state_force_callback, NewtonManager.unregister_state_force_callback), + driver = SurfaceVelocity( + 2, ( - substep, - NewtonManager.register_post_solver_substep_callback, - NewtonManager.unregister_post_solver_substep_callback, + SurfaceVelocitySpec("{ENV_REGEX_NS}/BeltA", velocity=0.2), + SurfaceVelocitySpec( + "{ENV_REGEX_NS}/BeltB", + velocity=0.2, + curved=True, + direction=(0, 0, 1), + pivot_point=(-0.5, 0, 0), + radius=0.5, + ), ), - (initialized, NewtonManager.register_solver_init_callback, NewtonManager.unregister_solver_init_callback), + body_pattern="/Cube$", + body_count_per_env=1, + startup_duration_s=2.0, ) - for callback, register, _ in observers: - register(callback) - NewtonManager.register_solver_init_callback(initialized) - driver = SurfaceVelocity(2, specs, body_pattern="Cube0$", body_count_per_env=1) try: - with pytest.raises(RuntimeError, match="not bound"): - driver.set_velocities(0.2) - sim.reset() - assert bindings == [(NewtonManager.get_model(), NewtonManager.get_contacts())] - first = driver._binding - assert NewtonManager.get_contacts().force is not None - assert driver.prim_paths == tuple( - f"/World/envs/env_{world}/Belt{name}" for world in range(2) for name in ("A", "B") - ) - model = NewtonManager.get_model() - mapping = first._conveyor.shape_conveyor.numpy() - assert [ - mapping[i] for i, label in enumerate(model.shape_label) if "Nested" in label or "/props/" in label - ] == [-1] * 3 - driver.set_velocities([0.3, -0.25, -0.4], indices=[0, 1, 3]) - driver.set_enabled([False, True], indices=[2, 3]) - np.testing.assert_allclose(driver.get_velocities().numpy(), [0.3, 0, 0, -0.4]) - np.testing.assert_allclose(driver.get_velocities(indices=[3, 0]).numpy(), [-0.4, 0.3]) - np.testing.assert_array_equal(driver.get_enabled(indices=[2, 0]).numpy(), [0, 1]) - sim.step(render=False) - assert calls == ["actuator"] + ["force", "substep"] * cfg.physics.num_substeps - first._conveyor.conveyor_body_f.assign(np.ones((2, 6), dtype=np.float32)) + previous_model = None + for _ in range(2): + sim.reset() + model = NewtonManager.get_model() + assert model is not previous_model + previous_model = model + before = NewtonManager.get_state_0().body_q.numpy().copy() + for _ in range(120): + sim.step(render=False) + after = NewtonManager.get_state_0().body_q.numpy() + assert after[0, 0] - before[0, 0] > 0.025 # Straight belt in world 0. + assert after[2, 1] - before[2, 1] > 0.025 # Pivot belt in world 1. + np.testing.assert_allclose(after[[1, 3], :2], before[[1, 3], :2], atol=0.002) + assert np.isfinite(after).all() and np.all(after[:, 2] > 0.15) + + forces = driver._conveyor.conveyor_body_f + forces.assign(np.ones((4, 6), dtype=np.float32)) driver.reset([0]) state = model.state() - first.apply(state) - np.testing.assert_array_equal(state.body_f.numpy(), [[0] * 6, [1] * 6]) - sim.reset() - assert first._closed and driver._binding is not first - assert driver._binding._model is NewtonManager.get_model() - np.testing.assert_allclose(driver.get_velocities().numpy(), [0.3, 0, 0, -0.4]) - assert len(bindings) == 2 and bindings[1] == (NewtonManager.get_model(), NewtonManager.get_contacts()) - assert bindings[0][0] is not bindings[1][0] and bindings[0][1] is not bindings[1][1] - for callback, _, unregister in observers: - unregister(callback) - unregister(callback) - previous_calls = list(calls) - sim.step(render=False) - sim.reset() - assert calls == previous_calls and len(bindings) == 2 - np.testing.assert_array_equal(driver.get_enabled().numpy(), [1, 0, 0, 1]) - np.testing.assert_allclose(driver.get_commanded_velocities().numpy(), [0.3, -0.25, 0.1, -0.4]) - callbacks_live = set(NewtonManager._callbacks) - with monkeypatch.context() as patch: - - def fail_registration(cls, callback): - raise RuntimeError("solver callback unavailable") - - patch.setattr(NewtonManager, "register_solver_init_callback", classmethod(fail_registration)) - with pytest.raises(RuntimeError, match="solver callback unavailable"): - SurfaceVelocity(2, specs, body_pattern="Cube0$") - assert set(NewtonManager._callbacks) == callbacks_live + driver._conveyor.apply(state) + np.testing.assert_array_equal(state.body_f.numpy(), [[0] * 6, [0] * 6, [1] * 6, [1] * 6]) + driver.reset([1, 0]) + np.testing.assert_array_equal(forces.numpy(), np.zeros((4, 6))) + assert driver._conveyor.global_velocity_scale.numpy()[0] == 0 + if device != "cpu": + assert NewtonManager._graph is not None finally: driver.close() driver.close() - assert set(NewtonManager._callbacks) == callbacks_before assert not NewtonManager._solver_init_callbacks assert not NewtonManager._state_force_callbacks assert not NewtonManager._post_solver_substep_callbacks - - -@pytest.mark.parametrize( - ("specs", "num_envs", "env_path", "error", "message"), - [ - ((), 1, "/World/envs/env_{}", ValueError, "At least one"), - ((object(),), 1, "/World/envs/env_{}", TypeError, "SurfaceVelocitySpec"), - ((SurfaceVelocitySpec("/Belt"),) * 2, 1, "/World/envs/env_{}", ValueError, "unique"), - ( - (SurfaceVelocitySpec("/Belt"), SurfaceVelocitySpec("/Belt/Child")), - 1, - "/World/envs/env_{}", - ValueError, - "ancestors", - ), - ((SurfaceVelocitySpec("/Curve", curved=True),), 1, "/World/envs/env_{}", ValueError, "positive radius"), - ((SurfaceVelocitySpec("/Shared/Belt"),), 2, "/World/envs/env_{}", ValueError, "Replicated"), - ((SurfaceVelocitySpec("{ENV_REGEX_NS}/Belt"),), 1, "/World/envs/env_.*", ValueError, "env_path_format"), - ( - (SurfaceVelocitySpec("{ENV_REGEX_NS}/Belt"),), - 1, - "/World/envs/env_{}/{missing}", - ValueError, - "env_path_format", - ), - ], -) -def test_invalid_conveyors_leave_no_callbacks(specs, num_envs, env_path, error, message): - """Rejected authoring must not leave a partially registered adapter.""" - before = set(NewtonManager._callbacks) - with pytest.raises(error, match=message): - SurfaceVelocity(num_envs, specs, env_path_format=env_path, body_pattern="Cube0$") - assert set(NewtonManager._callbacks) == before - - -@pytest.mark.parametrize("device", test_devices(DeviceScope.CUDA)) -def test_removed_callbacks_leave_captured_graph(device): - """Removed callbacks stop writing GPU buffers in eager and recaptured simulation steps.""" - cfg = SimulationCfg(device=device, physics=NewtonCfg(use_cuda_graph=True)) - with build_simulation_context(sim_cfg=cfg) as sim: - builder = sim.get_or_create_backend(NewtonBuilderCfg(physics_cfg=cfg.physics)) - body = builder.add_link(xform=wp.transform_identity()) - builder.add_shape_box(body, hx=0.1, hy=0.1, hz=0.1) - builder.add_articulation([builder.add_joint_free(body)]) - observed = wp.zeros(2, device=device) - ones = wp.ones(2, device=device) - - def force(state): - wp.copy(observed, ones, dest_offset=0, count=1) - - def substep(solver, contacts, state, dt): - wp.copy(observed, ones, dest_offset=1, count=1) - - NewtonManager.register_state_force_callback(force) - NewtonManager.register_post_solver_substep_callback(substep) - sim.reset() - sim.step(render=False) - assert NewtonManager._graph is not None - np.testing.assert_array_equal(observed.numpy(), [1, 1]) - NewtonManager.unregister_state_force_callback(force) - NewtonManager.unregister_post_solver_substep_callback(substep) - observed.zero_() - sim.step(render=False) - np.testing.assert_array_equal(observed.numpy(), [0, 0]) - graph = NewtonManager._graph - NewtonManager.unregister_state_force_callback(force) - NewtonManager.unregister_post_solver_substep_callback(substep) - assert NewtonManager._graph is graph + assert set(NewtonManager._callbacks) == callbacks_before + forces.fill_(1.0) sim.step(render=False) - np.testing.assert_array_equal(observed.numpy(), [0, 0]) + np.testing.assert_array_equal(forces.numpy(), np.ones((4, 6))) From 1a861b341505c688c868f9a1742bd6074a1673d4 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 16:28:19 +0200 Subject: [PATCH 31/45] Remove unused conveyor task control entry points --- .../changelog.d/maximiliank-conveyor-racetrack.added.rst | 2 +- .../isaaclab_tasks/contrib/conveyor_franka/README.md | 2 +- .../contrib/conveyor_franka/conveyor_franka_env.py | 8 -------- .../contrib/conveyor_franka/conveyor_geometry.py | 2 -- .../contrib/conveyor_franka/mdp/commands.py | 6 ------ 5 files changed, 2 insertions(+), 18 deletions(-) diff --git a/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst index 971fa697b75c..b7b736627707 100644 --- a/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst +++ b/source/isaaclab_tasks/changelog.d/maximiliank-conveyor-racetrack.added.rst @@ -1 +1 @@ -* Added Newton four-cube conveyor racetrack transfer with a pretrained policy, reset curriculum, and seeded evaluation. +* Added Newton four-cube conveyor racetrack transfer with fixed belt speeds, a pretrained policy, reset curriculum, and seeded evaluation. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md index f5a5b94d9043..e9811f4614bf 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md @@ -8,7 +8,7 @@ with 120 Hz physics and a 0.35 m/s belt speed. Newton traction uses the upstream `ConveyorForceModel`, including its MuJoCo contact reporting. `isaaclab_contrib.conveyors.newton` adapts replicated surfaces, parcel selection, -controls, and reset lifecycle; the task owns its geometry, commands, and reset recipes. +configured belt speeds, and reset lifecycle; the task owns its geometry, commands, and reset recipes. ## Run diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py index 440c749c7100..f7ee9f3d3f87 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py @@ -49,14 +49,6 @@ def _init_sim(self) -> None: self._conveyor_driver = None raise - @property - def conveyor_belt(self) -> SurfaceVelocity: - """Tensorized conveyor control view for this environment.""" - driver = self._conveyor_driver - if driver is None: - raise RuntimeError("The conveyor belt is unavailable before simulation initialization or after close().") - return driver - def _setup_goal_selector(self) -> None: """Attach one task panel to the first supported interactive visualizer.""" for visualizer in self.sim.visualizers: diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py index eca495f9563e..1217e449364d 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_geometry.py @@ -244,7 +244,6 @@ def belt_collision_section_specs( velocity: float = 0.0, friction_coefficient: float = 0.7, contact_threshold: float = 0.997, - enabled: bool = True, ) -> tuple[ConveyorSectionSpec, ConveyorSectionSpec, ConveyorSectionSpec, ConveyorSectionSpec]: """Build collision geometry and authored conveyor intent for one racetrack.""" center_y = BELT_CENTER_Y if side == "Left" else -BELT_CENTER_Y @@ -266,7 +265,6 @@ def belt( belt=SurfaceVelocitySpec( prim_path=f"{{ENV_REGEX_NS}}/{geometry.name}", velocity=velocity, - enabled=enabled, direction=travel_direction, curved=curved, pivot_point=pivot_point, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py index b223693c4c6a..3b4d859d1405 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/commands.py @@ -250,12 +250,6 @@ def _resolve_env_ids(self, env_ids: Sequence[int] | torch.Tensor | slice | None) raise IndexError("Conveyor command environment indices are out of range.") return ids - def _set_debug_vis_impl(self, debug_vis: bool) -> None: - raise NotImplementedError("Conveyor commands are visualized by the Newton goal selector.") - - def _debug_vis_callback(self, event) -> None: - raise NotImplementedError("Conveyor commands are visualized by the Newton goal selector.") - @configclass class ConveyorTransferCommandCfg(CommandTermCfg): From 46f1aeac17c6333d16872916c2ae17551ceb421e Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 17:15:26 +0200 Subject: [PATCH 32/45] Use the resolved warehouse viewer configuration --- .../conveyor_franka_asset_env_cfg.py | 6 +++--- .../conveyor_franka_warehouse_env.py | 4 ++-- .../test/contrib/test_conveyor_franka_asset_cfg.py | 14 +++++++++++++- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py index 3bd6fce50581..136f14bb2c55 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py @@ -397,9 +397,9 @@ def __post_init__(self) -> None: "minimum": (-0.4, -1.2, -0.05), "maximum": (2.85, 1.2, 0.8), } - self.sim.default_visualizer_cfg.eye = (4.8, -5.2, 3.0) - self.sim.default_visualizer_cfg.lookat = (0.9, 0.6, 0.95) - self.sim.default_visualizer_cfg.focal_length = 24.0 + self.sim.visualizer_cfgs.eye = (4.8, -5.2, 3.0) + self.sim.visualizer_cfgs.lookat = (0.9, 0.6, 0.95) + self.sim.visualizer_cfgs.focal_length = 24.0 def play_mode(self) -> None: """Enable the idle controller only for pretrained-policy playback.""" diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py index 18eff8a2aaa2..59cf0ea31a20 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py @@ -35,7 +35,8 @@ def __init__(self, cfg: ConveyorFrankaA09A12EnvCfg, render_mode: str | None = No self._warehouse_arm_joint_ids: list[int] | None = None cfg.scene._configure_route_assets(cfg.commands.transfer.parcel_colors) super().__init__(cfg, render_mode=render_mode, **kwargs) - if not any(viz.cfg.visualizer_type == "kit" for viz in self.sim.visualizers): + viewer = next((viz.cfg for viz in self.sim.visualizers if viz.cfg.backend.visualizer_type == "kit"), None) + if viewer is None: return self.sim.set_setting("/app/viewport/grid/enabled", False) @@ -46,7 +47,6 @@ def __init__(self, cfg: ConveyorFrankaA09A12EnvCfg, render_mode: str | None = No camera = self.sim.stage.GetPrimAtPath("/OmniverseKit_Persp") if camera: # Kit owns this camera in its session layer; weaker root-layer edits are ignored. - viewer = cfg.sim.default_visualizer_cfg with Usd.EditContext(self.sim.stage, self.sim.stage.GetSessionLayer()): self.sim.set_camera_view(viewer.eye, viewer.lookat) camera.GetAttribute("focalLength").Set(viewer.focal_length) diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py index 96901a675a9e..1d48e054bef4 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py @@ -188,6 +188,7 @@ def test_warehouse_animation_uses_active_kit_viewer_and_policy_time(tmp_path, mo source.GetRootLayer().Save() stage = Usd.Stage.CreateInMemory() stage.DefinePrim("/World/envs/env_0/WarehouseVisual").GetReferences().AddReference(path) + camera = UsdGeom.Camera.Define(stage, "/OmniverseKit_Persp") def initialize(env, cfg, **kwargs): env.cfg = cfg @@ -195,7 +196,17 @@ def initialize(env, cfg, **kwargs): env.scene = SimpleNamespace(env_prim_paths=["/World/envs/env_0"]) env.sim = SimpleNamespace( stage=stage, - visualizers=[SimpleNamespace(cfg=SimpleNamespace(visualizer_type="kit"))], + visualizers=[ + SimpleNamespace( + cfg=SimpleNamespace( + backend=SimpleNamespace(visualizer_type="kit"), + eye=(4.0, -5.0, 3.0), + lookat=(0.9, 0.6, 0.95), + focal_length=37.0, + ) + ) + ], + set_camera_view=lambda eye, lookat: None, set_setting=lambda name, value: None, add_render_callback=lambda name, callback: None, remove_render_callback=lambda name: None, @@ -209,6 +220,7 @@ def initialize(env, cfg, **kwargs): cfg.sim.visualizer_cfgs = [] cfg.scene.warehouse_visual.spawn.usd_path = path env = ConveyorFrankaWarehouseEnv(cfg) + assert camera.GetFocalLengthAttr().Get() == 37.0 action = torch.zeros(1, 8) try: # Headless Kit recording captures on demand, without invoking render callbacks. From 645b74052afddb8ff67bc62c2c903eb2d3bb04b1 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 17:21:26 +0200 Subject: [PATCH 33/45] Use the owned backend manager in sorter contact checks --- .../isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py index c2f95e46c952..c51803a92569 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py @@ -99,8 +99,8 @@ def test_checkpoint_interface_and_invalid_action_recovery(task): _place(env, positions) if pool is not None: env.sim.step(render=False) - model = env.sim.physics_manager.get_model() - contacts = env.sim.physics_manager.get_contacts() + model = env.sim.backend_manager.get_model() + contacts = env.sim.backend_manager.get_contacts() count = int(contacts.rigid_contact_count.numpy()[0]) assert count > 0 bodies = model.shape_body.numpy() From 21bc31bf6d2a9f85d96779e90aa23471ac59af6e Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:31:25 +0100 Subject: [PATCH 34/45] Simplify conveyor action state and bind the Panda tool once --- .../conveyor_franka/conveyor_franka_env.py | 28 +++++++---- .../contrib/conveyor_franka/mdp/actions.py | 19 +++---- .../conveyor_franka/mdp/curriculums.py | 6 +-- .../contrib/conveyor_franka/mdp/kinematics.py | 49 ++++--------------- 4 files changed, 39 insertions(+), 63 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py index 114a7ad146c3..5099c1fbc55f 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py @@ -9,6 +9,8 @@ from collections.abc import Sequence +import torch + from isaaclab.envs import ManagerBasedRLEnv from isaaclab_contrib.conveyors.newton import SurfaceVelocity @@ -22,7 +24,7 @@ class ConveyorFrankaEnv(ManagerBasedRLEnv): cfg: ConveyorFrankaEnvCfg - def __init__(self, cfg: ConveyorFrankaEnvCfg, render_mode: str | None = None, **kwargs): + def __init__(self, cfg: ConveyorFrankaEnvCfg, render_mode: str | None = None, **kwargs) -> None: self._conveyor_driver: SurfaceVelocity | None = None super().__init__(cfg, render_mode=render_mode, **kwargs) self._setup_goal_selector() @@ -50,21 +52,29 @@ def _init_sim(self) -> None: self._conveyor_driver = None raise + def load_managers(self) -> None: + """Bind the fixed Panda tool frame before manager terms inspect it.""" + hand_ids, _ = self.scene["robot"].find_bodies("panda_hand") + if len(hand_ids) != 1: + raise ValueError(f"Expected one Panda hand body, found {len(hand_ids)}.") + self._conveyor_hand_body_id = hand_ids[0] + self._conveyor_tool_offset = torch.tensor((0.0, 0.0, 0.1034), dtype=torch.float32, device=self.device).expand( + self.num_envs, -1 + ) + super().load_managers() + def _setup_goal_selector(self) -> None: - """Attach one task panel to the first supported interactive visualizer.""" + """Attach one task panel to the first GL visualizer.""" for visualizer in self.sim.visualizers: - if visualizer.cfg.backend.visualizer_type not in {"newton_gl", "newton_rtx"}: - continue - register_callback = getattr(visualizer, "register_ui_callback", None) - if register_callback is None: + if visualizer.cfg.backend.visualizer_type != "newton_gl": continue visible_env_ids = visualizer.get_visualized_env_ids() env_id = visible_env_ids[0] if visible_env_ids else 0 selector = ConveyorGoalSelector(self, env_id) - register_callback(selector.render, position="panel") + visualizer.register_ui_callback(selector.render, position="panel") return - def _reset_idx(self, env_ids: Sequence[int] | slice): + def _reset_idx(self, env_ids: Sequence[int] | torch.Tensor | slice) -> None: """Reset selected environments and discard stale conveyor forces.""" if isinstance(env_ids, slice): env_ids = self.scene._ALL_INDICES[env_ids] @@ -74,7 +84,7 @@ def _reset_idx(self, env_ids: Sequence[int] | slice): if conveyor_driver is not None: conveyor_driver.reset(env_ids) - def close(self): + def close(self) -> None: """Release conveyor callbacks before the physics scene is destroyed.""" conveyor_driver = self._conveyor_driver if conveyor_driver is not None: diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py index 3e24729dd627..5ca9f7c14a4b 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/actions.py @@ -48,7 +48,7 @@ def __init__(self, cfg: ConveyorRelativeJointPositionActionCfg, env: ManagerBase raise ValueError("workspace bounds must be finite.") self._previous_actions = torch.zeros_like(self._raw_actions) self._invalid_actions = torch.zeros(self.num_envs, dtype=torch.bool, device=self.device) - self._position_targets = self._asset.data.joint_pos.torch[:, self._joint_ids].clone() + self._processed_actions = self._asset.data.joint_pos.torch[:, self._joint_ids].clone() @property def previous_actions(self) -> torch.Tensor: @@ -71,23 +71,20 @@ def process_actions(self, actions: torch.Tensor) -> None: limits = self._asset.data.soft_joint_pos_limits.torch[:, self._joint_ids] lower = torch.maximum(limits[..., 0] + self.cfg.joint_limit_margin, self._workspace_lower) upper = torch.minimum(limits[..., 1] - self.cfg.joint_limit_margin, self._workspace_upper) - self._position_targets = torch.clamp(positions + delta, min=lower, max=upper) - self._processed_actions = self._position_targets + self._processed_actions = torch.clamp(positions + delta, min=lower, max=upper) def apply_actions(self) -> None: """Hold the policy-step target through all physics substeps.""" - self._asset.actuators.target_command.set_position_index(value=self._position_targets, joint_ids=self._joint_ids) + self._asset.actuators.target_command.set_position_index( + value=self._processed_actions, joint_ids=self._joint_ids + ) - def reset(self, env_ids: Sequence[int] | None = None) -> None: + def reset(self, env_ids: Sequence[int] | torch.Tensor | None = None) -> None: """Initialize targets from the sampled reset pose.""" super().reset(env_ids) + env_ids = slice(None) if env_ids is None else env_ids positions = self._asset.data.joint_pos.torch[:, self._joint_ids] - if env_ids is None: - self._position_targets[:] = positions - self._processed_actions[:] = positions - else: - self._position_targets[env_ids] = positions[env_ids] - self._processed_actions[env_ids] = positions[env_ids] + self._processed_actions[env_ids] = positions[env_ids] self._previous_actions[env_ids] = 0.0 self._invalid_actions[env_ids] = False diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py index a01340f1b709..dff99bd7d286 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py @@ -32,7 +32,7 @@ class ConveyorResetCurriculum(ManagerTermBase): evidence lives here; standard PPO checkpoints start a fresh sampling history on resume. """ - def __init__(self, cfg: CurriculumTermCfg, env: ManagerBasedRLEnv): + def __init__(self, cfg: CurriculumTermCfg, env: ManagerBasedRLEnv) -> None: super().__init__(cfg, env) self._reset_term = env.event_manager.get_term_cfg("reset_from_state_table").func self.success_monitor = instantiate(cfg.params["success_monitor"], 1, self._reset_term.row_count, env.device) @@ -41,7 +41,7 @@ def __init__(self, cfg: CurriculumTermCfg, env: ManagerBasedRLEnv): def __call__( self, env: ManagerBasedRLEnv, - env_ids: Sequence[int] | slice, + env_ids: Sequence[int] | torch.Tensor | slice, success_monitor: SuccessMonitorCfg, sampling: SamplerCfg, command_name: str = "transfer", @@ -50,7 +50,7 @@ def __call__( """Record finished episodes and draw rows for only the resetting environments.""" del success_monitor, sampling ids = ( - torch.arange(env.num_envs, device=env.device)[env_ids] + env.scene._ALL_INDICES[env_ids] if isinstance(env_ids, slice) else torch.as_tensor(env_ids, dtype=torch.long, device=env.device).flatten() ) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py index 2edaaa038abd..9380bc345a46 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/kinematics.py @@ -11,61 +11,30 @@ import torch -from isaaclab.managers import SceneEntityCfg from isaaclab.utils import math as math_utils if TYPE_CHECKING: from isaaclab.assets import Articulation - from isaaclab.envs import ManagerBasedRLEnv + from ..conveyor_franka_env import ConveyorFrankaEnv -def _end_effector_cache_entry( - env: ManagerBasedRLEnv, - robot_cfg: SceneEntityCfg, - body_name: str, - body_offset: tuple[float, float, float], -) -> tuple[Articulation, int, torch.Tensor]: - """Resolve and cache the Franka hand body and tool-frame offset.""" - robot: Articulation = env.scene[robot_cfg.name] - cache = getattr(env, "_conveyor_end_effector_cache", None) - if cache is None: - cache = {} - env._conveyor_end_effector_cache = cache - key = (robot_cfg.name, body_name, body_offset) - entry = cache.get(key) - if entry is None: - body_ids, _ = robot.find_bodies(body_name) - if len(body_ids) != 1: - raise ValueError(f"Expected one end-effector body matching '{body_name}', found {len(body_ids)}.") - entry = (body_ids[0], torch.tensor(body_offset, dtype=torch.float32, device=env.device)) - cache[key] = entry - return robot, entry[0], entry[1] - -def end_effector_pose( - env: ManagerBasedRLEnv, - robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), - body_name: str = "panda_hand", - body_offset: tuple[float, float, float] = (0.0, 0.0, 0.1034), -) -> tuple[torch.Tensor, torch.Tensor]: +def end_effector_pose(env: ConveyorFrankaEnv) -> tuple[torch.Tensor, torch.Tensor]: """Return the Franka tool-center position [m] and orientation.""" - robot, body_id, offset = _end_effector_cache_entry(env, robot_cfg, body_name, body_offset) + robot: Articulation = env.scene["robot"] + body_id = env._conveyor_hand_body_id orientation = robot.data.body_quat_w.torch[:, body_id] position = robot.data.body_pos_w.torch[:, body_id] - position = position + math_utils.quat_apply(orientation, offset.expand(env.num_envs, -1)) + position = position + math_utils.quat_apply(orientation, env._conveyor_tool_offset) return position, orientation -def tool_velocity( - env: ManagerBasedRLEnv, - robot_cfg: SceneEntityCfg = SceneEntityCfg("robot"), - body_name: str = "panda_hand", - body_offset: tuple[float, float, float] = (0.0, 0.0, 0.1034), -) -> torch.Tensor: +def tool_velocity(env: ConveyorFrankaEnv) -> torch.Tensor: """Return tool-center linear and angular velocity [m/s, rad/s].""" - robot, body_id, offset = _end_effector_cache_entry(env, robot_cfg, body_name, body_offset) + robot: Articulation = env.scene["robot"] + body_id = env._conveyor_hand_body_id orientation = robot.data.body_quat_w.torch[:, body_id] body_velocity = robot.data.body_link_vel_w.torch[:, body_id] - offset_world = math_utils.quat_apply(orientation, offset.expand(env.num_envs, -1)) + offset_world = math_utils.quat_apply(orientation, env._conveyor_tool_offset) linear_velocity = body_velocity[:, :3] + torch.linalg.cross(body_velocity[:, 3:], offset_world) return torch.cat((linear_velocity, body_velocity[:, 3:]), dim=1) From 85c2a3c441ecc2c74d3ba6269146475a474a0891 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:31:29 +0100 Subject: [PATCH 35/45] Reuse native mesh and cuboid spawners in conveyor scene --- .../conveyor_franka_env_cfg.py | 47 +++---------------- 1 file changed, 7 insertions(+), 40 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py index e71e3fb3625b..d6d3efde51ca 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py @@ -25,7 +25,7 @@ from isaaclab.managers import TerminationTermCfg as DoneTerm from isaaclab.scene import InteractiveSceneCfg from isaaclab.sim import SimulationCfg -from isaaclab.sim.schemas import CollisionFragment, UsdPhysicsCollisionCfg +from isaaclab.sim.schemas import CollisionFragment, UsdPhysicsCollisionCfg, UsdPhysicsMeshCollisionCfg from isaaclab.sim.spawners.materials import RigidBodyMaterialBaseCfg from isaaclab.utils.configclass import configclass from isaaclab.visualizers import VisualizerCfg @@ -75,7 +75,6 @@ def _validate_common_config(cfg: ConveyorFrankaEnvCfg) -> None: """Validate backend-independent timing and policy tensor contracts.""" - cfg.conveyor_force.validate_config() if not cfg.sim.use_newton_actuators: raise ValueError("The conveyor Franka requires the shared Newton-actuator execution path.") if not math.isfinite(cfg.sim.dt) or cfg.sim.dt <= 0.0 or cfg.decimation <= 0: @@ -100,13 +99,6 @@ def _collision_properties(contact_margin: float = 0.0, mujoco_priority: int = 0) ] -def _srgb_to_linear_channel(value: float) -> float: - """Convert an sRGB channel to the linear value expected by USD displayColor.""" - if value <= 0.04045: - return value / 12.92 - return ((value + 0.055) / 1.055) ** 2.4 - - @configclass class ActionsCfg: """Relative arm and binary gripper actions.""" @@ -294,10 +286,6 @@ class ConveyorForceCfg: transported_body_count_per_env: int = 4 """Expected number of transported rigid bodies in each environment.""" - def __post_init__(self) -> None: - """Validate conveyor force parameters.""" - self.validate_config() - def validate_config(self) -> None: """Validate final conveyor-force values after overrides are applied.""" if not math.isfinite(self.speed) or self.speed < 0.0: @@ -319,27 +307,6 @@ def validate_config(self) -> None: raise ValueError(f"Invalid conveyor transported-body pattern: {self.transported_body_pattern!r}.") from exc -@sim_utils.clone -def _spawn_shape_with_display_color( - prim_path: str, - cfg: sim_utils.CuboidCfg, - translation: tuple[float, float, float] | None = None, - orientation: tuple[float, float, float, float] | None = None, - **kwargs, -): - """Spawn a cuboid and author a renderer-independent USD display color.""" - prim = sim_utils.spawn_cuboid(prim_path, cfg, translation, orientation, **kwargs) - - if cfg.visual_material is not None: - from pxr import Usd, UsdGeom - - display_color = tuple(_srgb_to_linear_channel(value) for value in cfg.visual_material.diffuse_color) - for child in Usd.PrimRange(prim): - if child.IsA(UsdGeom.Gprim): - UsdGeom.Gprim(child).CreateDisplayColorAttr([display_color]) - return prim - - def _static_cuboid( prim_path: str, size: tuple[float, float, float], @@ -351,7 +318,6 @@ def _static_cuboid( prim_path=prim_path, init_state=AssetBaseCfg.InitialStateCfg(pos=pos), spawn=sim_utils.CuboidCfg( - func=_spawn_shape_with_display_color, size=size, collision_props=_collision_properties(), physics_material=RigidBodyMaterialBaseCfg(static_friction=0.7, dynamic_friction=0.7, restitution=0.0), @@ -372,7 +338,10 @@ def _conveyor_asset( spawn = sim_utils.CuboidCfg(size=spec.size) position = spec.position else: - spawn = sim_utils.MeshCustomCfg(vertices=spec.vertices, faces=spec.faces) + spawn = sim_utils.MeshFileCfg( + mesh=sim_utils.MeshFileCfg.TriangleMeshCfg(vertices=spec.vertices, faces=spec.faces), + mesh_collision_props=UsdPhysicsMeshCollisionCfg(mesh_approximation_name="none"), + ) position = (0.0, 0.0, 0.0) spawn.visible = visual_material is not None spawn.visual_material = visual_material @@ -397,7 +366,6 @@ def _cube( ) -> RigidObjectCfg: """Build one numbered dynamic transfer cube.""" spawn = sim_utils.CuboidCfg( - func=_spawn_shape_with_display_color, size=(0.04, 0.04, 0.04), rigid_props=sim_utils.UsdPhysicsRigidBodyCfg(), mass_props=sim_utils.MassCfg(mass=0.05), @@ -503,7 +471,7 @@ class ConveyorFrankaEnvCfg(ManagerBasedRLEnvCfg): """Manager-based RL task for commanded conveyor-to-conveyor cube transfer.""" class_type: type | str = "{DIR}.conveyor_franka_env:ConveyorFrankaEnv" - scene: ConveyorFrankaSceneCfg = ConveyorFrankaSceneCfg(num_envs=256, env_spacing=3.0, replicate_physics=True) + scene: ConveyorFrankaSceneCfg = ConveyorFrankaSceneCfg(num_envs=256, env_spacing=3.0) conveyor_force: ConveyorForceCfg = ConveyorForceCfg() actions: ActionsCfg = ActionsCfg() commands: CommandsCfg = CommandsCfg() @@ -523,8 +491,7 @@ class ConveyorFrankaEnvCfg(ManagerBasedRLEnvCfg): solver="newton", integrator="implicitfast", # Per-environment capacities include headroom for all four cubes, - # the gripper, table, belts, and guard contacts without allocating - # the previous 256k-contact scene-wide driver buffers. + # the gripper, table, belts, and guard contacts. njmax=300, nconmax=200, impratio=1.0, From 96afdacd306dc266de8208141e0977b30bc51821 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:33:45 +0100 Subject: [PATCH 36/45] Drop the redundant custom mesh foundation --- .../maximiliank-custom-mesh.added.rst | 2 - .../changelog.d/maximiliank-custom-mesh.minor | 0 source/isaaclab/isaaclab/sim/__init__.pyi | 4 -- .../isaaclab/sim/spawners/__init__.pyi | 4 -- .../isaaclab/sim/spawners/meshes/__init__.pyi | 4 -- .../isaaclab/sim/spawners/meshes/meshes.py | 58 +------------------ .../sim/spawners/meshes/meshes_cfg.py | 20 ------- source/isaaclab/test/sim/test_spawn_meshes.py | 31 ---------- 8 files changed, 2 insertions(+), 121 deletions(-) delete mode 100644 source/isaaclab/changelog.d/maximiliank-custom-mesh.added.rst delete mode 100644 source/isaaclab/changelog.d/maximiliank-custom-mesh.minor diff --git a/source/isaaclab/changelog.d/maximiliank-custom-mesh.added.rst b/source/isaaclab/changelog.d/maximiliank-custom-mesh.added.rst deleted file mode 100644 index 4f2d5bb1aa63..000000000000 --- a/source/isaaclab/changelog.d/maximiliank-custom-mesh.added.rst +++ /dev/null @@ -1,2 +0,0 @@ -* Added :class:`~isaaclab.sim.MeshCustomCfg` for spawning meshes from authored - vertices and triangular faces. diff --git a/source/isaaclab/changelog.d/maximiliank-custom-mesh.minor b/source/isaaclab/changelog.d/maximiliank-custom-mesh.minor deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/source/isaaclab/isaaclab/sim/__init__.pyi b/source/isaaclab/isaaclab/sim/__init__.pyi index 3d3c12f24c36..480e1b8f8a1a 100644 --- a/source/isaaclab/isaaclab/sim/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/__init__.pyi @@ -133,7 +133,6 @@ __all__ = [ "VisualMaterialCfg", "spawn_mesh_capsule", "spawn_mesh_cone", - "spawn_mesh_custom", "spawn_mesh_cuboid", "spawn_mesh_cylinder", "spawn_mesh_rectangle", @@ -141,7 +140,6 @@ __all__ = [ "MeshCapsuleCfg", "MeshCfg", "MeshConeCfg", - "MeshCustomCfg", "MeshCuboidCfg", "MeshCylinderCfg", "MeshRectangleCfg", @@ -352,7 +350,6 @@ from .spawners import ( MeshCapsuleCfg, MeshCfg, MeshConeCfg, - MeshCustomCfg, MeshCuboidCfg, MeshCylinderCfg, MeshRectangleCfg, @@ -397,7 +394,6 @@ from .spawners import ( spawn_light, spawn_mesh_capsule, spawn_mesh_cone, - spawn_mesh_custom, spawn_mesh_cuboid, spawn_mesh_cylinder, spawn_mesh_rectangle, diff --git a/source/isaaclab/isaaclab/sim/spawners/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/__init__.pyi index 1cc2db9bd5c2..930f07a51acb 100644 --- a/source/isaaclab/isaaclab/sim/spawners/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/__init__.pyi @@ -45,7 +45,6 @@ __all__ = [ "VisualMaterialCfg", "spawn_mesh_capsule", "spawn_mesh_cone", - "spawn_mesh_custom", "spawn_mesh_cuboid", "spawn_mesh_cylinder", "spawn_mesh_rectangle", @@ -53,7 +52,6 @@ __all__ = [ "MeshCapsuleCfg", "MeshCfg", "MeshConeCfg", - "MeshCustomCfg", "MeshCuboidCfg", "MeshCylinderCfg", "MeshRectangleCfg", @@ -129,7 +127,6 @@ from .materials import ( from .meshes import ( spawn_mesh_capsule, spawn_mesh_cone, - spawn_mesh_custom, spawn_mesh_cuboid, spawn_mesh_cylinder, spawn_mesh_rectangle, @@ -137,7 +134,6 @@ from .meshes import ( MeshCapsuleCfg, MeshCfg, MeshConeCfg, - MeshCustomCfg, MeshCuboidCfg, MeshCylinderCfg, MeshRectangleCfg, diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi b/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi index fd1e4ac5fdf5..c853dca4d1d0 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/__init__.pyi @@ -6,7 +6,6 @@ __all__ = [ "spawn_mesh_capsule", "spawn_mesh_cone", - "spawn_mesh_custom", "spawn_mesh_cuboid", "spawn_mesh_cylinder", "spawn_mesh_rectangle", @@ -14,7 +13,6 @@ __all__ = [ "MeshCapsuleCfg", "MeshCfg", "MeshConeCfg", - "MeshCustomCfg", "MeshCuboidCfg", "MeshCylinderCfg", "MeshRectangleCfg", @@ -24,7 +22,6 @@ __all__ = [ from .meshes import ( spawn_mesh_capsule, spawn_mesh_cone, - spawn_mesh_custom, spawn_mesh_cuboid, spawn_mesh_cylinder, spawn_mesh_rectangle, @@ -34,7 +31,6 @@ from .meshes_cfg import ( MeshCapsuleCfg, MeshCfg, MeshConeCfg, - MeshCustomCfg, MeshCuboidCfg, MeshCylinderCfg, MeshRectangleCfg, diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py index 84a22f67b7ef..277557f05749 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes.py @@ -29,62 +29,11 @@ props_expr, resolve_deformable_slot, ) -from .meshes_cfg import MeshCustomCfg if TYPE_CHECKING: from . import meshes_cfg -@clone -def spawn_mesh_custom( - prim_path: str, - cfg: meshes_cfg.MeshCustomCfg, - translation: tuple[float, float, float] | None = None, - orientation: tuple[float, float, float, float] | None = None, - **kwargs, -) -> Usd.Prim: - """Create a USD mesh from explicitly authored vertices and triangular faces. - - This spawner is intended for custom static geometry such as terrain patches, - tracks, and fixtures that cannot be represented by the generated mesh - primitives. Face winding is preserved, so counter-clockwise faces define the - colliding side when exact triangle-mesh collision is selected. - - Args: - prim_path: Prim path or pattern at which to spawn the asset. - cfg: Custom mesh configuration. - translation: Translation relative to the parent prim [m]. - orientation: Quaternion orientation in ``(x, y, z, w)`` order. - **kwargs: Additional cloning keyword arguments. - - Returns: - The created root prim. - - Raises: - ValueError: If the vertex or face arrays are malformed, a face index is - out of range. - """ - del kwargs - with np.errstate(over="ignore"): - vertices = np.asarray(cfg.vertices, dtype=np.float32) - if not np.isfinite(vertices).all(): - raise ValueError("Custom mesh vertices must be finite USD float32 points.") - faces = np.asarray(cfg.faces) - if not np.issubdtype(faces.dtype, np.integer): - raise ValueError("Custom mesh face indices must be integers.") - if vertices.ndim != 2 or vertices.shape[1:] != (3,) or len(vertices) < 3: - raise ValueError(f"Custom mesh vertices must have shape (N, 3) with N >= 3, got {vertices.shape}.") - if faces.ndim != 2 or faces.shape[1:] != (3,) or len(faces) < 1: - raise ValueError(f"Custom mesh faces must have shape (M, 3) with M >= 1, got {faces.shape}.") - if np.any(faces < 0) or np.any(faces >= len(vertices)): - raise ValueError(f"Custom mesh face indices must be in [0, {len(vertices) - 1}].") - - mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False) - stage = get_current_stage() - _spawn_mesh_geom_from_mesh(prim_path, cfg, mesh, translation, orientation, stage=stage) - return stage.GetPrimAtPath(prim_path) - - @clone def spawn_mesh_sphere( prim_path: str, @@ -447,7 +396,7 @@ def _spawn_mesh_geom_from_mesh( "points": mesh.vertices, "faceVertexIndices": mesh.faces.flatten(), "faceVertexCounts": np.asarray([3] * len(mesh.faces)), - "subdivisionScheme": getattr(cfg, "subdivision_scheme", "bilinear"), + "subdivisionScheme": "bilinear", }, stage=stage, ) @@ -479,16 +428,13 @@ def _spawn_mesh_geom_from_mesh( ) elif cfg.collision_props is not None: # decide on type of collision approximation based on the mesh - if isinstance(cfg, MeshCustomCfg): - collision_approximation = "none" - elif cfg.__class__.__name__ == "MeshSphereCfg": + if cfg.__class__.__name__ == "MeshSphereCfg": collision_approximation = "boundingSphere" elif cfg.__class__.__name__ == "MeshCuboidCfg": collision_approximation = "boundingCube" else: # for: MeshCylinderCfg, MeshCapsuleCfg, MeshConeCfg collision_approximation = "convexHull" - # apply collision approximation to mesh # note: for primitives, we use the convex hull approximation -- this should be sufficient for most cases. UsdPhysics.MeshCollisionAPI.Apply(mesh_prim).GetApproximationAttr().Set(collision_approximation) # collision properties anchor at the geometry prim diff --git a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py index 6145dec84174..42d71cb4d40b 100644 --- a/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py +++ b/source/isaaclab/isaaclab/sim/spawners/meshes/meshes_cfg.py @@ -90,26 +90,6 @@ class MeshCfg(RigidObjectSpawnerCfg, DeformableObjectSpawnerCfg): """ -@configclass -class MeshCustomCfg(MeshCfg): - """Configuration parameters for a mesh authored from vertices and triangular faces. - - Collision defaults to exact triangles. Use inherited ``mesh_collision_props`` - to choose another collision approximation. See :meth:`spawn_mesh_custom` for more information. - """ - - func: Callable | str = "{DIR}.meshes:spawn_mesh_custom" - - vertices: tuple[tuple[float, float, float], ...] = MISSING - """Vertex positions [m].""" - - faces: tuple[tuple[int, int, int], ...] = MISSING - """Triangle vertex indices with counter-clockwise front-face winding.""" - - subdivision_scheme: Literal["none", "catmullClark", "loop", "bilinear"] = "none" - """USD subdivision scheme. Defaults to ``"none"`` to preserve the authored surface.""" - - @configclass class MeshSphereCfg(MeshCfg): """Configuration parameters for a sphere mesh prim with deformable properties. diff --git a/source/isaaclab/test/sim/test_spawn_meshes.py b/source/isaaclab/test/sim/test_spawn_meshes.py index dbe5c785c73c..abd0e18197b7 100644 --- a/source/isaaclab/test/sim/test_spawn_meshes.py +++ b/source/isaaclab/test/sim/test_spawn_meshes.py @@ -160,37 +160,6 @@ def test_invalid_edge_refinement(sim): cfg.func("/World/Invalid", cfg) -def test_spawn_custom_mesh(sim): - """Test spawning an exact collision mesh from authored triangle data.""" - cfg = sim_utils.MeshCustomCfg( - vertices=((-0.5, -0.5, 0.0), (0.5, -0.5, 0.0), (0.5, 0.5, 0.0), (-0.5, 0.5, 0.0)), - faces=((0, 1, 2), (0, 2, 3)), - collision_props=sim_utils.CollisionBaseCfg(), - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.2, 0.4, 0.8)), - ) - prim = cfg.func("/World/CustomMesh", cfg) - - assert prim.IsValid() - mesh_prim = sim.stage.GetPrimAtPath("/World/CustomMesh/geometry/mesh") - assert mesh_prim.GetPrimTypeInfo().GetTypeName() == "Mesh" - assert mesh_prim.GetAttribute("faceVertexIndices").Get() == [0, 1, 2, 0, 2, 3] - assert mesh_prim.GetAttribute("subdivisionScheme").Get() == "none" - assert mesh_prim.GetAttribute("physics:approximation").Get() == "none" - assert not mesh_prim.GetAttribute("primvars:displayColor").HasAuthoredValue() - - -def test_spawn_custom_mesh_rejects_malformed_data(sim): - """Reject lossy face indices and non-finite USD points before authoring any prim.""" - cfg = sim_utils.MeshCustomCfg(vertices=((0, 0, 0), (1, 0, 0), (0, 1, 0)), faces=((0, 1, 2),)) - for invalid, message in ( - (cfg.replace(faces=((0, 1.9, 2),)), "integer"), - (cfg.replace(vertices=((float("nan"), 0, 0), (1, 0, 0), (0, 1, 0))), "finite"), - ): - with pytest.raises(ValueError, match=message): - invalid.func("/World/InvalidCustomMesh", invalid) - assert not sim.stage.GetPrimAtPath("/World/InvalidCustomMesh").IsValid() - - """ Physics properties. """ From b6d91df332d473ef74cc567df03749ab6fe5296e Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:36:36 +0100 Subject: [PATCH 37/45] Align warehouse presentation with the policy workspace --- .../conveyor_franka_asset_env_cfg.py | 109 ++++++------------ .../conveyor_franka_asset_terrain.py | 30 ----- .../conveyor_warehouse_geometry.py | 8 +- .../contrib/test_conveyor_franka_asset_cfg.py | 16 ++- 4 files changed, 48 insertions(+), 115 deletions(-) delete mode 100644 source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_terrain.py diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py index 136f14bb2c55..4cf89d08cded 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py @@ -14,7 +14,6 @@ import isaaclab.sim as sim_utils from isaaclab.assets import AssetBaseCfg -from isaaclab.terrains import TerrainImporterCfg from isaaclab.utils.assets import ISAAC_NUCLEUS_DIR, retrieve_file_path from isaaclab.utils.configclass import configclass @@ -23,14 +22,11 @@ if TYPE_CHECKING: from pxr import Sdf, Usd - from isaaclab.terrains import TerrainImporter - from . import mdp from .conveyor_franka_env_cfg import ( ConveyorFrankaEnvCfg, ConveyorFrankaSceneCfg, _conveyor_asset, - _spawn_shape_with_display_color, ) from .conveyor_geometry import ( BELT_CENTER_X, @@ -45,13 +41,13 @@ # The A12 endpoints are 2.9922 m apart, its belt crown is 1.78053 m above the # asset origin. USD point overrides extend the legs to the floor while keeping -# the upper frames unchanged; the policy workspace follows the deck elevation. +# the upper frames unchanged; presentation roots align the deck with the policy workspace. _A12_ENDPOINT_SEPARATION = 2.9922 _ASSET_BELT_TOP_Z = 1.78053 _ASSET_XY_SCALE = 2.0 * BELT_TURN_RADIUS / _A12_ENDPOINT_SEPARATION _CONVEYOR_SUPPORT_Z = 0.55 # Preserve the upper frames' proportions. The scaled belt crown determines -# how far to elevate the policy workspace. +# the presentation translation into the policy workspace. _ASSET_Z_SCALE = _ASSET_XY_SCALE _ASSET_ROOT_Z = _CONVEYOR_SUPPORT_Z _ASSET_BELT_WORLD_Z = _ASSET_ROOT_Z + _ASSET_BELT_TOP_Z * _ASSET_Z_SCALE @@ -63,8 +59,8 @@ _A09_X_SCALE = 2.0 * BELT_HALF_STRAIGHT / _A09_LENGTH # The Thor table authors its mounting surface at local z=0 and its lowest foot -# at z=-0.795 m. Uniformly scaling that distance to the elevated robot base -# puts every foot on the global ground without distorting the table. +# at z=-0.795 m. Uniformly scaling that distance to the robot's height above +# the warehouse floor puts every foot on the ground without distorting the table. _THOR_TABLE_LOWEST_Z = -0.795 _THOR_TABLE_SCALE = _WORKSPACE_ELEVATION / -_THOR_TABLE_LOWEST_Z @@ -118,7 +114,7 @@ def _spawn_authored_visual( return prim -def _extend_conveyor_supports(root_prim) -> None: +def _extend_conveyor_supports(root_prim: Usd.Prim) -> None: """Apply authored support parameters to referenced meshes without bundling geometry.""" import numpy as np @@ -160,7 +156,7 @@ def _spawn_carton_cube( """Dress the original 40 mm collider with a centered, equally sized SimReady carton.""" from pxr import UsdGeom - prim = _spawn_shape_with_display_color(prim_path, cfg, translation, orientation, **kwargs) + prim = sim_utils.spawn_cuboid(prim_path, cfg, translation, orientation, **kwargs) UsdGeom.Imageable(prim.GetStage().GetPrimAtPath(f"{prim_path}/geometry/mesh")).MakeInvisible() visual_cfg = sim_utils.UsdFileCfg(usd_path=cfg.parcel_usd_path) _spawn_authored_visual(f"{prim_path}/CartonVisual", visual_cfg) @@ -175,23 +171,11 @@ class _ParcelCuboidCfg(sim_utils.CuboidCfg): """USD visual, normalized to the task's 40 mm cube.""" -def _is_physics_schema(schema_name: str) -> bool: - """Return whether an applied schema can add physics ownership to a visual asset.""" - return schema_name == "IsaacConveyorAPI" or schema_name.startswith(_PHYSICS_SCHEMA_PREFIXES) - - -def _make_usd_subtree_visual_only(root_prim) -> None: +def _make_usd_subtree_visual_only(root_prim: Usd.Prim) -> None: """Remove physics ownership with stage overrides, preserving the source asset.""" from pxr import Sdf, Usd, UsdPhysics - children = tuple(Usd.PrimRange(root_prim, Usd.TraverseInstanceProxies())) - instance_proxies = tuple(str(child.GetPath()) for child in children if child.IsInstanceProxy()) - if instance_proxies: - raise RuntimeError( - "Visual-only USD overrides require editable descendants; set make_uninstanceable=True. " - f"Found instance proxies below {root_prim.GetPath()}: {instance_proxies[:3]}" - ) - + children = tuple(Usd.PrimRange(root_prim)) with Sdf.ChangeBlock(): for child in children: if not child.IsValid() or not child.IsActive(): @@ -204,18 +188,10 @@ def _make_usd_subtree_visual_only(root_prim) -> None: child.SetActive(False) continue for schema_name in tuple(child.GetAppliedSchemas()): - if _is_physics_schema(schema_name) and not child.RemoveAppliedSchema(schema_name): + is_physics = schema_name == "IsaacConveyorAPI" or schema_name.startswith(_PHYSICS_SCHEMA_PREFIXES) + if is_physics and not child.RemoveAppliedSchema(schema_name): raise RuntimeError(f"Failed to remove physics schema {schema_name!r} from {child.GetPath()}.") - remaining = { - str(child.GetPath()): tuple(schema for schema in child.GetAppliedSchemas() if _is_physics_schema(schema)) - for child in children - if child.IsValid() and child.IsActive() - } - remaining = {path: schemas for path, schemas in remaining.items() if schemas} - if remaining: - raise RuntimeError(f"Visual-only USD subtree still contains physics schemas: {remaining}") - @sim_utils.clone def _spawn_visual_only_usd( @@ -224,7 +200,7 @@ def _spawn_visual_only_usd( translation: tuple[float, float, float] | None = None, orientation: tuple[float, float, float, float] | None = None, **kwargs, -): +) -> Usd.Prim: """Spawn a USD asset and strip its authored physics metadata.""" prim = sim_utils.spawn_from_usd(prim_path, cfg, translation, orientation, **kwargs) @@ -233,29 +209,17 @@ def _spawn_visual_only_usd( return prim -@configclass -class _ElevatedGroundPlaneCfg(TerrainImporterCfg): - """Ground plane that reports the elevated workspace as its environment origin.""" - - class_type: type[TerrainImporter] | str = ( - "{DIR}.conveyor_franka_asset_terrain:ConveyorFrankaGroundPlaneTerrainImporter" - ) - workspace_origin_offset: tuple[float, float, float] = (0.0, 0.0, _WORKSPACE_ELEVATION) - """Translation from clone-grid origins to policy workspaces [m].""" - - def _visual_usd_asset( prim_path: str, usd_path: str, position: tuple[float, float, float], scale: tuple[float, float, float], - rotation: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0), spawn_func: Callable = _spawn_authored_visual, ) -> AssetBaseCfg: """Build one visual asset whose authored colliders are explicitly disabled.""" return AssetBaseCfg( prim_path=prim_path, - init_state=AssetBaseCfg.InitialStateCfg(pos=position, rot=rotation), + init_state=AssetBaseCfg.InitialStateCfg(pos=position), spawn=sim_utils.UsdFileCfg(func=spawn_func, usd_path=usd_path, scale=scale, make_uninstanceable=True), ) @@ -268,16 +232,12 @@ def __post_init__(self) -> None: """Dress the workcell and extend its returns around the fixed manipulation sections.""" super().__post_init__() - # Matching asset and environment-origin offsets preserve the policy's local coordinates. - for asset in vars(self).values(): - if isinstance(asset, AssetBaseCfg) and asset.prim_path.startswith("{ENV_REGEX_NS}/"): - x, y, z = asset.init_state.pos - asset.init_state.pos = (x, y, z + _WORKSPACE_ELEVATION) - - self.ground = _ElevatedGroundPlaneCfg( + # Translate the warehouse dressing around the unchanged policy workspace. + self.ground = AssetBaseCfg( prim_path="/World/GroundPlane", - terrain_type="plane", - visual_material=sim_utils.PreviewSurfaceCfg(diffuse_color=(0.055, 0.065, 0.082), roughness=0.82), + collision_group=-1, + init_state=AssetBaseCfg.InitialStateCfg(pos=(0.0, 0.0, -_WORKSPACE_ELEVATION)), + spawn=sim_utils.GroundPlaneCfg(visible=False, color=(0.055, 0.065, 0.082)), ) self.dome_light.spawn.color = (0.70, 0.78, 0.92) self.dome_light.spawn.intensity = 700.0 @@ -289,7 +249,7 @@ def __post_init__(self) -> None: self.tabletop = _visual_usd_asset( prim_path="{ENV_REGEX_NS}/RobotThorTableVisual", usd_path=_THOR_TABLE_ASSET_PATH, - position=(0.0, 0.0, _WORKSPACE_ELEVATION), + position=(0.0, 0.0, 0.0), scale=(_THOR_TABLE_SCALE,) * 3, spawn_func=_spawn_visual_only_usd, ) @@ -312,7 +272,7 @@ def __post_init__(self) -> None: _visual_usd_asset( prim_path=f"{{ENV_REGEX_NS}}/Conveyor{side}{run.title()}A09Visual", usd_path=str(_PRESENTATION_ASSETS / "conveyor_straight_supported.usda"), - position=(right_x, y_position, _ASSET_ROOT_Z), + position=(right_x, y_position, _ASSET_ROOT_Z - _WORKSPACE_ELEVATION), scale=straight_scale, ), ) @@ -320,7 +280,7 @@ def __post_init__(self) -> None: self.warehouse_visual = _visual_usd_asset( prim_path="{ENV_REGEX_NS}/WarehouseVisual", usd_path=str(_PRESENTATION_ASSETS / "warehouse.usda"), - position=(0.0, 0.0, 0.0), + position=(0.0, 0.0, -_WORKSPACE_ELEVATION), scale=(1.0, 1.0, 1.0), ) for cube_id in range(4): @@ -328,9 +288,7 @@ def __post_init__(self) -> None: cube.spawn = _ParcelCuboidCfg(**vars(cube.spawn)) cube.spawn.func = _spawn_carton_cube - def _configure_route_assets( - self, parcel_colors: tuple[str, ...] = ("blue", "orange", "green", "purple") * 6 - ) -> None: + def _configure_route_assets(self, parcel_colors: tuple[str, ...]) -> None: """Read USD route geometry after the application selects its USD runtime.""" from .conveyor_warehouse_geometry import warehouse_parcel_positions @@ -341,22 +299,25 @@ def _configure_route_assets( cube = self.cube_0.copy() cube.spawn.parcel_usd_path = str(_PRESENTATION_ASSETS / f"parcel_{color}.usda") cube.prim_path = f"{{ENV_REGEX_NS}}/Cube{cube_id}" - cube.init_state.pos = (position[0], position[1], position[2] + _WORKSPACE_ELEVATION) + cube.init_state.pos = position setattr(self, f"cube_{cube_id}", cube) for side in ("Left", "Right"): for key in ("top_straight", "bottom_straight", "right_turn", "left_turn"): setattr(self, f"conveyor_{side.lower()}_{key}_collision", None) for index, section in enumerate(warehouse_belt_sections(side)): - asset = _conveyor_asset(section.belt.prim_path, section.geometry) - x, y, z = asset.init_state.pos - asset.init_state.pos = (x, y, z + _WORKSPACE_ELEVATION) - setattr(self, f"warehouse_{side.lower()}_section_{index}", asset) + setattr( + self, + f"warehouse_{side.lower()}_section_{index}", + _conveyor_asset(section.belt.prim_path, section.geometry), + ) for guard in warehouse_guard_meshes(side): - asset = _conveyor_asset(f"{{ENV_REGEX_NS}}/{guard.name}Collision", guard) - asset.init_state.pos = (0.0, 0.0, _WORKSPACE_ELEVATION) - setattr(self, f"warehouse_{guard.name}_collision", asset) + setattr( + self, + f"warehouse_{guard.name}_collision", + _conveyor_asset(f"{{ENV_REGEX_NS}}/{guard.name}Collision", guard), + ) - def build_conveyor_belt_specs(self, **kwargs: float | bool) -> tuple[SurfaceVelocitySpec, ...]: + def build_conveyor_belt_specs(self, **kwargs: float) -> tuple[SurfaceVelocitySpec, ...]: """Describe the linked warehouse surfaces to the existing Newton conveyor driver.""" return tuple(section.belt for side in ("Left", "Right") for section in warehouse_belt_sections(side, **kwargs)) @@ -397,8 +358,8 @@ def __post_init__(self) -> None: "minimum": (-0.4, -1.2, -0.05), "maximum": (2.85, 1.2, 0.8), } - self.sim.visualizer_cfgs.eye = (4.8, -5.2, 3.0) - self.sim.visualizer_cfgs.lookat = (0.9, 0.6, 0.95) + self.sim.visualizer_cfgs.eye = (4.8, -5.2, 3.0 - _WORKSPACE_ELEVATION) + self.sim.visualizer_cfgs.lookat = (0.9, 0.6, 0.95 - _WORKSPACE_ELEVATION) self.sim.visualizer_cfgs.focal_length = 24.0 def play_mode(self) -> None: diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_terrain.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_terrain.py deleted file mode 100644 index 645b58511d27..000000000000 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_terrain.py +++ /dev/null @@ -1,30 +0,0 @@ -# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause - -"""Terrain-origin support for the asset-rich conveyor playback variant.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from isaaclab.terrains import TerrainImporter - -if TYPE_CHECKING: - from .conveyor_franka_asset_env_cfg import _ElevatedGroundPlaneCfg - - -class ConveyorFrankaGroundPlaneTerrainImporter(TerrainImporter): - """Plane terrain whose environment origins follow an elevated workspace.""" - - def __init__(self, cfg: _ElevatedGroundPlaneCfg): - """Create the ground plane and translate its policy-facing origins.""" - super().__init__(cfg) - self.env_origins.add_(self.env_origins.new_tensor(cfg.workspace_origin_offset)) - # The authored warehouse floor replaces the default calibration-grid visual. - from pxr import UsdGeom - - from isaaclab.sim.utils.stage import get_current_stage - - UsdGeom.Imageable(get_current_stage().GetPrimAtPath(cfg.prim_path)).MakeInvisible() diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_warehouse_geometry.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_warehouse_geometry.py index 607f055a2795..85e6b6d8d401 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_warehouse_geometry.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_warehouse_geometry.py @@ -11,6 +11,7 @@ from dataclasses import dataclass, replace from functools import lru_cache from pathlib import Path +from typing import TYPE_CHECKING import numpy as np @@ -33,11 +34,14 @@ belt_direction, ) +if TYPE_CHECKING: + from pxr import Sdf + _ROUTE_ASSET = Path(__file__).parent / "assets" / "conveyor_routes.usda" @lru_cache(maxsize=1) -def _route_layer(): +def _route_layer() -> Sdf.Layer: # Import Usd to register file formats in kitless mode, without composing asset dependencies. from pxr import Sdf, Usd # noqa: F401 @@ -140,7 +144,7 @@ def _prism( return MeshSpec(name, tuple(tuple(vertex) for vertex in vertices), faces) -def warehouse_belt_sections(side: str, **kwargs: float | bool) -> tuple[ConveyorSectionSpec, ...]: +def warehouse_belt_sections(side: str, **kwargs: float) -> tuple[ConveyorSectionSpec, ...]: """Build linked belt surfaces from USD while preserving the original manipulation geometry.""" sign = belt_direction(side) sections = [] diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py index 1d48e054bef4..bf99c17ca99d 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_asset_cfg.py @@ -124,14 +124,14 @@ def test_asset_transforms_preserve_the_original_workcell() -> None: warehouse_belt_sections, ) - scene = ConveyorFrankaA09A12EnvCfg().scene + cfg = ConveyorFrankaA09A12EnvCfg() + scene = cfg.scene layer = Sdf.Layer.FindOrOpen(scene.warehouse_visual.spawn.usd_path) - scene._configure_route_assets() - workspace_z = scene.ground.workspace_origin_offset[2] - assert math.isclose(scene.robot.init_state.pos[2], workspace_z) - assert math.isclose(scene.cube_0.init_state.pos[2], 0.28 + workspace_z) + scene._configure_route_assets(cfg.commands.transfer.parcel_colors) + assert scene.robot.init_state.pos == ConveyorFrankaEnvCfg().scene.robot.init_state.pos + assert math.isclose(scene.cube_0.init_state.pos[2], 0.28) base_z = ConveyorFrankaEnvCfg().scene.conveyor_left_top_straight_collision.init_state.pos[2] - assert math.isclose(scene.warehouse_left_section_0.init_state.pos[2], base_z + workspace_z) + assert math.isclose(scene.warehouse_left_section_0.init_state.pos[2], base_z) for side, sign, original_index in (("Left", 1, 1), ("Right", -1, 0)): sections = warehouse_belt_sections(side, velocity=0.35) original = belt_collision_section_specs(side)[original_index].geometry @@ -144,9 +144,7 @@ def test_asset_transforms_preserve_the_original_workcell() -> None: sign * (BELT_CENTER_Y - BELT_TURN_RADIUS), ) assert 4 * visual.spawn.scale[0] == pytest.approx(2 * BELT_HALF_STRAIGHT) - assert visual.init_state.pos[2] + 1.78053 * visual.spawn.scale[2] == pytest.approx( - BELT_TOP_Z + scene.ground.workspace_origin_offset[2] - ) + assert visual.init_state.pos[2] + 1.78053 * visual.spawn.scale[2] == pytest.approx(BELT_TOP_Z) for name, x in ( ("RightInner", BELT_CENTER_X + BELT_HALF_STRAIGHT), ("LeftInner", BELT_CENTER_X - BELT_HALF_STRAIGHT), From 5c865d75beea33a0f4f53fc70f6b8811c01ebf09 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:35:00 +0100 Subject: [PATCH 38/45] Batch warehouse arrival dispatch and resolve parking joints once --- .../conveyor_franka_warehouse_env.py | 19 +++++++++++-------- .../contrib/conveyor_franka/mdp/sorting.py | 14 +++++++------- .../test/contrib/test_conveyor_franka_mdp.py | 12 ++++++++++++ 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py index 59cf0ea31a20..cbf0ae198da1 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py @@ -7,6 +7,7 @@ from __future__ import annotations +from collections.abc import Sequence from typing import TYPE_CHECKING import torch @@ -30,9 +31,10 @@ class ConveyorFrankaWarehouseEnv(ConveyorFrankaEnv): Newton viewers show a static approximation of the warehouse dressing. """ - def __init__(self, cfg: ConveyorFrankaA09A12EnvCfg, render_mode: str | None = None, **kwargs): + cfg: ConveyorFrankaA09A12EnvCfg + + def __init__(self, cfg: ConveyorFrankaA09A12EnvCfg, render_mode: str | None = None, **kwargs) -> None: self._warehouse_animation: list[tuple[Usd.Attribute, Usd.Attribute]] = [] - self._warehouse_arm_joint_ids: list[int] | None = None cfg.scene._configure_route_assets(cfg.commands.transfer.parcel_colors) super().__init__(cfg, render_mode=render_mode, **kwargs) viewer = next((viz.cfg for viz in self.sim.visualizers if viz.cfg.backend.visualizer_type == "kit"), None) @@ -65,6 +67,9 @@ def load_managers(self) -> None: assets = tuple(self.scene[f"cube_{i}"] for i in range(self.cfg.conveyor_force.transported_body_count_per_env)) self.conveyor_cube_pool = ConveyorCubePool(assets, self.num_envs, self.device) self._parcel_start_positions = torch.tensor(warehouse_parcel_positions(), device=self.device) + self._warehouse_arm_joint_ids = self.scene["robot"].find_joints( + self.cfg.actions.arm_action.joint_names, preserve_order=True + )[0] super().load_managers() @staticmethod @@ -79,7 +84,9 @@ def _in_workcell(positions: torch.Tensor) -> torch.Tensor: & ((positions[..., 2] < 0.12) | (positions[..., 0] < 1.05)) ) - def _adapt_policy_cube_state(self, positions, quaternions, velocities): + def _adapt_policy_cube_state( + self, positions: torch.Tensor, quaternions: torch.Tensor, velocities: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: """Represent remote inventory as waiting slots while keeping local manipulation states exact.""" origins = self.scene.env_origins[:, None, :] local = positions - origins @@ -106,10 +113,6 @@ def step(self, action: torch.Tensor) -> VecEnvStepReturn: return super().step(action) command = self.command_manager.get_term("transfer") robot = self.scene["robot"] - if self._warehouse_arm_joint_ids is None: - self._warehouse_arm_joint_ids = robot.find_joints( - self.cfg.actions.arm_action.joint_names, preserve_order=True - )[0] joint_ids = self._warehouse_arm_joint_ids parked = torch.zeros_like(action) parked[:, :7] = ( @@ -129,7 +132,7 @@ def _animate_warehouse(self) -> None: for source, target in self._warehouse_animation: target.Set(source.Get(time_code)) - def _reset_idx(self, env_ids) -> None: + def _reset_idx(self, env_ids: Sequence[int] | torch.Tensor | slice) -> None: """Shuffle a mixed batch across the authored feeds in selected environments.""" if isinstance(env_ids, slice): env_ids = self.scene._ALL_INDICES[env_ids] diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py index 1383611224fb..597ec6445034 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/sorting.py @@ -85,7 +85,7 @@ def _update_command(self) -> None: positions_w = cube_values(env, "root_pos_w", all_cubes=True) positions = positions_w - env.scene.env_origins[:, None] local = env._in_workcell(positions) - rows = torch.arange(self.num_envs, device=self.device) + rows = env.scene._ALL_INDICES physical_target = pool.slot_ids[rows, self.target_cube_ids] held = self._physical_cube_acquisition_mask(positions_w[rows, physical_target]) & self.has_target self.has_target &= ~self.pending_success & (local[rows, physical_target] | held) @@ -104,12 +104,12 @@ def _update_command(self) -> None: available = candidates.gather(1, pool.slot_ids) eligible = available.any(dim=1) & ~self.has_target choices = torch.where(available, positions[..., 0].gather(1, pool.slot_ids), -torch.inf).argmax(dim=1) - for slot in range(4): - env_ids = torch.where(eligible & (choices == slot))[0] - if env_ids.numel(): - self.set_goal(slot, env_ids) - self.has_target[env_ids] = True - self.command_counter[env_ids] += 1 + env_ids = eligible.nonzero(as_tuple=False).flatten() + slots = choices[env_ids] + physical_ids = pool.slot_ids[env_ids, slots] + self._assign_goal(env_ids, slots, (positions[env_ids, physical_ids, 1] < 0.0).long()) + self.has_target[env_ids] = True + self.command_counter[env_ids] += 1 # A class is counted only after leaving the elevated supply and landing on a loop. velocities = cube_values(env, "root_lin_vel_w", all_cubes=True) on_loop = (positions[..., 2] > 0.04) & (positions[..., 2] < 0.20) & (velocities[..., 2].abs() < 0.15) diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py index c51803a92569..8aa078c07b82 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py @@ -276,6 +276,17 @@ def test_sorter_dispatch_inventory_and_selected_resets(): assert pool.slot_ids[0, 0] == 0 assert command.metrics["sorted_parcels"].tolist() == [3, 1] torch.testing.assert_close(pool.assets[5].data.root_pos_w.torch[0] - env.scene.env_origins[0], positions[0, 5]) + # Simultaneous arrivals can select different slots and opposite transfer directions. + waiting = positions[1, 2].clone() + positions[1, 2] = torch.tensor([0.75, -0.27, 0.06]) + _place(env, positions) + command.has_target.zero_() + command._update_command() + assert command.target_cube_ids.tolist() == [slot, 2] + assert command.source_side_ids.tolist() == [0, 1] + positions[1, 2] = waiting + _place(env, positions) + command._update_command() # Carry the assigned parcel above the workcell; a remote held target must retain its slot. robot = env.scene["robot"] fingers, _ = robot.find_joints("panda_finger_joint[1-2]", preserve_order=True) @@ -284,6 +295,7 @@ def test_sorter_dispatch_inventory_and_selected_resets(): robot.write_joint_position_to_sim_index(position=joints) arms, _ = robot.find_joints("panda_joint[1-7]", preserve_order=True) joints[:, arms] = robot.data.default_joint_pos.torch[:, arms] + joints[:, arms[1]] = -0.8 robot.write_joint_position_to_sim_index(position=joints) env.sim.forward() env.scene.update(env.step_dt) From 9a619ac23848ec82d40030cd5d6af8807dee67bb Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:36:15 +0100 Subject: [PATCH 39/45] Protect the robot mounting frame during conveyor resets --- source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py index 8aa078c07b82..e9f3702a5f85 100644 --- a/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py +++ b/source/isaaclab_tasks/test/contrib/test_conveyor_franka_mdp.py @@ -72,6 +72,7 @@ def test_checkpoint_interface_and_invalid_action_recovery(task): """Both real tasks preserve learned feature ordering, finite rewards and independent reset flags.""" with closing(_environment(task)) as env: obs, _ = env.reset() + torch.testing.assert_close(env.scene["robot"].data.root_pos_w.torch[:, 2], env.scene.env_origins[:, 2]) assert obs["policy"].shape == (2, 123) assert env.action_manager.total_action_dim == 8 assert env.physics_dt == 1 / 120 and env.step_dt == 1 / 60 From 16fd64057c8dfbb95f245a5af7eeb2fd47c7e142 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:42:05 +0100 Subject: [PATCH 40/45] Order conveyor lifecycle methods according to Lab style --- .../conveyor_franka/conveyor_franka_env.py | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py index 5099c1fbc55f..680c77692b42 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env.py @@ -29,6 +29,25 @@ def __init__(self, cfg: ConveyorFrankaEnvCfg, render_mode: str | None = None, ** super().__init__(cfg, render_mode=render_mode, **kwargs) self._setup_goal_selector() + def load_managers(self) -> None: + """Bind the fixed Panda tool frame before manager terms inspect it.""" + hand_ids, _ = self.scene["robot"].find_bodies("panda_hand") + if len(hand_ids) != 1: + raise ValueError(f"Expected one Panda hand body, found {len(hand_ids)}.") + self._conveyor_hand_body_id = hand_ids[0] + self._conveyor_tool_offset = torch.tensor((0.0, 0.0, 0.1034), dtype=torch.float32, device=self.device).expand( + self.num_envs, -1 + ) + super().load_managers() + + def close(self) -> None: + """Release conveyor callbacks before the physics scene is destroyed.""" + conveyor_driver = self._conveyor_driver + if conveyor_driver is not None: + conveyor_driver.close() + self._conveyor_driver = None + super().close() + def _init_sim(self) -> None: """Register solved-contact hooks before the first reset captures the solver.""" driver = SurfaceVelocity( @@ -52,17 +71,6 @@ def _init_sim(self) -> None: self._conveyor_driver = None raise - def load_managers(self) -> None: - """Bind the fixed Panda tool frame before manager terms inspect it.""" - hand_ids, _ = self.scene["robot"].find_bodies("panda_hand") - if len(hand_ids) != 1: - raise ValueError(f"Expected one Panda hand body, found {len(hand_ids)}.") - self._conveyor_hand_body_id = hand_ids[0] - self._conveyor_tool_offset = torch.tensor((0.0, 0.0, 0.1034), dtype=torch.float32, device=self.device).expand( - self.num_envs, -1 - ) - super().load_managers() - def _setup_goal_selector(self) -> None: """Attach one task panel to the first GL visualizer.""" for visualizer in self.sim.visualizers: @@ -83,11 +91,3 @@ def _reset_idx(self, env_ids: Sequence[int] | torch.Tensor | slice) -> None: conveyor_driver = self._conveyor_driver if conveyor_driver is not None: conveyor_driver.reset(env_ids) - - def close(self) -> None: - """Release conveyor callbacks before the physics scene is destroyed.""" - conveyor_driver = self._conveyor_driver - if conveyor_driver is not None: - conveyor_driver.close() - self._conveyor_driver = None - super().close() From 7ef95f39ab1369d74f88fb5b623f1ba220fbab6c Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:42:32 +0100 Subject: [PATCH 41/45] Keep presentation offsets separate from policy coordinates --- .../conveyor-warehouse-reset-frame.fixed.rst | 2 + .../conveyor_franka_asset_env_cfg.py | 18 +++-- .../conveyor_franka_warehouse_env.py | 66 +++++++++---------- 3 files changed, 43 insertions(+), 43 deletions(-) create mode 100644 source/isaaclab_tasks/changelog.d/conveyor-warehouse-reset-frame.fixed.rst diff --git a/source/isaaclab_tasks/changelog.d/conveyor-warehouse-reset-frame.fixed.rst b/source/isaaclab_tasks/changelog.d/conveyor-warehouse-reset-frame.fixed.rst new file mode 100644 index 000000000000..f810496457cd --- /dev/null +++ b/source/isaaclab_tasks/changelog.d/conveyor-warehouse-reset-frame.fixed.rst @@ -0,0 +1,2 @@ +* Fixed warehouse robot resets by keeping physics assets at their clone origins and + translating the presentation instead of applying the workspace height twice. diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py index 4cf89d08cded..22b4ef771ab2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_asset_env_cfg.py @@ -45,13 +45,12 @@ _A12_ENDPOINT_SEPARATION = 2.9922 _ASSET_BELT_TOP_Z = 1.78053 _ASSET_XY_SCALE = 2.0 * BELT_TURN_RADIUS / _A12_ENDPOINT_SEPARATION -_CONVEYOR_SUPPORT_Z = 0.55 # Preserve the upper frames' proportions. The scaled belt crown determines # the presentation translation into the policy workspace. _ASSET_Z_SCALE = _ASSET_XY_SCALE -_ASSET_ROOT_Z = _CONVEYOR_SUPPORT_Z +_ASSET_ROOT_Z = 0.55 _ASSET_BELT_WORLD_Z = _ASSET_ROOT_Z + _ASSET_BELT_TOP_Z * _ASSET_Z_SCALE -_WORKSPACE_ELEVATION = _ASSET_BELT_WORLD_Z - BELT_TOP_Z +_PRESENTATION_OFFSET_Z = BELT_TOP_Z - _ASSET_BELT_WORLD_Z # A09 is a 4 m straight. Its travel-axis scale is independent of the common # lateral scale so one asset spans the task's complete 0.88 m straight run. @@ -62,7 +61,7 @@ # at z=-0.795 m. Uniformly scaling that distance to the robot's height above # the warehouse floor puts every foot on the ground without distorting the table. _THOR_TABLE_LOWEST_Z = -0.795 -_THOR_TABLE_SCALE = _WORKSPACE_ELEVATION / -_THOR_TABLE_LOWEST_Z +_THOR_TABLE_SCALE = _PRESENTATION_OFFSET_Z / _THOR_TABLE_LOWEST_Z _PHYSICS_SCHEMA_PREFIXES = ("Physics", "Physx", "Newton", "Mujoco") @@ -236,7 +235,7 @@ def __post_init__(self) -> None: self.ground = AssetBaseCfg( prim_path="/World/GroundPlane", collision_group=-1, - init_state=AssetBaseCfg.InitialStateCfg(pos=(0.0, 0.0, -_WORKSPACE_ELEVATION)), + init_state=AssetBaseCfg.InitialStateCfg(pos=(0.0, 0.0, _PRESENTATION_OFFSET_Z)), spawn=sim_utils.GroundPlaneCfg(visible=False, color=(0.055, 0.065, 0.082)), ) self.dome_light.spawn.color = (0.70, 0.78, 0.92) @@ -272,7 +271,7 @@ def __post_init__(self) -> None: _visual_usd_asset( prim_path=f"{{ENV_REGEX_NS}}/Conveyor{side}{run.title()}A09Visual", usd_path=str(_PRESENTATION_ASSETS / "conveyor_straight_supported.usda"), - position=(right_x, y_position, _ASSET_ROOT_Z - _WORKSPACE_ELEVATION), + position=(right_x, y_position, _ASSET_ROOT_Z + _PRESENTATION_OFFSET_Z), scale=straight_scale, ), ) @@ -280,7 +279,7 @@ def __post_init__(self) -> None: self.warehouse_visual = _visual_usd_asset( prim_path="{ENV_REGEX_NS}/WarehouseVisual", usd_path=str(_PRESENTATION_ASSETS / "warehouse.usda"), - position=(0.0, 0.0, -_WORKSPACE_ELEVATION), + position=(0.0, 0.0, _PRESENTATION_OFFSET_Z), scale=(1.0, 1.0, 1.0), ) for cube_id in range(4): @@ -332,7 +331,6 @@ class ConveyorFrankaA09A12EnvCfg(ConveyorFrankaEnvCfg): scene: ConveyorFrankaA09A12SceneCfg = ConveyorFrankaA09A12SceneCfg( num_envs=1, env_spacing=24.0, - replicate_physics=True, ) def __post_init__(self) -> None: @@ -358,8 +356,8 @@ def __post_init__(self) -> None: "minimum": (-0.4, -1.2, -0.05), "maximum": (2.85, 1.2, 0.8), } - self.sim.visualizer_cfgs.eye = (4.8, -5.2, 3.0 - _WORKSPACE_ELEVATION) - self.sim.visualizer_cfgs.lookat = (0.9, 0.6, 0.95 - _WORKSPACE_ELEVATION) + self.sim.visualizer_cfgs.eye = (4.8, -5.2, 3.0 + _PRESENTATION_OFFSET_Z) + self.sim.visualizer_cfgs.lookat = (0.9, 0.6, 0.95 + _PRESENTATION_OFFSET_Z) self.sim.visualizer_cfgs.focal_length = 24.0 def play_mode(self) -> None: diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py index cbf0ae198da1..ee115d7fe312 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_warehouse_env.py @@ -72,17 +72,28 @@ def load_managers(self) -> None: )[0] super().load_managers() - @staticmethod - def _in_workcell(positions: torch.Tensor) -> torch.Tensor: - """Identify parcels within the trained controller's local manipulation region [m].""" - return ( - (positions[..., 0] > -0.25) - & (positions[..., 0] < 1.5) - & (positions[..., 1].abs() < 0.6) - & (positions[..., 2] < 0.4) - # The low merge passes beside the placement bend but is still remote transport. - & ((positions[..., 2] < 0.12) | (positions[..., 0] < 1.05)) - ) + def step(self, action: torch.Tensor) -> VecEnvStepReturn: + """Advance presentation motion; pass training actions through or park during idle playback.""" + if self._warehouse_animation: + self._animate_warehouse() + if not self.cfg.park_when_idle: + return super().step(action) + command = self.command_manager.get_term("transfer") + robot = self.scene["robot"] + joint_ids = self._warehouse_arm_joint_ids + parked = torch.zeros_like(action) + parked[:, :7] = ( + (robot.data.default_joint_pos.torch[:, joint_ids] - robot.data.joint_pos.torch[:, joint_ids]) + / self.cfg.actions.arm_action.scale + ).clamp(-0.25, 0.25) + # Keep invalid actions visible to the existing sanitization and termination terms. + use_policy = command.has_target | ~torch.isfinite(action).all(dim=1) + return super().step(torch.where(use_policy[:, None], action, parked)) + + def close(self) -> None: + """Release presentation attributes before closing the shared task resources.""" + self._warehouse_animation.clear() + super().close() def _adapt_policy_cube_state( self, positions: torch.Tensor, quaternions: torch.Tensor, velocities: torch.Tensor @@ -105,24 +116,6 @@ def _adapt_policy_cube_state( torch.where(remote[..., None], transport_velocity, velocities), ) - def step(self, action: torch.Tensor) -> VecEnvStepReturn: - """Advance presentation motion; pass training actions through or park during idle playback.""" - if self._warehouse_animation: - self._animate_warehouse() - if not self.cfg.park_when_idle: - return super().step(action) - command = self.command_manager.get_term("transfer") - robot = self.scene["robot"] - joint_ids = self._warehouse_arm_joint_ids - parked = torch.zeros_like(action) - parked[:, :7] = ( - (robot.data.default_joint_pos.torch[:, joint_ids] - robot.data.joint_pos.torch[:, joint_ids]) - / self.cfg.actions.arm_action.scale - ).clamp(-0.25, 0.25) - # Keep invalid actions visible to the existing sanitization and termination terms. - use_policy = command.has_target | ~torch.isfinite(action).all(dim=1) - return super().step(torch.where(use_policy[:, None], action, parked)) - def _animate_warehouse(self) -> None: """Sample USD motion using policy time, independently of render frame rate.""" from pxr import Sdf @@ -150,7 +143,14 @@ def _reset_idx(self, env_ids: Sequence[int] | torch.Tensor | slice) -> None: cube.write_root_pose_to_sim_index(root_pose=pose, env_ids=env_ids) cube.write_root_velocity_to_sim_index(root_velocity=pose.new_zeros((len(env_ids), 6)), env_ids=env_ids) - def close(self) -> None: - """Release presentation attributes before closing the shared task resources.""" - self._warehouse_animation.clear() - super().close() + @staticmethod + def _in_workcell(positions: torch.Tensor) -> torch.Tensor: + """Identify parcels within the trained controller's local manipulation region [m].""" + return ( + (positions[..., 0] > -0.25) + & (positions[..., 0] < 1.5) + & (positions[..., 1].abs() < 0.6) + & (positions[..., 2] < 0.4) + # The low merge passes beside the placement bend but is still remote transport. + & ((positions[..., 2] < 0.12) | (positions[..., 0] < 1.05)) + ) From e07445aeaaaf47b3f1dfc990460fc0809f7f9f94 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:44:23 +0100 Subject: [PATCH 42/45] Match Lab import and scene annotation conventions --- .../contrib/conveyor_franka/conveyor_franka_env_cfg.py | 2 +- .../isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi | 2 +- .../isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py index d6d3efde51ca..1a2763e29147 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/conveyor_franka_env_cfg.py @@ -459,7 +459,7 @@ def __post_init__(self) -> None: ), ) - def build_conveyor_belt_specs(self, **kwargs) -> tuple[SurfaceVelocitySpec, ...]: + def build_conveyor_belt_specs(self, **kwargs: float) -> tuple[SurfaceVelocitySpec, ...]: """Describe the velocity field on each authored collision section.""" return tuple( section.belt for side in ("Left", "Right") for section in belt_collision_section_specs(side, **kwargs) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi index 5fc03c8498f2..6e9c2ab2d3c8 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi @@ -77,7 +77,7 @@ from .terminations import ( transfer_sequence_time_out, ) from isaaclab.envs.mdp import * -from isaaclab_tasks.contrib.nist.utils.sampling import ( +from ...nist.utils.sampling import ( BetaSamplingStrategyCfg, SamplerCfg, UniformSamplingStrategyCfg, diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py index dff99bd7d286..7824f81bbee7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/curriculums.py @@ -16,9 +16,9 @@ from isaaclab.managers import CurriculumTermCfg, ManagerTermBase from isaaclab.utils import instantiate -from isaaclab_tasks.contrib.nist.utils.sampling import Sampler, SamplerCfg from isaaclab_tasks.utils.success_monitor import SuccessMonitorCfg +from ...nist.utils.sampling import Sampler, SamplerCfg from .reset_events import ConveyorResetRecipe if TYPE_CHECKING: From b702f94a3074fc3816df7aa35c5240c771ff65fa Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:47:16 +0100 Subject: [PATCH 43/45] Keep external named exports compatible with Lab lazy loading --- .../isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi index 6e9c2ab2d3c8..5fc03c8498f2 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/mdp/__init__.pyi @@ -77,7 +77,7 @@ from .terminations import ( transfer_sequence_time_out, ) from isaaclab.envs.mdp import * -from ...nist.utils.sampling import ( +from isaaclab_tasks.contrib.nist.utils.sampling import ( BetaSamplingStrategyCfg, SamplerCfg, UniformSamplingStrategyCfg, From 8ff9bf90dbc270fd9fe51b4070118e585db2d13e Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:50:27 +0100 Subject: [PATCH 44/45] Document the bounded warehouse policy evaluation --- .../isaaclab_tasks/contrib/conveyor_franka/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md index e1145717599d..978c7bdb4873 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md +++ b/source/isaaclab_tasks/isaaclab_tasks/contrib/conveyor_franka/README.md @@ -82,8 +82,11 @@ Remote inventory is represented through canonical waiting-slot observations beca the checkpoint learned compact, flat returns. Local states and physical interactions remain exact. Class dispatch uses supervisory metadata; the state-based policy does not recognize color. Playback parks the arm between transfers and after sorting. -Invalid actions still reach sanitization and termination. The unchanged checkpoint -can miss grasps and reset before finishing; reliable complete-batch sorting is not established. +Invalid actions still reach sanitization and termination. The shared racetrack +checkpoint has not demonstrated successful warehouse sorting: a two-environment, +2,400-step check completed no transfers despite active arrivals, with no safety +resets or non-finite observations. Evaluate warehouse training with physical +transfer and complete-batch sorting metrics. `commands.transfer.parcel_colors` sets appearances and `parcel_destinations` sets conveyor IDs; each color must have one destination. Set `randomize_arrivals=False` From bcda49cfc4acf7247f37c6cecbb5ff116857937a Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Mon, 26 Oct 2026 06:26:41 +0100 Subject: [PATCH 45/45] fix: Use shared conveyor clone defaults Use the clone configuration owner for existing conveyor namespace defaults. This preserves the public values while satisfying the current clone-ownership check across the integrated task stack. Recorded upstream main: e907f82edf18223be68df8f0201ed2660d9798fa --- .../changelog.d/conveyor-clone-default.fixed.rst | 1 + source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py | 3 ++- .../isaaclab_contrib/conveyors/surface_velocity.py | 4 +++- 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 source/isaaclab_contrib/changelog.d/conveyor-clone-default.fixed.rst diff --git a/source/isaaclab_contrib/changelog.d/conveyor-clone-default.fixed.rst b/source/isaaclab_contrib/changelog.d/conveyor-clone-default.fixed.rst new file mode 100644 index 000000000000..b8217db029c1 --- /dev/null +++ b/source/isaaclab_contrib/changelog.d/conveyor-clone-default.fixed.rst @@ -0,0 +1 @@ +* Fixed conveyor namespace defaults to follow the shared clone configuration. diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py index c91e2d079853..29e426209bec 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/newton.py @@ -18,6 +18,7 @@ from newton.examples.basic.example_basic_conveyor_forces import ConveyorForceModel from newton.solvers import SolverBase +from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE from isaaclab.physics import PhysicsEvent from .surface_velocity import SurfaceVelocitySpec, resolve_surface_velocity_paths @@ -74,7 +75,7 @@ def __init__( body_pattern: str, body_count_per_env: int, startup_duration_s: float = 1.0, - env_path_format: str = "/World/envs/env_{}", + env_path_format: str = DEFAULT_ENV_TEMPLATE, ) -> None: """Register model and solver callbacks for the authored surfaces. diff --git a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py index ab4113d0a38a..639f08796363 100644 --- a/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py +++ b/source/isaaclab_contrib/isaaclab_contrib/conveyors/surface_velocity.py @@ -11,6 +11,8 @@ from collections.abc import Sequence from dataclasses import dataclass +from isaaclab.cloner.cloner_cfg import DEFAULT_ENV_TEMPLATE + _ENV_REGEX_NS = "{ENV_REGEX_NS}" @@ -76,7 +78,7 @@ def __post_init__(self) -> None: def resolve_surface_velocity_paths( num_envs: int, surface_specs: Sequence[SurfaceVelocitySpec], - env_path_format: str = "/World/envs/env_{}", + env_path_format: str = DEFAULT_ENV_TEMPLATE, ) -> tuple[str, ...]: """Resolve conveyor templates to exact environment-major prim paths.