Skip to content
14 changes: 14 additions & 0 deletions isaaclab_arena/environments/arena_environment_cfg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: Apache-2.0

"""Base type for Arena environment configurations."""


from dataclasses import dataclass


@dataclass
class ArenaEnvironmentCfg:
"""Mark a typed Arena environment configuration."""
Comment thread
cvolkcvolk marked this conversation as resolved.
105 changes: 105 additions & 0 deletions isaaclab_arena/tests/test_environment_cfgs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: Apache-2.0

"""Verify the compatibility boundary between legacy CLI arguments and typed configs.

Each registered environment still accepts an ``argparse.Namespace`` through
``get_env()``. These tests replace ``build()`` so they can inspect the configuration
created at that boundary without starting Isaac Sim. Environment construction itself
is covered by the all-environments smoke test.
"""

import argparse
from dataclasses import fields, is_dataclass
from typing import get_args, get_origin

from isaaclab_arena.assets.registries import EnvironmentRegistry
from isaaclab_arena.environments.arena_environment_cfg import ArenaEnvironmentCfg
from isaaclab_arena_environments.cli import ensure_environments_registered
from isaaclab_arena_environments.example_environment_base import ExampleEnvironmentBase
from isaaclab_arena_environments.pick_and_place_maple_table_environment import PickAndPlaceMapleTableEnvironment


def _environment_cfg_type(environment_type: type[ExampleEnvironmentBase]) -> type[ArenaEnvironmentCfg]:
"""Return the concrete configuration type declared by an environment factory."""
for base in environment_type.__orig_bases__:
if get_origin(base) is ExampleEnvironmentBase:
(cfg_type,) = get_args(base)
return cfg_type
raise AssertionError(f"{environment_type.__name__} does not declare a typed environment configuration")


def _parse_legacy_arguments(
environment_type: type[ExampleEnvironmentBase],
environment_args: list[str] | None = None,
) -> argparse.Namespace:
"""Parse one environment's legacy options with the shared CLI defaults it consumes."""
parser = argparse.ArgumentParser(exit_on_error=False)
environment_type.add_cli_args(parser)
legacy_arguments = parser.parse_args([] if environment_args is None else environment_args)

# These options belong to the shared Arena/Isaac Lab parser rather than an
# environment subparser, but some compatibility adapters consume them.
legacy_arguments.enable_cameras = False
legacy_arguments.mimic = False
legacy_arguments.num_envs = 1
return legacy_arguments


def _capture_typed_cfg(
monkeypatch,
environment_type: type[ExampleEnvironmentBase],
legacy_arguments: argparse.Namespace,
) -> ArenaEnvironmentCfg:
"""Return the config passed from the legacy ``get_env()`` adapter to ``build()``."""
captured = {}
expected_environment = object()

def fake_build(self, cfg):
captured["cfg"] = cfg
return expected_environment

monkeypatch.setattr(environment_type, "build", fake_build)
factory = object.__new__(environment_type)

environment = factory.get_env(legacy_arguments)

assert environment is expected_environment
return captured["cfg"]


def test_every_registered_legacy_adapter_translates_cli_defaults_to_its_typed_cfg(monkeypatch):
"""Check every ``get_env(args_cli)`` adapter at its legacy default values."""
ensure_environments_registered()

for environment_name in sorted(EnvironmentRegistry().get_all_keys()):
environment_type = EnvironmentRegistry().get_component_by_name(environment_name)
cfg_type = _environment_cfg_type(environment_type)
assert is_dataclass(cfg_type), f"{cfg_type.__name__} must be a dataclass"

legacy_arguments = _parse_legacy_arguments(environment_type)
cfg = _capture_typed_cfg(monkeypatch, environment_type, legacy_arguments)

assert type(cfg) is cfg_type
assert isinstance(cfg, ArenaEnvironmentCfg)
expected_cfg = cfg_type(**{
cfg_field.name: getattr(legacy_arguments, cfg_field.name)
for cfg_field in fields(cfg)
if hasattr(legacy_arguments, cfg_field.name)
})
assert cfg == expected_cfg, f"{environment_name} did not retain its legacy defaults"


def test_maple_teleop_device_remains_a_cli_only_option(monkeypatch):
"""Keep Maple's teleop selection out of the environment construction config."""
legacy_arguments = _parse_legacy_arguments(
PickAndPlaceMapleTableEnvironment,
["--teleop_device", "spacemouse"],
)

cfg = _capture_typed_cfg(monkeypatch, PickAndPlaceMapleTableEnvironment, legacy_arguments)

assert legacy_arguments.teleop_device == "spacemouse"
assert "teleop_device" not in {cfg_field.name for cfg_field in fields(cfg)}
38 changes: 32 additions & 6 deletions isaaclab_arena_environments/cube_goal_pose_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,41 +6,67 @@
from __future__ import annotations

import argparse
from dataclasses import dataclass
from typing import TYPE_CHECKING

from isaaclab_arena.assets.register import register_environment
from isaaclab_arena.environments.arena_environment_cfg import ArenaEnvironmentCfg
from isaaclab_arena_environments.example_environment_base import ExampleEnvironmentBase

if TYPE_CHECKING:
from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment


@dataclass
class CubeGoalPoseEnvironmentCfg(ArenaEnvironmentCfg):
"""Configure the cube goal-pose environment."""

enable_cameras: bool = False
object: str = "dex_cube"
background: str = "table"
embodiment: str = "franka_ik"
teleop_device: str | None = None


@register_environment
class CubeGoalPoseEnvironment(ExampleEnvironmentBase):
class CubeGoalPoseEnvironment(ExampleEnvironmentBase[CubeGoalPoseEnvironmentCfg]):
"""
A environment for achieving the goal pose of a cube.
"""

name = "cube_goal_pose"

def get_env(self, args_cli: argparse.Namespace) -> IsaacLabArenaEnvironment:
"""Translate the legacy CLI namespace and build the environment."""
return self.build(
CubeGoalPoseEnvironmentCfg(
enable_cameras=args_cli.enable_cameras,
object=args_cli.object,
background=args_cli.background,
embodiment=args_cli.embodiment,
teleop_device=args_cli.teleop_device,
)
)

def build(self, cfg: CubeGoalPoseEnvironmentCfg) -> IsaacLabArenaEnvironment:
"""Build the environment from its typed configuration."""

from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment
from isaaclab_arena.scene.scene import Scene
from isaaclab_arena.tasks.goal_pose_task import GoalPoseTask
from isaaclab_arena.utils.pose import Pose

# Add the asset registry from the arena migration package
background = self.asset_registry.get_asset_by_name(args_cli.background)()
background = self.asset_registry.get_asset_by_name(cfg.background)()
light = self.asset_registry.get_asset_by_name("light")()
object = self.asset_registry.get_asset_by_name(args_cli.object)()
object = self.asset_registry.get_asset_by_name(cfg.object)()
object.set_initial_pose(
Pose(
position_xyz=(0.1, 0.0, 0.2),
rotation_xyzw=(0.0, 0.0, 0.0, 1.0),
)
)
embodiment = self.asset_registry.get_asset_by_name(args_cli.embodiment)(enable_cameras=args_cli.enable_cameras)
embodiment = self.asset_registry.get_asset_by_name(cfg.embodiment)(enable_cameras=cfg.enable_cameras)
embodiment.set_initial_pose(
Pose(
position_xyz=(-0.4, 0.0, 0.0),
Expand All @@ -52,8 +78,8 @@ def get_env(self, args_cli: argparse.Namespace) -> IsaacLabArenaEnvironment:
initial_joint_pose=[0.0444, -0.1894, -0.1107, -2.5148, 0.0044, 2.3775, 0.6952, 0.0400, 0.0400]
)

if args_cli.teleop_device is not None:
teleop_device = self.device_registry.get_device_by_name(args_cli.teleop_device)()
if cfg.teleop_device is not None:
teleop_device = self.device_registry.get_device_by_name(cfg.teleop_device)()
# increase sensitivity for teleop device
teleop_device.pos_sensitivity = 0.25
teleop_device.rot_sensitivity = 0.5
Expand Down
14 changes: 13 additions & 1 deletion isaaclab_arena_environments/dexsuite_lift_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@
from __future__ import annotations

import argparse
from dataclasses import dataclass
from typing import TYPE_CHECKING

from isaaclab_arena.assets.register import register_environment
from isaaclab_arena.environments.arena_environment_cfg import ArenaEnvironmentCfg
from isaaclab_arena_environments.example_environment_base import ExampleEnvironmentBase

if TYPE_CHECKING:
from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment


@dataclass
class DexsuiteLiftEnvironmentCfg(ArenaEnvironmentCfg):
"""Configure the Dexsuite lift environment."""


@register_environment
class DexsuiteLiftEnvironment(ExampleEnvironmentBase):
class DexsuiteLiftEnvironment(ExampleEnvironmentBase[DexsuiteLiftEnvironmentCfg]):
"""
Dexsuite Kuka Allegro lift task; RSL-RL config ``DexsuiteKukaAllegroPPORunnerCfg``.
The robot picks up a cube and lifts it to a target position.
Expand All @@ -26,6 +33,11 @@ class DexsuiteLiftEnvironment(ExampleEnvironmentBase):
name: str = "dexsuite_lift"

def get_env(self, args_cli: argparse.Namespace) -> IsaacLabArenaEnvironment:
"""Translate the legacy CLI namespace and build the environment."""
return self.build(DexsuiteLiftEnvironmentCfg())

def build(self, cfg: DexsuiteLiftEnvironmentCfg) -> IsaacLabArenaEnvironment:
"""Build the environment from its typed configuration."""
import math

import isaaclab_tasks.manager_based.manipulation.dexsuite # noqa: F401
Expand Down
16 changes: 14 additions & 2 deletions isaaclab_arena_environments/example_environment_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@

import argparse
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Generic, TypeVar

from isaaclab_arena.environments.arena_environment_cfg import ArenaEnvironmentCfg

if TYPE_CHECKING:
from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment

ArenaEnvironmentCfgT = TypeVar("ArenaEnvironmentCfgT", bound=ArenaEnvironmentCfg)


class ExampleEnvironmentBase(ABC):
# TODO(cvolk, 2026-07-03): Co-locate ArenaEnvironmentCfg and this base in the core
# arena_environment_factory module as ArenaEnvironmentFactoryBase.
class ExampleEnvironmentBase(ABC, Generic[ArenaEnvironmentCfgT]):
Comment thread
cvolkcvolk marked this conversation as resolved.

name: str | None = None

Expand All @@ -26,8 +32,14 @@ def __init__(self):

@abstractmethod
def get_env(self, args_cli: argparse.Namespace) -> IsaacLabArenaEnvironment:
# TODO(cvolk, 2026-07-03): Deprecate this legacy argparse entry point; build(cfg) will
Comment thread
cvolkcvolk marked this conversation as resolved.
Outdated
# become the primary environment construction API.
pass

def build(self, cfg: ArenaEnvironmentCfgT) -> IsaacLabArenaEnvironment:
"""Build an Arena environment from its typed configuration."""
raise NotImplementedError(f"{type(self).__name__} does not support typed environment configuration")
Comment thread
cvolkcvolk marked this conversation as resolved.

@abstractmethod
def add_cli_args(parser: argparse.ArgumentParser) -> None:
pass
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,29 @@
from __future__ import annotations

import argparse
from dataclasses import dataclass
from typing import TYPE_CHECKING

from isaaclab_arena.assets.register import register_environment
from isaaclab_arena.environments.arena_environment_cfg import ArenaEnvironmentCfg
from isaaclab_arena_environments.example_environment_base import ExampleEnvironmentBase

if TYPE_CHECKING:
from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment


@dataclass
class FrankaPutAndCloseDoorEnvironmentCfg(ArenaEnvironmentCfg):
"""Configure the Franka put-and-close-door environment."""

enable_cameras: bool = False
object: str = "dex_cube"
embodiment: str = "franka_ik"
teleop_device: str | None = None


@register_environment
class FrankaPutAndCloseDoorEnvironment(ExampleEnvironmentBase):
class FrankaPutAndCloseDoorEnvironment(ExampleEnvironmentBase[FrankaPutAndCloseDoorEnvironmentCfg]):
"""
A sequential task environment with two subtasks:
1. Pick and place object into the microwave
Expand All @@ -27,6 +39,18 @@ class FrankaPutAndCloseDoorEnvironment(ExampleEnvironmentBase):
name = "franka_put_and_close_door"

def get_env(self, args_cli: argparse.Namespace) -> IsaacLabArenaEnvironment:
"""Translate the legacy CLI namespace and build the environment."""
return self.build(
FrankaPutAndCloseDoorEnvironmentCfg(
enable_cameras=args_cli.enable_cameras,
object=args_cli.object,
embodiment=args_cli.embodiment,
teleop_device=args_cli.teleop_device,
)
)

def build(self, cfg: FrankaPutAndCloseDoorEnvironmentCfg) -> IsaacLabArenaEnvironment:
"""Build the environment from its typed configuration."""
from isaaclab_arena.assets.object_base import ObjectType
from isaaclab_arena.assets.object_reference import ObjectReference
from isaaclab_arena.environments.isaaclab_arena_environment import IsaacLabArenaEnvironment
Expand All @@ -41,11 +65,11 @@ def get_env(self, args_cli: argparse.Namespace) -> IsaacLabArenaEnvironment:
# Get assets
background = self.asset_registry.get_asset_by_name("kitchen")()
container = self.asset_registry.get_asset_by_name("microwave")()
pick_object = self.asset_registry.get_asset_by_name(args_cli.object)()
embodiment = self.asset_registry.get_asset_by_name(args_cli.embodiment)(enable_cameras=args_cli.enable_cameras)
pick_object = self.asset_registry.get_asset_by_name(cfg.object)()
embodiment = self.asset_registry.get_asset_by_name(cfg.embodiment)(enable_cameras=cfg.enable_cameras)

if args_cli.teleop_device is not None:
teleop_device = self.device_registry.get_device_by_name(args_cli.teleop_device)()
if cfg.teleop_device is not None:
teleop_device = self.device_registry.get_device_by_name(cfg.teleop_device)()
else:
teleop_device = None

Expand Down Expand Up @@ -73,7 +97,7 @@ def get_env(self, args_cli: argparse.Namespace) -> IsaacLabArenaEnvironment:
)
)

if args_cli.embodiment == "franka_ik":
if cfg.embodiment == "franka_ik":
# Set Franka arm pose for kitchen setup
embodiment.set_initial_joint_pose([0.0, -1.309, 0.0, -2.793, 0.0, 3.037, 0.740, 0.04, 0.04])

Expand Down
Loading
Loading