From ebceba78f26cc70734658f55265c8eedd6c647f8 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Tue, 6 Oct 2026 14:52:22 +0200 Subject: [PATCH 01/28] 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/28] 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/28] 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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½M3J=P0#nL`DuX}?!@QSPrH=D8_E*NI|A(!&jEb^vzrRT-2`Pyg;sT^W znjxg5r8^aAkWLvu8U!4=hVJg6OQb;rhM~K=loG}3Iq|;l|L@JS*16ENTnmR`X1@E} z$38wg2{{%|Y@^7uta~_(;AkbV=un()OjGHNjC(SC$DS5*V57l`vF_^EN)b;Ydli^B zDK4@2O1;AWv9$&LVY!ClbE$5K_-(LmbHlt%%{0rE{_wwOuO?eqi%OkY-rVp1$laOf z1!h3AGe}H#t$tG6UK}jCf-L-K=RJ4F0-SQ2-6!p#D#mX%#yB(e?4o5&sY4f^e(;f) zWgW!FO-7~Ye&ew*7k~Oe*@;%q?jZCyP5B_geP5%A?6;w>|NE-%IzlTlc6ugM=k60M zMG!TE+x_nS?%i*S5oJ|t6uDFDJddUZRw#Gk)5yA? z?ysfRRX$JR5a^!bJT zDFcN!+(SF4`H1W4(yDa`IS60`e}HD=UrGX$uZM&F4HG)9bQp85#-L^>wS&FGSZtvu z!xuwh?ZN$_w+q}#hM~%nn5d?mLd&k1X)e47iAuzDu#SDS>F#p_x<*yxD3Jx7ik$_v zWq_!p$}zH{{rE1T8U|Cbfz(iOa}P^lo4@XkgX^!GvF?}jG2W~{6WA8@<5lYZ2-}^h zWoc%8?2L5@^<6!?bns+uU-C`~y|#&!cppN}e^57Wt(c?%7Sg1ze|8{+W4YV4A_T(| zNFPev)3rA?$hu@2QRlDa`1#AEvCGVb^~l(}?$O1nrN~gGdux-hU$o^Po@hJeqr|5o zyH6)aF1-ZWWY<{6D<*Z5Z2eM7*Gq9deDO){-eV=t@XKC*_jIRBxWM)-M8Rt?Ues3B zz8m6RO75dabrtJ1WU?p#lYa=_eJjmbx+y$6%Kqbb`ck$)5<&l@@Y~9f9jdF{sRA^` z+AB;T3w6x$d-R*|N5of40Gjd)XtVA77wxo*-y5?SZ)6&gJNlu0P#Ckgccb*+cifFS zbwF0(IPct&Qc90KLQ-1Mc5a&+u^B3h7m4SZ! zEQMb;pt5ncy5#G)`<8D&p~e`8)B6?rZ#)UVtJ9;lFYX(y#JZct*NURk>2It?zB}t8 z$1el@YaCC#ekd=_r&CFMQ@OmPQO`*4XBzQ5IDW41h>1?+A^~Dt?0GtPte-fiW4uL5 zJS*YS^R@iGQBfWjvj8NXyvu3ov_oa$HYL;vZ|Y)orl3ehpO~EMdHj&(s?=ZDf@cp5P zoL8HPLtvXrO%D9sULZSx#!wSiGjpw#HgbBh7t8`8jHVi-8H`1veC4*;c~;td1E50U zSMwtFa5p*?6?r72d%x>PWG_97M$yK1cP<@*?<(@!1(wg+{pScGw2;jE7YdAkaeczY@}i z-2eaI^&|)0qH5OtLyo(BPh~XveZ6fz7tt*|idGnE4!<0I^lP8IG|Ek)@;C!bVpAi?eSIj3Q z)0ym3e(#zMxj$8uyBr;AC>>u1`4R_d?>1GVSEHd1qooi~E-i*J0>HWdtHmAvFIQbQ zK9L0S!$7s*0#C>VQ_%A~!|-icoFw@%-83}L?$kV`yuh({SnH@d8nSrS2!f_|Tp1ngoIskK0vg)O zju$Jad1fQB2O+0p@U`1*sq2o7@O?r&gp*Wdp3{V?xrgA3=}lI5U*a>7^b2a7CWR)2 zh|-#9rP7jzXJ*{P9UoZOzU}PlYmEZ$47IzLO^B-rmjzS1i_Wt_h=lJ2_$4o+olB8NUUsu& z5t7De(dR`E=;h;o(Z;BDZZrE@d1dTBz1C3$`KU>HMEDs8(LHu1m1jHI_bzwDJLeBEeHpyaqL0SgWZIV znrKro2>Kz`kMkE5U5ihr8pgf(%>)JO7c0RhVxNBAAtG=5wq8)Xxb8l%hz+tZ8?u^R zW(jta+Qk0eC7=+t?lQgqy-VPHrG!7)@TTN+FWU56H3yXZm}|3M71!r6H?T<;o3}gR zVwD#u$irW`E;Mso0VvG-aL7!J*70x>)w5 zIN;6<;3~+HcUPTTJfeHtkkffS0EsQF;h zV7gcMo+%(>HR6p}PG9fZi?6)e$4dutC0^qHv^Y|2H5!OM`uJUqtF-?ju6F@^Sd#Qy z);&?ru#D%4aLo!QEs}5-66q%rI?MNiAlOJ=PxgK^Y77eg3XD0XLVKx{ldVbn+L+*c zxmf67IZy#HihsOwuCsI}jWm8E|KK&w2dEj7SHmoua^-wOCO@!;qsJu1CZJz$ZS$5egQ2y{7P%;bM@;Xb0Q4kijE%&S8A#!DjPyw z%J$l)(z8Ag8}yApF$z{ka42iQLmGY0rEDZpx__zXU_E&d&2nZkT1L*UqIdm7s}WlB z8v{i?qMU^?0bKSDSe~@fO>mza`XaVP)4uB0euuvb1c|0Ly?ah(HO`3C|1juD zRW?&s3LXUL73Y@NWhQlDx(O(rDbD?doa3vOHc6KJi>3(PQd>DVT=#i+e6CPYH=NcB zof!4bni9h39g^}`*EMIFi=Pzy7rzHGBwBq6p@mS;&~w1D@5QMLTR;8Wr30P1Beo~W zC@qaU@&&3ii5E};L)Vc}j5>Ei7Gi}OA32V#R@s)4qB1+gaiEfC8{Y|11_zs~k(4_^ zI~Pj(HkJ#_R!!p`N*)@94By#}*Xs)1gf7C9$-^G!p#nr*CHN70EkjI7+yu29%kbOa zLtS~>X7#V>YY8Y^Ra?|zsH!RT>vyfXle&$e))BJP8wp>1C>Q|&tEyhI1`3Sm(a}^qN*fQCy1L zx%YMpeAFvMnKpDHEq%)9IBj~G>QHuQzYEVX%H-;_1DGr7GxIQHrqaSL z@~!QDN3v6u>z1n2g0A2V=1osne64Z;qt}t($xBE8)7{^!*RcW1#)ZTX6KII&O)R-A zBc3MjKd~VRRBYre^^ADK(#Yqr=-D}uhmTIj5WUp7Y-b?FNmwo_w;!(6}C_+a>(~* zSP^A;AKz|9r91`eU$mbf=erVOE3gSX-1oGsrwtaaCN6KRV--#*EaN`?M4P?(vA$x_ z8&-6I3s!w2&?fFBY#&@7c0px?DQmX6gNX@EYSC#?(R?0^rW!;Vq~hx0fQ8AzMx}tE zi?L_sLSmBHitE4aYV+PFrP3Eg7)0a8ilaQ-Q{D|ORxp3f6tlXyN0z9(sYg^@A_1X>hEI(XPgu6OPA8s-P$BM&^Js$I`!!mM<2nr)tur zEd70$7)L58_daC0+kqpqKbz#M?l|O~Wo_qmjq;E-3Q!SE9Co`|Wtzke-srpOSwx!N zTw;AX2a%CRE&`uFe;8x+6Zp*<(s;Rtq|8-1*jze-^B4BSC{7tNtkpPBQ8l8lMJpbVDN_h z--`2p7M&lyDuGmD4!Mi%?)f@Le<9p~$0T!|Xj>ONwpo>_R!o3x?M2a!qdPYQ?*c?ztRLG3!QUxA6L%Z-t5`hp?^v?|qS&A0O|yP5jbMIur^jyF zYH8+i^LJ2dKY*%VQu-FRtBYFa^aE*!iFN!+FUq;5lKj<=O>2b*D}4pU*XrUcU+>t= zaRhmi@Tl61&3-ODFDH{KAzCAK0iT>M{}}CPwr*CK!L1T&e=~B+Pc!76R3}w;Qz63? zA=_rjf1Emmkio*hW%$lWFwr5nM1K8L(GrF1hJJW7py}I9w6(aF*kkWdeq_H@QIwG+ z$0?V74T{8^#otI3p*=wwC2^31$N!BK0Xd8Z5*8rZ!>(8@w8_Fq-xskm3byme^25Wc zd7{+;B3eE>LP+G-hl(H_mY)A%ze(Um$LqUeuscofz&4z!%pW*opq2D&{$`CIzEE&@zQ)U&5 zTPXvDxWsizKn;i`TvK$_wo)Nmq%Y5}I}&iUXjBfPMb3H|fqwSr_(`+!O4p%)teyr9 z%ymttwo2OcW&$11a^7?zgCa|*B%3RQpkkLxBaSJtLoilh+)uxDo9u;g^eDqIt{MDMQe3Y8!R`1O|nloC`GU=~q-r9XahVBw)! z;Cg=zExpOyLv;nXHS+7{TFAK=D<|SSA^EN<+;Bj@u)2h3Zhz;gJm#_05S@ZD5sO3P`3fw;hP?*0GMyR<67b zU@EOCp-BB4kQpwn90xYp%CZH*KAb6VN4s=3!^LxbX^+_g*fym6^A0Z-6_|u6@yi{+ zs&76Q*Cxr^nlZ6Rr|f1}hsthE*u&G$bP}a++>1+|s|;`vx^7oB6IN%Pv=|>BdLt+G z#T-bEyF-f3WT8HO^Uy`bK!Xb5_bmZW5O!P5C`j{IR_G&Gd7t0iFYFMZq!6Pf=l(vj%G4l)LugH@)ENiJ{OgPQgCTxo%fAMN$?-b zmxpH@8V##4P$)DOPH2CA*cTWmuX)2>KT!Ta9sS(u1BPgbEAbIYj=s5Vs2h0&X!gdp zIVc*UoDv9A04K9$FVTY<3@P;LnYx3y-(?cn*+3-e2ntyHG6U&U=bI?6CFrePPig$9 zgTGpim{cAn9;`o;K}aU8yy#JsG;1(Dy*wB9V^_3nYwBz3Tdy(0yR0qxpeTc&bqijm z1%ftd0t0_jEg`KsOB=JwnUE{%P#7Zj$<@r%` z{Q)sdP|0o?py`6Q^@}6@kECVmHV4%3o?gf-s=HQQ!rX39eM{O}4hQNH`GJ^jXQu8j zM(rBLIhffgXP~~7`rk(ftb=oWuv+8}V0dSs^rwZM`;RvI*6?qC)YwpWs@3=H`Y|=zG%Fg^M#m1D`A3juEhz`I9{fGy3Do?FA@o>1|CBvNHk%WLZqr2<_8}^ z>f0L!`xa44;-RN-)6skVi}sjqXAp=ppXg#HAG?k!7nBucUp6Iwml0I-R@%03oQDEO zv`Da)k}zLvK4Rf729ig)UnWBSKfi^o|>oh+V28Y*HBUN zaojT&s;Vy6yRBOe6sRDWZRL_H2-bB((zOQ{LmN-*pSJ-aMv5}HOxEAee3I>I?Fu~_;)R>0aAYuhMe#)y!_r@USccr)Nd$b_c+P-7 zMSR+*BtH&IuOL4mv%^~1Xge0u&{%qp74WfS%}}k<>1->TS->}|*VEIP`eIWSFI1Rj zFBy!vdaZ!GX%(ubX3jxZa9#|ap+#LT3-c}n?s2zUGQ`nf{(b2NO9njVGk2gx+kmGoS01&Stfq-oAb+k=FgtC!r_6{jl?uxta5ok6#YF zxL+_}khr~f<$kuw%v#<{?$Du#7ur5C*86tCp@DG=PX~QiHL=WHQ^zMU&lRGQp4}O& znIvhiDUMmsVWXl6)#Sl>J0b7dYP?6`y|{2tY(MO;!TloUkv3__=yayvjCbC=3(0_I zm@CV_Xtq33Bveu))!ZV@PEX5x-X_*9Rs7dOJe7YR`-UMR<=gE=C4c>i(hlsejaU!x z*xm7c=$f`^DeF+}HqyViHwzxYfUP8j4`Ju}&RKUa)yL&JCk! z54KL~m%1=dft_&F8&s=#${kgdn4zQkEWon77hkq^4i%K*!4}zkLiBujh zR+I_g-e>xWja9A(ua%Yj61Lpv|E&_URSo<)sL(wV)zeo8{X%rZ&ZWQPm#O%ZG62+C z<)C0(Y&BsY#8J)1awq1~0KM|PrJx+XKR<{KB3T}bf|bb|UhAf5SS;|Sj?1}3AJ|t| zovZNU?h;#o;z%RYUP%=i~w^Kj%SxrVrngGS}USpM;|*XR0&1SsCb)v+T9V@J&Y z-HXj&O~h6*n@_+$s0NdRHVtoT^YBqDAJ+6BKR^}fXdvX5fs0-^H zHOLYneVtR6?dYOpF5O{C|Rvf_k48tp~lnuGT)@RCnqP!ZA=1YNOs(#dwPWiMS ze+fvdQh*l9gH0DJ9(B?Ybl!fiQ^u84IpLB%oyFZF8dKyMFn6fNR`w_j5HjO@_Gx)> zqYv0~@tsfs=MW^BI9LVk1nc^q9_4wxh-f3Y{#aEOZ3vSy{H=uZW&#Jcby&rn8^Fv~ zQDmL@8ubpReZKstlr};(kRqV@D z?{yq`((BQVH5ZwKdp#o{UiWV9k+Q~fV)Po~XidLg#8LWcF(wOVU&-}8IZ3_@^&DWO zR9?ng_b0CSxn+kESDqix5=^}J9`DIuh<_@p=nNS=z33Zw4SlgBoG^E6Ey=Bv-_eC7VHTN!mVA?OaBC)$KG+L2! zEiP@<5w)HieSSM(cVvHtv)Q&2{^?Z?tK)aFmWCQ|egKH)Ixz7deZoa@b zW0vW!hV1tRuQ%~eHeEYj*0me=3y%j7)OhB48psE0_Ac&zPnx`~RWNdCSG1#SUU~PF zjdNW>ObNyudkra#(QqU9tk>+n-H#cFclSZ((2UI0dT_)oCn7&Gbkmz(9Cv$T*_V5a zQ|L@=iAC?(Yg_>ZvCo?lvU{N^`tQ~bEZ(&Wi`z_=4}gBta30LRQa^*@P~OJE)@z^1 zTcdQfmQTFmC&;(_#Na-WKoz_zM`1{Ky@7K@KJ)N6ke+tx?722x>cuE*d-#YPe9u6V zhW4Eu7_m+|Y_AE3ILywVo%+ip{kUX3l2A3zlAZ}fGDIIoc`I8>!29# zF^x&R_OZeoO1S-53wP_Q^)~UoI{~XXmE?i&)&t)rd(%0&p1R(0)=J-V=1GD(>{3rZ zJ)|T)hPC`x+IH@mH>ep+FWrbg`*vNZI<;J8e^}xekd`oo8d%>rvHZ&6V-x@&KC17= z84d1?)2;5!WvMl%=80E6j(7fE)85|$;&kPDZWv8#Xq8n)P{!FIp@AbIOR~9?uEwt zGA(>)Hie|=C%SLv`-hCbd8k}(K^!`E^or1(;080nFyzX9W|~-f@KyTK3XGKaG2LeC zvgdM#ZrfHe9jdQ$s9mH9SO9edTC9PaIjD#AWRudc?SDIC!6T(6WPM9ZWYc z81eijdBimzk>+ENXgilOV!Eb*Nb@nJRdDE(HG^BJRnP#z2xC+EpMV6E@ex0{0UuC%T>Jt_;MDev75XuWQ98g_u)Gue+wPmwUi)LCNHkzQA&Z zxTvf;wd(K&#&$85g%F!`8(0a49g>`nk&lNi-L?^yxA6}N`^o6bjS*ZD>lz}*F;meH zNJ#OWha=6J+UWqr%uMphz1r)=$3Eg`#6qM>M7j;;-_rhM-O1P=p3Hj@k~be{U@{3X zn};%u80lB86x^tDREyB~wZ?Uk^>zMw3efDD*(y&dN5bXXn;q;;MlK`}cRW4=ylSs< zCS-pAwcdj@h*UY>Xxb{wVBTL4x7(oJ+i<&s6NFvn>9)>nrjuNcfv$*2&ffi+-stVI zgON`EGdV$_Cm-LWB=S8>tZ_DT>WlyCK8gq7B1=*${Wl5v4`3WVWEmeWOt4*rhvq%Q z`WsKNx8DxHW2F_Mn7xG`Z{sV$GBrZ_r5-)1E4%*A_o_tOmK~lRw>z#4q$K=Z0(c;9 zL?}8NS`nME?D?oUP?b!c)1Lu|Oq_=iFDpO*WR1rA@)8}e(HL+?SL)Yl7HxLGvlF`e zwz0QOebqH!3wvf!^`X-1_lPC^mSPf#W}sFka5g=+>(|#4dKMvbo}$XimKjA8IW1+abQlJG$UN$3E>=OgKI zyf3DO=on=2@vgG+CDFb`59&9ZTNU_1jVt>qj37r;MoC;#`NTjqPhBjL2~KeU{F5*s z*_?z)N#k?aDdOX;uJf@Kgv^nGFqnnY;_jU87I#VHQu1#cUb1l8rqx8RFR1plSN2qX zjwiEhrV1vw;z+69!sr68z_lyxqrfB$xw~h{=WAFmY>7b@04v>KLQ>xJw4y4J%8) zw`CaEWO2Yr&I3|Dw;5&#m_D|QhK~S~4Q&Tak6t4k6~CW_3!HRwvIM5j6(HftN8Hh{ zl$kqs9eK^^Zl+BpR)iPgme+LFB>h~*R*0(oV80k={?K6NO503guh9Ff(o^k~AJooM zU{0rT!Aa&{G|j>5GxucQteY!d`2#|GhaPPJGd^cCK_$T)K2T$EpGM>)Ibh(|Xq$Re zMHJHhsquMMkRl0GW|ugmv=Wh8ztA->H~!SU+|E|G@$3{(G-08waUrU1f9eAax;_6O z4Nn#wf5>StYWDWs!e3a9*v|gq2B7c76ke{)9ADPzTahHprz6`J%f~ks?^$hol#V2K zE*6`roSF+TH7x$&2H(`i{K9Nu zv1qP?Vdj^Ve+q>bZFp6{;+;2mQfk|UjQV0@UYDg|uNvwwP|f{x7hffN(eLAn@=&t*iD-LO>gpQ#6hYZL|RXZDjMfz|BiAWO-Ujg{FpQ%_W_0H9N)-a9*R^OYe2 zh8M3`8jK;gr!H<*L0Ex-K8fnwrVsc;D#McEp`K0%jJ1RW>6wP!uoI zKOZei%=?RahcpTKDc0JSniYyGp; zCbcot%5J8Y797Bcc?14Q;SJNqFnA~_O9_X8<^RD3m+Q!UamPe|_DDrHvI)ZiloYR^ zDS%ne=rGbwru5~pHNdQ^>bDjQ3Dr9fMKu){J9f=XaT_jwz2fAs|5Db%w;nP>-)`S( zOZ1CxyW|XbdsK5j_t8d`7ju_2f6y=?Pf$}CVR{gG+fUZZ#+@#qr3n+|)8FCjZI`^* zQ{sKq-Q5HWGnBJRxZGMtB^eQ}f#Z_qBc4C6=bXr_<}Az} z(PMkRyE$-ryvrFl#hp#iebB1)Jk__G#}Wv+>VGSqB)_T~(a=&EdF$JkejSM+fuAqb zGxhyZ28ZX-I4FZP-SaO0?&ktGZpnttb)@u!Y+J7sW`ud|<`77;<9iCibCLIr zh__-nAX*+t@K+xg!RQI7RFmaEUF_VD zNS`3j)E>1pl{+?8?-;;vZ8NT6k)&_TK6C)pl}A2;36XEbz(2e%2pbW>54ZKLg2P(vE1J;A;S<+lfk$` zgN`??+GlkEozkgvtCI!XtOOeSzYro$#J0LAO5 zmSzI+r=c?*OEU%04h0c_rsXwgOENa9Tu4=7&0O5|lO8h<;nP{-(ORq-DChqDBVf7Mu9tmm8=41+<_636E~Mkf7?l$$?^2 z)%x5m?IcT{ zMGQ3D06%P-3O+pb03rTge%bhC z(>+R@*xtut5h0GAk->=)WAu5ebSfiFueol9vDFH8Gz_SZqNR_0uXzk;X>yi^Wy!Ar zaq;tdNNi@ur&B&5;wEa&a zvKj}whGh;*%-|juGBf^zEuqawJ&KO2b|k%x0u(a3;8VV(aW^bCEH4%{iAghBz9pHc zzS6631!LD?PSf|^!W#GH4yTq2Otj3-M2-iAhYWSel9_e?TAAV8 zDgXyySe8Vcgpq}nsEE-XSds#$5k^&$VHqk>vf8#1B%8A#d!lHNq+vFq+9h=uvg2}6 z_fJx_3sFS+w&0&1KZ&)gG7kb?UaqrwR|5cKB<=Z(sl1uxho8nSPCxH~f*aB@r`OF+ z))g@a3;Ns$^^HmGL_GS=Kem%>&xlFY-P`?tv1a%1R0(9j|O8`J4x}ZQ}&Pa!?sx@)xLf;bt?*%gIP3 zHjvpW+-x4MVlQb&o&zE1D)s6@e%}4EM+29*2p>BALpHN9qWE!Z{2zkBk+N!^eKE%X zX*c>n5nvjtxHsYnZ0OWVY3u8m9MT%di|40AaziyJEbTuP>*!NXt%=z0N?`Scxm?^Q z?Ki$X5o3JD4=E-EiWMz`v8OO>aifGItXc9dU4PjWC1hH75d|u}JIm=;l61%5C|T`sE2w$GYhn?Rs83?sc#^#|)IUyag4Qh(vb7?Y{kbyT;uYX#yTE1;85E zOH)!x;-@)4zOi6xLVJ*8N`H2zYq?f4WgM3>cH?x}kaFlP$*?sX=u=67R2Cbkv(PW6$rgkLmR2 z_ieDF@2BgVCAMQUrtz_N8#Pi3w6l=Fmczlkg+qMf7#h>!cDY8j4qje1j;I=7#cPno zFvAp5yI>EdAG@DyS5kGeML~0l5{Kgfe6dvE*jca$Px^2KL?@n|u--X`;Qr)rkM`Pt zi@vB@ZO81@bOiBF>hT;2lT`rIRi>Q47N0}#s*@yZD88VDp9Fu7cyx3+KKL88A((`f z6M}qCk6HJMdEO=JlDc_zOnqXZeml5fr0U4Nd*sWUZ%@P5i&qB~>jMQS!t@=t{hjaeeAFrgTcz*_-vVC!x#wrI+%%a>3jlN=)ezU!H1@`%Kd; zE{+ugorbT~H-fo{H>c+}#!FjctMt76D*U^jTie=!6r&96Ch^?k8GZm2ZWAC24s@VZ zEm#DKWCfC13fh#V8~;3(&j5D3IN!vUq+VADy(T#}Mo!~+@qBa`+{Lz5W4cP6-^QY} zv=oa(wxQtT3=I*y2JcN-5zH>P_bXHeXT1#USdYiHdUwy8 z&TO$te(HwF6xVRP^Y=w~67#7k&N0~TyXM$#ReoyB{}U4|DwIg>xdv+T(nWyy~0p@vX`>1W>%1iy_lqF zqA^qBeSlT>YH9)}2E2&mCyu_J9eT~2DOfkoN$?n`?#Rp9s7V;;4r9+XpY{IkJjJDX z01oh*MtofkNi9k@`Nb!he@y?VlQ?9XZvkB`FafFksn(SCRY1L(3M)`qIrT5v{<5Ya zNEKtljLFI>`C_v^JfQ-8jC!|$BqNN<+F5pc?j;>^*~2WYC1kS9#-mru;{J({+%fcJ zt3BRY2ln^mb_T!QNFJ&`n5>Ccfu$v6CXe%=nxdkA zmUs+5oSI+PL}@5WC?*xUUYf+B2y2FoX!MKtt=miWA4E)6oooZ^}3w9MnxqEf0i`^_q0 zRSg{Eg&hRC7#)<*3I0;Zz^+J2hQ1DXmDDmDL0R=yrM6v<6q1YL>qFeLlZhwZYC=_p zvRE;biE--1SuCYln;yw*l9pg5uWNE>0&F00?GhF`HgIC>Rnf%bJU3iAB29R*hxEB$ zyov*fC}|2GkA?#dFjr*3Dw4%vd8Lkxk^f9PPd+EQy?sHGqk1Mu=hdpl$DPuzT zVK)R|zFa2@$Z1if;u!%#6f}oHJRtcVW2jPyC(}0|@j~kr4(aFPx?99HC$HK);}H{g zsO};)=X;uHo%XXBV&Z?=tT*f5%kPJSr+-N!#nOeEJHGOy|0$7cWB5Zhv0kJK_5(<@ zfbahxoY)K<9oef=qqz(?E*Do1y(cDAe z3ZyVoJzRWkS9lr=Mxr(#JO2|u!z+YqD=C$^%Pq$yhvne|hN;|oh--XK5w4CYYHNTI zm!(5$QGQKU?{rX2tzFT^!Iu8e#o}kJ>cP0O?TqmJX{ukzIN8s*_mO~$ChxobeY-5sm&)>(-CF~;+7>dCu5V>M2je!!|k+2@0UoPgGe zU2#;3>y5Zqngy=xJ79L%07$7IB_OiE%1-$NSGKuKmZZR&q;5tJIr;exJ{qG&bKK~2u~CqX{tXpVm`<|jvB>N8_^Zb zK_GpLSOk;t)yb5P*jCk8n=q1k1#^lrNAOOhpCyL4r|&VCZHHZzY!|Vhsq4Ik6@`_w z4Ko|tnl4`+WnzA;CK!=s^GhfwfA&SuE!&39Lx=W8G_k@H6WbeB zG)G5iBKrhwxuHb(skDBguCLSU-e={&bD|hb?P~it@4DOfl{agy(2U`O!Hz?KthJpLs@3Tm2yn7Lm@EomVhb)IW;Cdt54+DjEa;5;Urd_- zkeg=}uo>Urnc2K!VLVeG08$#QF5VM%erg^%*`bPTzs@XbFrb`K5D)^bex15Gy@FlD z*H!_OcFBQkgiB#bE^38kT16S0x_R6-_2^%;i1ZsoK;P;rIImXJ!HlWQ)B1hWp@|j3 z!|KwRiEk9ggbp}P+`w;m)$CXDOsjoMYFDdDK#wkdDG+VVt^1>P_3h1|c8L+q@SH70 zwqHumoK=aPA&BcI=bSk}?S~F)1P%I;k@31HXN#SbLdPe>n8&1mSbOFf4PKU*O(0GV zF?a~4vqIa@rWCH?P?r06vcvsvL}>%H?i;!Z6cI+8GXZ5FZ~N87rZZVV){k_9b|T{kMWO?dkr(GwZGT@fTx}ZrFa7HXl@$)V2znoiH*|7n35I+tFSTOLecZ z0Y_4w~n#Fh#bHk4S!3m=3HXM&Ak4ozIEigRy9t+Ks|ug)j~I7Qnj$yBOvDg;Cn|* znwP7bU8QsINlG}XQ4iCn{iTVvr4NmR<%A^6cU|lvX}&&Xt)%Gvgqef4BX{0f6G)vW zq@iie!0jkJh+p8sQO58M-LR>nm7yibBt*f$opPOsAjK)&)rdw1M<^k$3=1hvHoTtx zK5r0!G!VEQsap<($YjcYK4^|C&ZHXaQJc#0RY3+|+#DAPQ-j_=63u1V3<#UwM$wwp zH&s6q_m8VhkMpXpJI&qWqNg|>J25P1)Yr}Rernm75PWFx%BbQn^%cU+WU9^G9vl&z zf(#$?N?lU%HB8r6_^xGy!^{B6$7q5Gl5hWCT#N)(gvK^r#_l-}~;Ft#*FGsc;e)pl~Hg%@uXdvu$=CvK6`*Y`zB>ICFE8P-cZYX!~;e zjV_u5h#ww3d#9zqzt?(>UtCq|YN?tfO$bEBWDMYqc0}pjOBQL5d*7unWt=?z^G7wV zvgzy1UVC(CAPt(U?*{Uwqt54j*_bN@l0Pql9N>Y;q?31yQMtQ9 zWa7#5cAnD}<>d_%X#;<`Y-8icNUneZlqM z^&Px6_2Cm~tKwCqFk)Fd$?{sRJv_5 z8C0@ejDrgb%Q#XAc28oHMW;Gda1BEnW`JRVLWutQ~5B0U8xY31gE5df?o^#_*%iY>KFn{nT`~E?DB7*YB-+k2O?jr5=srBv<{jL$N z-rB!t;r4e`?{2?+xGd>#nCU)&KiT4wO8lMjFWO@6o%V;HwSVFO!9SCG8%N7C=N+`d zYJX4doE(^3WB+(aG-LM1cluwvqSoJ7zPYgsG3Qv-T(^160_TL5Z`-J~5Ny@*F4Qkw z{5iS5Z|%NR@K1B%Z@>MeI4>G9EV7mxE6K9HLnB#m@c6|wyMokM>p>b=QRHsBVSgnS zi_#-t+DYb~=O(H|>>wL92~?av^R1x3ReH~$yK>rZ8}br^BtDD5G%T!W!dMna{rM#_ zud2p}I!ybv89MWsIZ?QjmWORax-9Ep^~+t1jZT$ILREx$)m+6LeBG#*zxe zkQsz=6c??&eEM4bThXjvnt{4a_liw++t^ziZ((0SfD=Y8=sx<=7VL(E z*jStRhN>}Jl2ki%luy`;uCVY;)3;Vbpk&I@;qz)94Wy)SCF!_L@=WCujFXZW`(lrZ zGQ~slQ!e^;zfK5So4X}U(~qaGoSO!gc|l2i@bJ3rCV%t9K=1v9{2GGrtppHhbW zHi@bX$RC#Qpp&d@9vb4rN|3C1SPurs4gI^^iL2}Y*eRD%7L}6@j0g0-5)f=+JLn=I z22SrX#&o^+N;BAAe92V?s*6)7h%p;eNZ(*2F-P>P>B{W1TLD#*ZogdTUqKPl0RODaQgHZs3*Tbhx> zz1(*pXOP@SO~FbxW9!ZVeSP@|J-0Cj zQR=@Y`-JY0r|)w$epaW2;+(d;*O;3_8l2utm!KN_z8>3S;q;&51=H7e*jm^nZ}HYx zWC#N0PeHg;6GIn9hRX|^JQKBC`e)x$qB1U~hK+9l$R@-Sa`5ri+>lRE)um466_u1J z)0>D#o%1m5aJ3!ojPLH%^Ndr?Zz>u|Wp$1mof;cr7p)LJ0soNb1X4UM?Y;c@$x!iw zhP@MdD%sul!wvR()TCZ=LqLyAHGK$wTf(2G#W&ED!=lmGEpJmvxVpBBKYZ)C!}a#1 zsn&j8KndGs_CRyFQHq&sXYIOnD{LB-rHlBvd6%CAY8G;EAF~L@i58`*ps;EN3ou!* zsm9U;jzKVGxHDZDp?Op^C!%heWX5=GsEyrlTH9SnVX&DAs#oVE(Z&L&x7dDrc7F7( z`8bueKE1A9gSx#d8+&-t=gC8yibf`Bk)xWKlmQ1GP38C908Z&qUPz#&%`3xV)of~2 zA?#@3s1D8d%&cYJe_8O(EBga(Kiam}G2frfsAYFcJv?P?+pKylqhUG+XQSD6QE<3$ zg?BQwRE5h9bvm;VnV-pESoaMF{q+q|4cDB+zF_S?q}w0gjvM@nI7(*6;p-J-$9p&q zsId;V3b3}voJRxi#eA3S1vl54Tr;iJ`47rW@7{fru6PRoB&1BQsm{Zqm@5rwb4hp4l6BlrWk{e==$ z3|@=_E2`kPT(3(JMix9LmdPlG~28FM)?MhkDGl_ia>ADr48Kbv`TN zlHAHx*{Ku9|G^P;HOz1QlxP%@Vp zHtTD74sT3*K=k9|jAy9aD;;$h5;bRlKD#Y*FY=MU6{bEwh$YA#CgofH8{ce; zi+W4N#G_4li$g`{)5<()6U!vzJGAS0OHHStwdP7ZwTOiV}SiQ~)E^~a$|nS|i?0vYhJivlR)qrF(9~JuRU0 z|Gq_~B<6KcmH1TZdo0Z{yGEpT=vLa=<$flHTi@-YY`s`nU0R(qs`7Y?JY3NBaYKU^ zDkhfD41fH5!_UT^;2pIEri=DB@D;io+hI`rX=zl`{O}6DvL?gzTiW#ZN#PhO>zujv z>g_UX{$h7&g}z%Ao(p@=Q#P@GNG{*js-0xL3FI5?;m9v0=ZCKm;TwpvmT@{TVK-LTiypDA60m@KWbnMfg$;;@=?!r$6j8D)XL4FC zEF9?+Cdhr0B^J!qqqWfHXc$+b%;Tw$LMtm7kj@$R%lL=%;UAKzap~m9sA1*co|VDh>!{}wQKt!ey%X7|m{uAjKqEytUOl&L$u+BvvPJA3ERd*AFg z1QWdfxu3cMRHg3oU zmsE1?@AT$e*~x7Z{0A;E_kcOCtjq1m+dg$drjq1(X&R6X!U^*fYfw09^nrV2eN=L6 z)OL7Ab9v1{cl^S=;cJ_h+N?+OrX_9LiuJOpqQEGm@25Ae)0KJ1K>Lo{s@Rsbk9$$$ z7j*gc8G+rw1{C2oL!cn&hX<>^_7m-!RL*-vcG-JAl)jQg-1NRyC;Rf&9j8M(wKwdd z6+E{oCA|Erg+${6;sz%jn;``e8WYv`5{q-GGgdg>Rt3APxt06@P=yb$w>k3-0zGB7&Df9C-cff#sEixZQu_oA=l zWO_}6Wl(+~l<;J*3UjfJSqck;4*^w(H6o^FR>*|;!lACM2juQ$;)Il0+*+KhH0TK8G>(= zw4nGb#gEiuKAb3HHWBXG56cL;hw;Y&X|Kl0cYk+2zba`B(pD09p}fN54(WpjUZQ*V@ZE9zcnfcU*@cdgz-<7^-pz9m-onNl)TWyEIJhw_h)Nzn3#a90mF z1PAKzH^PXoqOd%#EFrQzThA*^=_FpRcd_;s&m$z*osj!4~XYe|B~)+0V-ZXMh0&Ec?T;?&f!JX zSah`A!G&?q%9uDO0R|K{!&u$4Ew6d?C}~r1GGS2xkD{$0H67$5Jz~CFIZS75gRM%t zk3x7-j4iTQ4}4+BL->Cx#q`3F-o%U#<|hKd8hJfA6|dx!{@J?1=JiHHW^%L|4b691 znW)6{%4AgjLSWpNY-)qoon0a*yyz({3vQhVcabmQ9vbY5`A`plFQ-SS&lDByBmf^| zcS>!XU23i=a+|0?=iqVI_uRhA`&{)NEjUM#Ce}WKq8xd0o|EV6(+V^!ea(mzPrF61 z(xxj4Yi?@&*cy%MRi|EgAQoOX&W6ZAH<~-a##`5OMq7OtK}+F3qyavf556j!;=v_0 zf+R6*zz^IA7f%$xUji?BX5USsoz3vqet1GRequ9{mnsTlRAm2RBmH8>K8=T(YChZJ zscrH@F7JB(uhqaYp@?^V64qhr;F^ICA7pvu5~7jwqX)Ykf}?Bf1C< zET%mcd8<6(a?qvQtVKSll(^?$=Oh)cK|!>4+o|65vG7Bzm~m$s=^s7ak27;bX1{zS zCNpFGUt(9z3)-zfDqx3a_bIuv!*N#I{ao=&dfnVYPWjPwvRq@a4re%C*?r0&%umWz z>PcB_u|UT{cz*Zf+ivz1eUeMuObqnGgGsStI#gSX}$AF%* zI4&U`W(vx+`bN~*zIeG@UokPSKr7+0uTP#L)ih`kXUGJyeTc=ehLII7%gxvI0^9;i zGY7(haY&Bzw@fCGyP?~@m-Y#=m@u+#g9CvJB;>Kgxek-stc0wb6)KMouIkRUrFW{` zPg)$NG~S-1kSPqcozK!H@EPEQWJ9L&&_fASK!b=r#(vuw8V=gpB>r@Cs;gX5_Rti zC~D`(y0dT@U}{PkzP(C7-dFZCq$K8o3k*QfHVrrdYnoJL?P!#2`NRAOM}99=)%%w|euR-d zSGXg^fnUD-$bi8%nWD|OI~!JZ&6u>nnO3t9UgFES99G9B1Q95g*72cy!?(>~1aI|? z4?D-!9Wv{7!|+-B;?O~wkO{7au6c2A-(FOWtU5Jlb_Sk`s!FR;Z@?`_m17~WZ?9i@ z2u0pLJt*q93e)Cy=^Y;bC)6%w*76PV@i(iDjt>Qy`!SE?tjpDXmhRBS=gh%=YT{Mu z*BxzZuT>R2SXP&Jn{Ut&bMKUDj`RZOg~6SN-acV}NkJG}+QRUm{r1sjedOVuQA|gy zad)qmTP0?%Med3)n$T6$15R}M5(0ymS+@2}t(4HvMbs0KF-7Abg?rJE!V48<0j2#k zkerG`zz(@G4n)j>nL4wG>{vkwtRfhyR-A`2@b){Dixq>GhDAj%U@Lg8?SwgumS%z= zKj|Zbq8z3OFcoBG5M+rEeIA&jww=fXqXR4r6*VH-7r}+*JYWX;TmcxlmfiPTFIVD0 z;M`?MJKhOYCVW~15z2j_y;6NeSZLE!bW5p7<7_(dB$4X?=NkKcgLbqEZ0Qf$2F}|B zODZdM5Ab}M^g7rbH7d}VOxZ$0Lh8m8BgIgB-b;hq81NfRi5}6i;$?5V79+5Cf1@9v z5(iE)+Y@od!bP=!t0-v#bQB_5+TA!uk=@Kc^uu5R5GGNfn0OL)F#QOcqR`AisJaT~ zduCB8OVlt-AzajO!US#tFe>3(0De+P51mB9&{BTPr1u2HLUe*Rl1T8S4Av1jQ<=`a zw24=PN~orx9y<|yCp{Dxn^|ZNVjs?UvrXTNyXgsflvRR{%6(c6zLs?%we9W3f8s06 zVyMe9DR*aOC?*+Uay%?Yh=)Htn|Ex3awqlkMX!^!DttC}87oZ)H29o)7hxF`!YPk) zQ`x&5Pz$Hi85rHLx5U)DASr`Qh8bBKt8$2f<{(p{Fq}!iJu=*^=Ch$K-@`g;^Jozp zD@%#pGe^+x&d7SjZm9YlxAZ!@soYm_xci?@(7`PnVBjy}(sx#n!>W__y;aiVR5Lj$ zb1oH`7|6H#3q|OeFd1m~{T1nNa}SsqUNC0n0wZdm%8MXT2#>bcN;pCt5O}msVqUXY zBWU!+iNOLljD$Te5`%lsn4!H^0wRprmK!W^<`>?xvNB3?Hf_0JY3l(w@a`aiKT{cJ z_BBD8GAgog2xl9JB{)}2n!fj}AMRwui~%+EC1@KZhlRjfDOJh0 zBW)}P;SX<2dM<1f)sRUgLgls$URwKv9ZFN=7G z;p@6Ijq97;>PoJbCQxkW6nXVmMY?^8UxH5qP#B#?}F%m}iqa-ZF6PLmN zkW$WZm)}PMsX@)Pjt+OTxfXe%^uo-vh|xSh62a~HAH=aH3nUdk@lt(^a-+!#g{Xcj z8;^haJN@9WkRe=Z4sSlkljQve92UC8Ux5}8H&Sm#+=l@M=pK{mOR~r|(tq%v0e)vuYwcY@fiEW_k(MBQ3 z6<^)Jav#_$NdpP#c?50lJ_l;$E_@w4n0R*`w9h*4x#aOv_8N|=GyGT2t zoL0Y^wCv)L&yxZ& z3&@fGj`o{S1$zFDU^!z&5wy9{7bO>=V4kkz2dWO|y=Tak7tN{>*&fe&1vst;+)4vzam5Fk~>|3-w`BvjxbvY_8_~I=D*FEec))?)ZLsLdd z!YlPEmdc*FB*_LX7)Di{$8GkW{L#1y%R9O9@*q`161xeuq_%2qHO3w!pK9J5t~330uJF1R2?H&@L1_%B1!hOWoDMIl zAE<01ryvm#U~FUx_$~tE4n@d;S5`cKIdrcX+7}7ZXmG3ilIYR5ePkPEmAp+CD$+9w zXG~{}&Q6hwYt|eD=VK>4J_{g4(d=Wdl-9KTQ5taA^RoV4V$u%+ynwO`NnVMnP;}Cc zgIAVdxnW6BO~*`8MRMoZ_XF@5jk8g+qDILR|38_+ZJ0Y-f-fd?$Fvli8vmw;u2E|0 zEDWoUfox;@8t=+LmpUzh&T`wfoyWzEBiJf?(O@Q&WONa4^%PF0OwjWv6lDY9uVUJ& zU$y9^J+;pKvAo2iuOK~O{KJcKMU!jWI)kWVo*1j936Ccug<&k=HTR4o!Q}pI0XFW| z>lC?5yYeZDE-5*EpGH}*$aWL{gTvRJzSKc9>6L>w9*QrCx!z%?>AtonbB0Wbhnb*h z>2>{lDs)AE(oB0du5$(r{zMA7b^XHm6}I5h7_$2R{!zepj_=`{)~{|S^e zX>_h~tw{f`mR3BSLyA<_{4k1V8BM@h2XTf&<5WPipP4q39nifekQ^h(4B^g(z6SUm zFa5V!KMI$1Z=;0q+{6e#3dxbt$XU|mKzL>tVMJvSqP>7~w}lAC#lR9BvD}T`Mwvo@ z2+nDYc5{n^l1d@LV+Ec>O40fLl8?$i)qwC^9&537mA}9!D5~? zl|WYsMg>eW>UPs)MrlI*AGmzDR@`Ypup#=ob}B(6+aiSX|6kI?iLgyBED z+B{6L=-^>f0Giy#^Z!GWGZVulV?UfKn{w+%uY^f=AF{M|S~Fg{_!MOod>vLtz1-;z zIg1YD)cnw-7%BSY&=ox?CT>lj-P)ul@h*1RJ2tFiU(xUVhi%1-|DCtL z$epLxD-Ly!Co!oI-qqY_(C>YV8lxJ zCth}AugZ_KAGu;m>bN`vamnJc3XXC=(v(!_B4M#=SiL69FPzfkuuX)+F(7Cr48q?{ z9YvxjyIJ~+36ls7ahsyf;IH#bH)@(eR4D7GQXd@GjWEIjyBa%dYmbiJR4?05a$u}T zoDoQPgEwj*^1~duwy8ETk!`7znr2D!_~53?Olp)B!B{jN+mk6LwOx>P(G;a~vk?_> zg+};KHp7$V0)aqvz(^Ym^HXa^S-AkO;Yo>7S@VLF1M{sdB20Xb!z_#=`+*$JH|#)3 z`%ZBcR9AXQCi`~3`os8aS}_^@Yf?m{kko})RBbk04{92G3Za*-sI9Cw{+oUR8q^(& zS05+#lH;RJ4z<%VT`eQDJcgVWqs&TK4&;Hh=>2xCw)6FKvW~p6xoK-JK(e~srv^2= zM~i3f&ym3q0=;WpOG9O)Mr6wHa8uD(Vp+!n4%YJ)1&eB&PKqCOpc$?1$JdmPG0{$g z6?&GWncyd1NNX!j9EWC4oK-0YQf&IdW2avyo6j^X|3jLQLR3xbHJu7t?r-%N<0wlg zKSu9Q;>T~**l6x`i#s=J2QDK0+z%8H&sf3S61%!>Ue+XD^v!3E5!Mn==$X6(bs_v$ znjllR!dm3=P*V?T*u+BcCH1CIrkW)G@X)MN};gjo5L{lJ5v9|jZ`)Ca*nrZ?fYQxoG?gAE?SPjAx~kKJ+R7L$Z#1N8Y*v*P1}I8a8&^vaNT9Qbha* z+j*J1S9Kfuw#WGj=5)C%=;5b!aqx#aV)xSH;8NGjE4GNL47EULp86lsc9;=i=;5){ zha(w3&U)#l4yRuzl_@Fi`^smXA0DZ5j{nv@{_-pC0<`$`KcstLnSZ!_LhaPtty_rE z;vZ6j^>p}Gti<_wLpF~925Q_d6Fa4&dpv^F$(X#(`q!IL~=`>KKeAG_=u{eKEL+qzN0xMzZ%vrtxQw`Lrzn@?zb?5eg=w&u$jQY5J7dYxOXhi;BbihQfCzZauofXSAIg0 z1qVEVj;$_Re_|aa%m_EI-sP%Y9#mfFy*J*<)~|8(D( zRllI!3MPDMR#$SD?UV8-qqUK6|7L@Yno6tT8+RxKb!G;iz9j6wtCN;3s-1-z>lo?c zN|i{vuekL2mpFa2vE86eJ zZA%ISTNBE)?d2{v=~^E#KzmglD+~+*E_~r35}EDNyPO^%i)j%KQn};k4qhLPM8OJ= z3kXWwZ`NZ2+Z-uN2jHT!&1gD6T26ce_Z*_|JM)eslku%<*6K`uhcR0OqOCA~z73i}YR0@m z@}l*ky#|mf%-ngPt3`8J`dk&_pMp9{91zFb*_*05jgH*&4pdn55+(3R9D<}63U+00 z-5FFHuK>YFJ_W~E4^8DKp>A>FVdxnUVWX=Bz#E_Kx1(KugX5wf6zFL?eFs7ra~r=J z&lWEsH@sM*f5_j3g;~c!CmcB(8Txk<7lP<--p-AD$`(5itb#F7$>I_PpIwQx(c0^? z^YY8O!_eYmJAVn_o@sdt!}WuLXZPaVZoc+Cl!XWjX-xBx={z?tkP2Nbmd*`+(*Ma) zHHFEN4sQ{-ay(^oftq4OI_>D20d+t(uSH5i&=q&L9O{CSE>wgd{TEJkO{@hp!e#1W z$UV=t)s{M87(Ffqi}+Xd7f!TmU`#ri8)B3NLE>qOZ=HqHl&;S8zIC#pW`D1IkH0l+ zl$bD&$XiIpvRa>p92>HAsY@e$o#u~~m)mT#eERw4kGXcWbAcyZstthqgUD!ZiO(*a z^4q_DqTttUn*6GlHj==}n)?8i?gvm8ZiAz@-}?@C7Gy|7PC~4#f!k_lasBz+VSDKM zp)@2GIkC|L0{ZRA+FXqN)kpm2aoKK3_S>*QHXMUZJdf&GmQ)2@lVuiBG*i&=h=;I= zY8=sp7L8LjHU@y)>sD_F1}T9Pkw+ZpX`CE()3XJXFv{?Dw}-iy1tf*bhW&4XPjWS! z#en@nChxF zO?G2cRguelpSICf{c4(fAvFH?Hs!lDwRJ3`q2@17#-5~X!A#eNI01=t!8Jz~Vb<4u zWJ60!JC-(joJhMb4i1lBp(7gAmrv`^#Zg4g9gms5CC?ZcTB7F+<}-ouBQ>m-ocmss21Q7UwbKrOD1=dWPC(@;Ua}?Eq$~-n3L$lX#EZ@M|+3#%e zAJs7o*X{*jPBl7<6k%8QRb1>J&z%3{gc7&eG_0H?RpmFTh{~dPaxRZwIbLLs#%0W3#Vc!k-*m+H7HS*zup92%#cMLozopbCfM;>O&a(bmw zCXY7n#t{aGi}3=I7%20w_`CvqtEjW%P@c>R5`6L_F`|`~n&ympeXaHqS69Y8h7G=_ z%q?$j-{V1@^L^5^b1nVq%r<%JZ_}OG#dKl3YvHFDmF_imgxScFHye{Dh_f{-4lc|i z7{<}fYsi}P6;+;VpI~hHfX?a8#Yx_Gw;Om=r=?9rRZFCw`7)guvjlOF%xFIuUi>O zR0PON5H2Kt=Zy1bs)7k6B&7Waz|t8$R$_oTs?Z8oP)4e0@k3SM!Zt)PvKO=&I*Z3ctP(u-7`blcaWz*xG(*g`OCFYh0~qY({7qvsxV; zFOv}y%nra{n+xKy0tRO~qg71|pUugB$zZEElnxWsi*5Zj(%BvgSB5 zOGTySXKKU#F8)Tf?9aY1hLOsqW)Yuc>G+$_0V;AItsHl$vrpN|ZI4V9eF@P^ynm;g zP1KmT0XQB>&b2G7Fre>FbnnLuX${9a+xnne*swS+C?LO0!#BO&za`%Z82$vg*An{y3S|b7e$0yZU7Xp0gglN$DKY zRbCS<`vk+iV(QWL@;fxPPjsVFEjqH=>?SWQBB%u>cpqb5CRcmxEe{OV9P(vgkySSf z;R&P};pPpFyXGx=eJV87OZ}c*X6hz1Aa*63WAFX?n}F1~CNX`XML}i6Mg01=?RMn= zRxA5XJ&u?|_Ec-R=G&gPW`n;HYF^sQekB<<%3iiB6ufD+71r5uPVK>WQ}*ROzocD^VuQBT$LU3o_D3pT+V{~-x>wJ&`tor^)jSy%r4NBtJA zI^jP3Rw?M#tM{j1)=TMMnD*1}yEi*3IV^fHdyo0GtwS?$<~B(hAA23=@3Yp$<-BxZl=ITv z+a?K5KB>pgN$m5}`au>ppJkJb20`Bs**sqg}{e7wM%}N-+>?o-|Fc8TzyL zK=YJ)8K1o{Kid>air4LHF|C!XnKW0IUS3`h$`{@COMnM?LoUqdwyPluCv)Y=#D3F` zdkA9T{%ZiNxgCA7UJ==1d57@Vmj(0mHS+uP{qXSOK(oC)Fp{mVL8$o5oLdYzXb>MC zsuo&_7u4yV@v~qs$xtd&r;m0OX{|&Z(lvTdT(NVjE}ICw@5QH2dvr4ML@E*D`Z-^t zv&OOTVEDFzL$h|KyC+rtZ6C#Zroi$Wiy3XPrM|p+@)}omfd|}%V8|aZK#s*^8;6%H zQv4{?&{TG6|CV!Q5kFz$%OOCG#y2a7Us1l6@<+|VPaR`_=MUNk=hs~+V6)R8b=wNd z2a&9WQP^&NpwWwz4NOOBph@i8mu`JMK^U>Covl`(AIhnZ9>4RW5_Kw~rEy@w_BLFC zhv#)jhj-PFFUs5{ZtFZtmnSTPCz~^GN*Y~-!T7VYv)s%DWHw}LNJRU~Ff&E_+V&)^ z;sJDu_9->=k4R08HfpMX>~r}MMkRaM7m`(1)-+8HA#rQLw9yjTz+pvFwmg=3;XoyW zx;=~q5bpc5wb6(et;5dXc?-%PMH6J{Liy#{`kn(C3}j!83oFKmLdL=pvxd4J&}y98 z=L`(Pytx2&?v-$n6$BFxkAUzyMxYkgi~v^*2mKcESVe;fq-e82=bhn7`K?LjD*667 z&?UB<>H^gR^R`MDnO+3!Bsi-hD~aDjupMZCglcZC zlbgBCIXHQgOwT8}w)hKJlrIlGw*0DNy)Nq60f1P*Z#j*9#o$yAkgp~7+_i=EL z6){I4uV%#>Td{fib|!PX#$l9dYrFeT_0j?NVN^^El6n9eo`;aw(4xauV;BSKFGUQN z@%?~hCDh_wCX~4VC-s04JGH=oQ#lZ!sFKK*TH*F2&;dxAV6gWecR&voMe>WF;M_f4 z0T?DRv#>0|0bB(NfK1c^3257^aSiF$P0i2TGrDE)lS zW#CIPGP&3fK;-=&H4b>KEcgzU%gDZtTH19J6BB!zZS~w}hQ*(-M@x$rTp+|5kC{h~ z1@a6I2P+X`^-2qXnwVz3^3SjIjQiBD{5;H>DEBj^5sc;kf~>YIC@6)K>aOrJM!llw zWxZZeg3$JC*HT0$#|PSk8Y+fb+qY(G<()%7P;@6OW=YRGeerUnemjuDG6PLWA(HPU zZ?q*6Xp2;E@7~Vjwwxz*`hM7Utzw>7YENKi>h0@b$ln$((4N(_%Cx@KvaEekWH#9V zC0OB=kIf|m7q??{-}V%}|-oxhxK5;Z8)_X<}X;Tia?r7WeJhur-3=}}RKVz`$lLZ9B=PH(Fv)Scw>?!^uSnm2)_yQ;C& zC;+aaDN~v7dPEokRguNh5m143ttHvA@LqJn;2E#Ptl++J z6s#jf2IwgHRzTq%5RKjK8_I>nswgmF4Q8t=)4z07U}8DbndAYS$6;mRW6C&MHcGgh z=cUeC5M?ARF|!WZSD9-ACn3j_r}uZg)H0gn!zjP0$Zs_iKex^C=V1z)!=K$I7&he{ISWcp(NUOt%46p(aXK^-g`ZxK5OCQg^XAlCcS z4?;5tP_)|Nu?;LPwyD&wiKpZ)(^ow3Twv=BpRc}`%it@w>D*@hQ=a%Ky`MjdO2X~x z1=1*{gkFqzFGv%|Y~V!MT5Z98>G!Nxs5FfWIOw10&|5-LInq2i-j^G=En;AaCy;C- zxzuSf6s(5@B=7QhR||>=cVleAF=SYE>7ZnP29v0w_&{9LH&4nHlHH* z)Fn=&+|w5Zo&F&$qm~BG%m+s9DH^PCt=1gFfW@b?=5W!7F`X;`|M2Wr0(#M+%tjVy z7&s)^Gj;J9Ihl>+#e;6O&GR)2fg4%q@+Z}Lfr0{Ut3rw{rFs>Ip20RLI||;K2~)6U z6$A78qglkpqOFk;ZaIL}GAwx4aRQZ_pmg;cwN5%0(k2Q>8p&~I1#!8-ZWPfYa zl07jP`>S_v?r>>6rvb|hfD{R7IxYLwH@AIX$f@MFEH1lmc!~JE% zjGN7MV*9DqQktCJ$wUruzU-Fo%MV*WXMfkgRfH8ncj7UvuCV#_Ypb9v!c;Ho2p5J8 zxGv~}can~3SgylopP2b#aT<7uQ}R0CCEmWuhGd<|Ufp<~X8GJf9gA`wxEm&GakOy7 zwe_|LBU zD2`OYp=u*}0N!$C(m|s%+h2Qy*pDH7tDSQ|rNAp{#9GPHD@?)JwTi;OT7P99EiMXf zHf7c;v|p`wH!+Yv)0lTY+=X$x8nh8qZflGjw|DLvz>E~d{PhugE`LcXD;l1Fo!$kW zc&sg4=!myE;>YNN6%QSM@jke(F9FvKw?SYD^6N(*k0Q$-f;%M7zf1u6p%T%CCtXJe zyyZ_ZB^#;uu}1Hs7Kjk6*ly_J3PUUJ5K|cWM>B_fJb|VV*}jwAE6p4eZR`9+gNmJS z*Lo6C>bHGUWjA4_e5|Cr=lQ$@Ai>$Je@IG3k!K~db#KYSJvj>+LYfw)?O0 zNT@VLo3Y#I59fYFW67P3??w7Kn2^w8XXAL{-vTQ{S=YAQRo6p|ndG@_<-a<@ag0Nr zYbM8|lKcEIG80r(JCq_dP|sU#TR`zWiI>^hfhrR9Rj&sa?}Y=o2y#-c2`lil*j=~( zT!fCTM$P}97qDR7{wgYY0mqs1+)n*Y2Y0egviz8-DrsD>-YZOnD!?@UJb7Q%z~By? z;F&b{!io+mAMMP>OGVUFZzmxDvJ(o_r(LcUXOQoAsBQZ$Ta{93sXv3?!38#4UL*Wz z`7tlHAly-C%`>5Sb=dRjm;Ibe+F|@R!XBL!uf#!y@nX?&f!DUm)-NNvi%|;@Fa1lJ zVD>y);U4?h&`J{_V)33{uK|#HFGdjtR+SQVSnzIt9`2F1T5$a*lS=FSN7qg2 zrn6e0|uRnT$Ltne51X)6!i*;wziv8y&VAFQKx{>5Nf_mK{`BAXXRGF zWZYqhuE%9?7{*U-L;})9NiAve?P>Ha+$?XZTkslm+$87f-->@I;a2p(kE4fqRBdPZs>yT}2_h0HFpX*DBNb~Idf4U5Vk%ndn zoJz~ZEFpxKnVwmM3PB^kMD*;U= z4OVaZNsFj_)wwP2Ylmhvy!+I@i!h8>W2YFM*X~sE?j`lB|Gml1TcV1gvJ>v{T9mfZ zdqr(p(xBom0plKqa|>NL8a$?7W+Mh{7eXZlM4? zDNz8ss|6h^NKWC}Ed=tMEF^IRfE4_}owkd)yU3vE&hyI2WIuB|xF6G#5h`${z!Zrp z&^vHub$}Q+W2R8Y)pJx74ky#%e;wAw32%J)yak3e@oX=>5}L)0bTT;>8LTwB)CJeY ze@8L=4s%qd!=Y@Wv57^Cm{4*YP%mY_xzlSsyGz{kSQL z3S-SDb;Z>zHk3LB`KL6~bLTGFglhI|AM(PC{B~P(v4sm~+lww>0kQO1WBhGisY;O} zJ8h06vtr4ktc)Z>qV)o>k3f?XuNr8ziK!t990UL_>OD4!X)beG8G@7q#Em!_Xpn@GJL=1>Cnk zIp}N`-7*lceQwIrDl4&TojVP~`cvAvr0ux^_ITltX(b6Vn3Gx$Y|*!k<0c?d|3fp}cMg zCu6bAWgTielRMJS{e%j1b$nLcKE?dr0XDMc5gS`-FiZuWtsBN!nTkp-{J%OnStWTN zDz4RL6$A-L=hpOU%Z9!n77-&uPpLG zi>OeorA|PdANT_l=Aroawas*mQkxDO7bWCMc<}FhR)gksL2J6pBGYHpY2j{6n29#U zLCgr@`Q*kbDtQN~Ogcq`O*7KF#C{Ri*V_@R<+eH*WLVn~+hyI%6l9q%R0!jE_?rYk z{jDq_y{NBF<&rT`gF^!k@Q{AUDr9?D6}(lt7yy}-cFbJzI!8Mu42f=Sw7BP-1X&;Z zaevAV=;u!?JbsT8^(~Hmx74v$BHfVEr)(C0PRUrPdrkfwg_?6^6lR#@x5(-_CoJ{+ zINL;{Ee?F=hME7-MoedE0cn8;9gc(+UooEI&ZNuZt-g|mtLCk&)|)+d`%7mFt)~&LuSAs zfEkcgfeHa<;&9b*VJ-o@D7j2RwZM>DjRJ&3Y1}S728&7v)^Gq6a67Yl`Y^&3MI=wL z<{zl0y}tV9TO*6>oqf

H{m=WDIZOl_>(6%%XVo$hNNeQffo+dWWFVeQz6LAkS6h1j|vYOs9Q1?TBgG1!4 z&kF|f8|~*0y#icIz+R*#iH&|M=gMfS)bt&74t1I+NoCfDHn#I`@>{3J6@|V~fh*Y^ zeiMfawz-%mffB`E7VV9X&S{>uzF&14hO&kmK+#23P<*_*7e-!qY0XOUEkeKxmfn$CX8zTW%)yMO&C4s zRRsNa{ha_76Jw$@Eu$PXgvYW}3?H~lf4qE$hwCM#T{i@P#kmo3NJ*q$j#IK>N z(7G6g{yU^fAEw$Wb>Fq}4t%RZ0f9Jsh3&PCn%IPIjh%rrD`M=c?A3g7(k+5dZH7N~ z2Db!Quy;~Z9{IT*BzdaG8bjl3=v!9xc~uTHKeRA<$1*;07SZ1_Fz`Cx`7G@IU*8YV z{6nH{f(f*Y(*CDDiA7QX)C0)@r`%CN;P)>i%0Mc#ECjy`wHg7IW>N^RKyiL|FfBpw z?6^R2-V56Z zi#tMj=kQ^9T46MGj5?afSf}gl-E^;NekwZpi(XTlFrJLj{1+a@!0t>KGyX9-%RvGx zj;9!cEL3T?|2&CMa!_;v&bMJKGRMFLly?m{r2>04jyjX{B?v2F8GO!PN=^d@G&U*R zj>FKw#$thJ8d$8ZPWA=5Mv&~!7{zKENl;dc?0XP>TFg@ZxpNyX$Yv%(=>EM`4DIw< zO-4&pT3=A1{ZhZ5YnCt5H0~p_w{}R_|9TSuZ(j@i(CZbnR>$ zlX0EQja_m6p(ACJNpPikS?3fK&82VH@kVW@d4z;d9srtrV4LPn_l~xawy0yKcidH1 z574BtrreD;W$m)sIjn zgMb{BQp~6IbE$K$0$eFLaLK%Wu41}&CM~_zU>E$+jg>PLRwlq@)_o$E+fSD2Xx1Zj zC^Ei@`ZezZG6&vUvLpGRrOap=yF)p2!dkV^+guSWIiQFFDcA%Pt-{!$q~-gJ!b$7X z0?1B>!#%uIG*alj-|~@tWV9kr69M@rPw(}yWGT6a5XmnYSAwsoT?-Pm>andDL<+Z2 zIJVGbw_cEg6Iua-({Y#7E_9QMdDC+pG|z8XoC|GX>(hpPF|)Cy8(SYP);yY-Y=~ZkX4=l5l9R# zSQwc`E*Of5DG_4Nv)g|;j7=E8Ql=Mm;e;0~qZ*h@b0HUkB`On4mt5Ki&zyG|Ks;Fo zmcm|nheDpc&@b4^c}_(HMn}|kNN*=={msKjJvN@5;JCLQ6cu$*rUXX6|>ZA6|GPjd!$t( zY7`}AQrD+`3W@CN!HZ}9pBH%+C(KvWS;Y~Ga>8th3MnTLFi}cmROR_O#(b|Cc z8|AV|qu)``ML@T_GJMQ?Gw zMtEI2?qE*3jL^?^57&Xwl)i)Wmx8Y{;|o?YGB` z)jf7;gGC;3`bkggFIwCbZR9u2kL`Zy(2t9&#~73K-$uc|40V6*jvKDVn(`OSpFkMb zX)Ko>#N@7avt@a?e_80)`i)0#F3MwM8zMWgw^cqS6u#Kk+LwP!DBAYuXivYNSdvFr z;VULe4w(X@4<1lyx*ku+fP#ogk4I9vR(v9W3C@NDXs-rlbaGHq_>hQ0{7B!+6eEqZ-fZ}MIFp*OkxmZgwiSzul9 zf|aXs+u6VR#+{dBjnh{O3zkZxPVJQYx43=g<97Qz0ULAZRT*6M_dm1ngHlo2O3R{k zwtI_Uvs}3leN@xSxQ!yzAZ_{Ap6V@I00Aq3>V^HX<|$_ z9|UE2G5A7my!M4tR<&F#t(gc^$`t+^(g|E%LVaw2N{>%Qj}+l0DSdIk63t-P|$s+=j_?1Um2 zTNE>*$F|3Uz#)cM#0)sVq_$M@A-s&oCg5T!giDolWhRpSJXvrC`n)H#b_pslkeY)D zF1QBRs&d=H>(tafqAXgAEhXXwGf2dHp5&501D867O+2w)#nCVOduhk5OI`O^31N5A z!qO05C6PZQWud(Z}+NgdH+)nV359iz1~N4OEYrzyJ~trhUNUV4=$gkb*w6z-c@2(&a-rIi_k)@zd9kv zb2U|dp)nBQ?K2}h-jk@t`ba&pFFd$%Q_jD3q^fNp*3J7i@6ns}^~8Uw`L%BcKciv? zA-zRgBbnQtG8fNHB=-YCsmYS<@wu4dfto$L$FX-+-zYT?j|*UP*aRba6P$qX|7!YW zXzz)4!Y4kBq+X+4Q0#M9Ec-9rDu3pYRAC6h*u^WJ0T zg#cNZ%F$gWJqiNDf(k(8V+QWykQ8@8Sc3QfE7#PvD#h~WAjDzGd~k@F4WgWd#9c-* z=c6roqO(&`+}KKRtnu;$frSAwC`-fH7?}r!d8;lE=5oy(+jX8Rm!>^EtsHo7W*0bu z&iP-dy!XY)ljHNZnQ^cEbydH%>{=GhbX-lMi+wXB1e+5hp*U0b|$%i9xo5IA{x z?M2ACTF3;cefRKAN&0hl&sCzOTMg&-TJ+0(k&0*7>rs!4@8K=662wTb@zC6X$tF^* zq*#h~Dxtbb<5Q-_5!|rGG@jmkPpN^v%1Xx-{qf z@o~KpN0f8XKOTrt;-?-nx0TWN&}l1VS%)4^l8}kv-Ww!yw-_inLZMzr1j`zW6|_{X zs?H%y%QW~ZTqO>zOp%tey+Z>N2T|Uv?7k6IS_+L&LWEc^$|v#7Mnf<}CA$c~rIbKf z56U~(Ih2>p?vmn`HTzu*mHA0x|7QyEb>@hPDpBBz z9%@KfgnD^#*CMHS9dNB7I|}nUyo^-AUL5TiFVOOZ+u0&$6J1nVosN^{y-gg|U`%x) z<}SmNZ?bCOVN5JH+_Du(V?k7q5~zJdqY-UahJ>kNJszen z2{F}(06Vps<3yGr%$^ft6A*eL0JlM?AeB3&Ad*O-^!n>Di6^D|h)hCg&wbNqup#PjHnDQF3(`y?^~#0jx)rzs0r9b5GvHwp{w%-gCwg z%kgAPQ(7f+)=hWD_RQ@Y{9%YtV~O%o%>fBc;epn~@fjo9vsV?}6^5Ih7rBGBCQq+R zaMawL$$!o%9LEAg0fjqCyen(V`!i&nljc*~`2RS^+HJi4u_ZMCSrdMjM&ny^xhbk# z(DQpl8RyqqEL?O&T>FAv+;$T%))@}h7q~N?V|sd4-_Oyc?QBPC*j+pQq}uDsi0P^; z27ky?fu)IohkpdXEH?uP1T7hE z29!ftu~@JrPu?9HFct)e0sCwMoY9ji9%5q#j!Q#F6N)fs8XUYEC^dp8nhLT^_4hWB zJh_AkG!R6NaW9dIZ#dfhgp$3rS<(?zVqv{baJ$8dYyAVQ@*(Mz#L{lDxJSasd_M6i@z^HT;AFd@tJC`p1YoxS#b9CUEwpl z-NgX2z8rYmYS9#Vsz);SavJbIQhk2yVA<7az{$Nb>St_w$86N@lS9w1TXqZIm93jc z26U0eL{g08kWaDMd5A4%Amh?0t^B!hH!J&X`J|w^$XQ9J(xdo336aK-WrlBFZ~tZ; za%f%d4#*XmTV2p@%K0lQvB32~|0DH(mA)l#`Sv;0a#1@!=+3u@hEnz1jyzdIA zdtUe6&nu6`G8Acynjz{Xc$}>nD{l=fioCSy6FSWl(nJX~qhq84s!`mqZZ5gs7&;U% zXzu1MbOD?3kpf@k1!I&Ftjq}9wylILIYO)-g{3vaNME7MJ3~5Q+AQL!;qOH z#LfqjmRC}He?fX)7>u3u&!$YKEelo}eXqg{gm0~qMUe(qhlx9(p7X=eXU%odKeO@ zEu^!!G6M@*yq;0TSxtr?94KvF{~?F`p<^!OwffKe(SfX&4VNujXZlF|==o^M3YIWk z|4foGpz<25yUJehMoO+(OrD5_>VcMersTkFhHZO04LU?2XYPCH(eXaUQ$DyD95pWN z0q1)2(_g$rC zRSASIFc~3RMp+gh#bO{Vt7J1RYt`|wgVZxS%obWkpGT9XOSR)km;&%x$3)A-2wR>I z&W%SGPp}p<^UzP_+ig@DS~8qqgxV2US=>Sm^Tg~_^64R{Hf;c%H}cpk(MuNg%HG;f za{;8fkAlpF6CCO*3FS%5bO7Lpf)$EjPiujk1;AvcaS9*(QZ^1`pAeK7iqFx1ZX?(D z0-`Y_a9i?3UK8(+2lLC?^-`0p^Nph;4>LATBY)P{UH@5oD*Cn1j~ljBN0OuesTOnn zs^RdQ$Bc!JRy(iuR`i|=dITm%c#d@k=HJ*QJuF{8&|maz5c7)->9weqkBaTfd zA=odE^e}UZ`i}G7Jt7bO_x%3go->6bf@A1je&p}Yx=Y!9%_d(qa#l~KzGLVv;IOYj zwxR1EFIHDZhHkXY`n=$^Wu&zf-r2ko@%alx_B@>YJm)>y)uMq?buv^vysY47SMflf z0qcjB05+xaO8$1MV*cz0vC>IdtJ+Dco$Hw;9nZSK;A4(21jT>6{J(P#_)&UPXrY1s zm zG0SbYT~2Q*6yT$qWdh{4MKM?8)UertN{E{cqM4E|uQBoTae$yN5F)BdEk34W1r1-lwStRa$$rdgydFAJC|4pevHMEFnsf4c)yn$K1H9Q}MUDZkpbFN)Jy&GzZTc63us8g|1f9`+TA4w^)2WDo|+63*!f16|BAu`v= zmvxpTYS+GcR%ti48fda04}iT)jiH`O9utHiD#jw7)v?t76bfb+Z&UhE?t=CBQ`HJe z;Pe7^XMb-fbnV9l?Jm>$z%BXpP3b6l;76j){CaFpvFF>hVYu-9Dg4mKN=6wyoqPy} zhesue>UiJ(x6xc=B=wpGJ;$(JPLgOCzmz!(+FUbIC1hv7RnDR<=+P#nz@2HDCA~i1 z*mhDzB5DqO3;W^m+vzj6E%(hjr%4a|JF1u3Btvp$#RX6I%0(IY6VM(Jiw`)=g1t(QWN6-?FCNHB{ZJiZGIX z4y696cHqsRbIdUmvZWV;_TZef*sJOD;-$0Dbfw2=UCeAJ1MNZ3Yq87mg<#M;E3mSV zjpA%hXpd}JMZD!i+Oiy7h|uLjAja{Q z*^($>Z8#&5+W-|7BUMdsg8^h0MmZ%BozO5?o_IdM5(Zdam{}GImH}XALdm|=f@f{i zadZ%^I+tiDBpq*GK$+S!z0&b6tkZg?c5}w-hiKVJV%y(V%CzRM;4tFQ6`uyG=#j8%` zLtq(Mfmf!(yJzL*J^edvEp%fHqzfK^Y2ftf?bVWmnZyrc7t!@4QtMKW%7Q$666&hy ziJS*Z<}1lzSPHf(;u-^h^ zs+Iiq=lUjiPp<>c&@$-A$(2pEjY;vzSk?*JZOrHz^#k|53np*wO7;{-pkkqhxr6yB z?4LBFU6#EYYMtxJugCo490;`nHxnSJ<_0D1EG;t!u%d{Tm%2_x`qL0hm9D-3n3;NbiL7k;tbFz zMGt#!yv}GGG=ri`nVR*jAg)DHZWBrHLaKa{8-GNr;xjBoVr;(Qbw}X+(8Za>L)9}& zw=Twi^SVCS{lPIX_Fdz}T?fo5i!AQXug|RbFYCoL7X93{ULb>hE8C{Dz5Uo+E4xx! zv0E^&=A8ciOi>NjR(A|P;?sDmg%*^WkmkvK-mnF1y!Rrl;Z&)br z(5m{yv0CzR3Nj;+i-AX3=~`}ZM7QZEcE=IX=1es9DLS&PI-{xiq&7AArmqCm zW<4ZDrVJp0mjV;^yskBMeh6++Z0E(qjuS1FVZ>5N`D&ZyboLN>?wa)tug`}lb%5GJ7Hn6g*L z0Yg=!9GT3_!;5xBf3{Hsl?h7P0=b)u;V@vPTF9}|tB{YYy zoVc=R#@f7a^A$zbdp=TwLp3TkeDyPD#uFiDD@hYa1A}Wcz&AeKbY!#9S z{w8x=&BHkFsAgLMQPV&;Q{C-N8_&-jGr5$MgB|xWQ9qP9V>VhM#}j|^*Z!*9n`ciu z22|I;r=Kubl@$pUj}EgH=cUs5mZ4ZTJe5`LlK2cQ474LO2aXi+l|R|X-#?MgC7&t$ z;lbitvTC76m4Q|kJTykWQ^t1@Nnn+i4S|P+ml`-dJ?i#pqjMqPVO3SNH_^2+I#6)* z4KYR;vGZc{q-XsHjlxm>v!k=o1NKfIF1wA+=**sp(u`Qfxwu;v2EEu+7GbPI7Q79( zr)c*k!o_Ug*PQbByY}KEnoe`{UFBzEHWJmP=2S$M5`HQ4BQJR2(r=@J!m;Ch&WXV| z%c&pI33GXzJqe5~mvL7`Ek*)xklVWqDk!3V$*uaxsIZeZvQea*U(D#1XMBB4pY^y# zaCDz7SpuTwo;_-QROy^M<;hXu(yq%t`_mu4`1jP+xb1ff`ftC=ea{@pd1$7k-6+Hf zyeJReJdUcj^}B`k3~AAPZ73%a(SnTk5AebO_k+y;%&xqOQZ|S?Q|x~4&$HhQ(X1z0 zJX=5AtZ`)Y=?quj3HIxlSjlbaV9ZUyhgSFCJQSLbK#ks{#V!_#VWcq7IrbO|VHj(~ z=!T-9EOf<7Xnrkrruc|zf*s&j%#kZp7$+b%40-e8F^u#eLKv1wYm!WsVRH6)^mF-+ z&D97-iX5FZnO;uhJ?~Qox_r)|Vv=?>AW3N1Ofw31O76obUp% zA&miNW66!RBS@%7p5aR~l0BvUuLC4ud_v|OBFri|(!nRa{&2qa?tfGR|IlNB9y84a znYdA^GB9<+R;)_r^_8$)9_P~6zNSQFfK@=jir#g>=XYU~rb zUB<-CwSoLa9|ckdRd)c9OKfIbAT7PBf4>n4l#vtV3}tYc9up?fSL#3lYV}fF)^G=^ zY2_i>Ys4a!l*o#sDs^?os1W^~TeVjIU5lL-~_XXFs3$Wnir&_MMOcSnq=^%# z4US_m1A&*YBxIwM2urR4-*Vs~NkFkcqNS?+kY7zfAX0?rWoK7NmCFjEGrLEc3Z`(I_ZmlB&@pK zuO!|{5x7qn0Snx(ozskw&(c&P&Mvy^3}4*5$zc|7y@R%nyUDg>tLuEdR&ILDvjuTkz9P^4BK&zkOX zrZsu-PsQU$8qpq}A;Z8|qLvmR)quo;>SB}Z4Y?ecJGhNTC5b|}(Zyg)0xS40Kvb1^ z+sK0&g8)K;xpb*To!zT3l~OYv?!3R{pbp{A&nAp5y>eF5bwu|9&IN6hnjw|l%r~Y2 zN)4tr49%SpRoQ@b<|9M`N?IH&4keif47nfwlo#E!5sOrKQcI33>j(FshRiQxMQT)c z^DZ^quhq1a;1)s~xw3(M$%*}wt=w2re6ozSBRXD z%OnNNe){l&i4fU(U;y`PeC{-Lww%u7Er#P99x%^US`C~A^C_|An>-9Lk}O?@DQr1) z--D7&X0N^o&`pxXiV43vslRCL6Vo+Nb7Ei)Gv2!NhQhoOTWob+9#jH8xXl1z-E)2w zl!5p?y_4zf$*n;)t;6$;bBPt)0|jGaX6V}`C7B|=-#hmW3&)0*WjI}qg zp}#zUSNe~Q9@i`RB#WEk77LYA8p}GFo-!+|8@GJEc!ch|jyp;&>5er9<$}6UQl+8U zW4S)#>!(HH(yI2|^i3tVW6}t*o(HEhSIdU8+1Kbg{Db}TfDZb{{0MG?I$#@3P&_8g z(_sWzKoT9(ZVXt^!2rd8kw^_n1-p?n0 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 f54d05c25433e386913e56a6d99b6364a944aa30 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 8 Oct 2026 13:43:48 +0200 Subject: [PATCH 04/28] 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 05/28] 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 06/28] 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 07/28] 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 ffbfb8d31be120939b6e1283930b471eaf0078ce Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Thu, 8 Oct 2026 14:13:12 +0200 Subject: [PATCH 08/28] 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 09/28] 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 10/28] 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 11/28] 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 cffb3570ce129bb5d7d3788632e2774036e21a76 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Mon, 12 Oct 2026 12:58:23 +0200 Subject: [PATCH 12/28] 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 e1dda73810c5a1cb3d90513a4680bd408ef9fd0f Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 12:33:11 +0200 Subject: [PATCH 13/28] 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 14/28] 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 15/28] 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 16/28] 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 17/28] 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 18/28] 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 4305eba923e425052552baae5af3cabebb2486a6 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Fri, 23 Oct 2026 16:24:06 +0200 Subject: [PATCH 19/28] 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 20/28] 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 21/28] 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 21bc31bf6d2a9f85d96779e90aa23471ac59af6e Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:31:25 +0100 Subject: [PATCH 22/28] 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 23/28] 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 24/28] 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 16fd64057c8dfbb95f245a5af7eeb2fd47c7e142 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:42:05 +0100 Subject: [PATCH 25/28] 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 e07445aeaaaf47b3f1dfc990460fc0809f7f9f94 Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Sun, 25 Oct 2026 19:44:23 +0100 Subject: [PATCH 26/28] 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 27/28] 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 3fbb1d4515f0d551738d6e56bb13d3c748fd8aaf Mon Sep 17 00:00:00 2001 From: Maximilian Krause Date: Mon, 26 Oct 2026 06:26:30 +0100 Subject: [PATCH 28/28] 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.