Skip to content
6 changes: 4 additions & 2 deletions isaaclab_arena/evaluation/legacy_eval_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from isaaclab_arena.environments.arena_env_builder_cfg import ArenaEnvBuilderCfg
from isaaclab_arena.environments.arena_environment_factory import ArenaEnvironmentCfg
from isaaclab_arena.evaluation.arena_run import ArenaRunCfg, RolloutLimitCfg
from isaaclab_arena.evaluation.legacy_environment_cli_args import legacy_environment_args_to_cli_args
from isaaclab_arena.evaluation.legacy_graph_environment_cli import LegacyGraphEnvironmentCfg
from isaaclab_arena.evaluation.policy_runner import get_policy_cls
from isaaclab_arena.policy.policy_base import PolicyCfg
Expand Down Expand Up @@ -157,7 +156,10 @@ def _graph_environment_cfg_from_legacy_args(
) -> LegacyGraphEnvironmentCfg:
"""Create the temporary graph-YAML compatibility config from legacy arguments."""
return LegacyGraphEnvironmentCfg(
arena_env_args=legacy_environment_args_to_cli_args(arena_env_args),
env_graph_spec_yaml_path=str(arena_env_args["environment"]),
per_run_overrides={
field_name: value for field_name, value in arena_env_args.items() if field_name != "environment"
},
)


Expand Down
39 changes: 26 additions & 13 deletions isaaclab_arena/evaluation/legacy_graph_environment_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from isaaclab_arena.environments.arena_environment_factory import ArenaEnvironmentCfg
from isaaclab_arena_environments.cli import get_arena_builder_from_cli, get_isaaclab_arena_environments_cli_parser
from isaaclab_arena.evaluation.legacy_environment_cli_args import legacy_environment_args_to_cli_args
from isaaclab_arena_environments.cli import arena_env_from_graph_spec, get_isaaclab_arena_environments_cli_parser

if TYPE_CHECKING:
from isaaclab_arena.environments.arena_env_builder import ArenaEnvBuilder
from isaaclab_arena.environments.arena_env_builder_cfg import ArenaEnvBuilderCfg

# TODO(cvolk, 2026-07-07): [typed-config-migration] Delete this module when graph-YAML environments have a
# typed configuration and factory. Until then, only graph construction crosses the
Expand All @@ -23,23 +25,34 @@

@dataclass
class LegacyGraphEnvironmentCfg(ArenaEnvironmentCfg):
"""Carry a graph-YAML environment through its temporary CLI construction path."""
"""Environment config for graph-YAML environments

# Keyword-only so this required field can follow the defaulted fields of ArenaEnvironmentCfg.
arena_env_args: list[str] = field(kw_only=True)
"""Arguments consumed by the existing graph-environment parser."""
The environment is stored as env_graph_spec_yaml_path and the per-run overrides.
"""

env_graph_spec_yaml_path: str = ""
Comment thread
alexmillane marked this conversation as resolved.
"""Graph-spec YAML path the environment was loaded from; serialized as the environment ``type``."""

per_run_overrides: dict[str, Any] = field(default_factory=dict)
"""The Run's ``environment`` YAML values minus the environment path itself
i.e. the per-run overrides e.g. {"pick_up_object": "banana"}. Combined with the path to
re-serialize the run and build the graph-environment CLI tokens at execution time.
"""


def build_arena_builder_from_legacy_graph(
cfg: LegacyGraphEnvironmentCfg,
device: str,
language_instruction: str | None,
environment_builder: ArenaEnvBuilderCfg,
hydra_overrides: list[str],
) -> ArenaEnvBuilder:
"""Build a graph-YAML environment through the existing argparse adapter."""
assert "--env_graph_spec_yaml" in cfg.arena_env_args, "legacy graph config must select a graph YAML"
from isaaclab_arena.environments.arena_env_builder import ArenaEnvBuilder

assert cfg.env_graph_spec_yaml_path.endswith((".yaml", ".yml")), "legacy graph config must select a graph YAML"
arena_env_args = legacy_environment_args_to_cli_args(
{"environment": cfg.env_graph_spec_yaml_path, **cfg.per_run_overrides}
)
parser = get_isaaclab_arena_environments_cli_parser()
args_cli = parser.parse_args(cfg.arena_env_args)
args_cli.device = device
args_cli.language_instruction = language_instruction
return get_arena_builder_from_cli(args_cli, hydra_overrides=hydra_overrides)
args_cli = parser.parse_args(arena_env_args)
arena_env = arena_env_from_graph_spec(args_cli.env_graph_spec_yaml, args_cli)
return ArenaEnvBuilder(arena_env, environment_builder, hydra_overrides=hydra_overrides)
3 changes: 1 addition & 2 deletions isaaclab_arena/evaluation/run_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,7 @@ def build_arena_builder_from_run_cfg(cfg: ArenaRunCfg) -> ArenaEnvBuilder:
return (
build_arena_builder_from_legacy_graph(
cfg.environment,
device=cfg.environment_builder.device,
language_instruction=cfg.environment_builder.language_instruction,
environment_builder=cfg.environment_builder,
hydra_overrides=hydra_overrides,
)
if isinstance(cfg.environment, LegacyGraphEnvironmentCfg)
Expand Down
76 changes: 70 additions & 6 deletions isaaclab_arena/hydra/typed_experiment_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from isaaclab_arena.environments.arena_environment_factory import ArenaEnvironmentCfg
from isaaclab_arena.evaluation.arena_experiment import ArenaExperimentCfg
from isaaclab_arena.evaluation.arena_run import ArenaRunCfg
from isaaclab_arena.evaluation.legacy_graph_environment_cli import LegacyGraphEnvironmentCfg
from isaaclab_arena.policy.policy_base import PolicyCfg


Expand All @@ -47,9 +48,9 @@ def load_arena_experiment_from_yaml(
"""Load a YAML Arena Experiment Definition as a typed named-Run mapping.

Each entry in the runs mapping declares one Run using its key as the Run
name. The environment.type selector chooses from the supplied mapping,
policy.type is resolved when its Run is built. Hydra overrides can update
fields on Runs declared in YAML, but cannot add Runs.
name. The environment.type selector chooses from the supplied mapping, or
names a graph-spec YAML path; policy.type is resolved when its Run is built.
Hydra overrides can update fields on Runs declared in YAML, but cannot add Runs.

Args:
yaml_path: Path to the Arena Experiment YAML file.
Expand Down Expand Up @@ -173,14 +174,12 @@ def _build_arena_run_cfg_from_yaml_values(
hydra_run_config_name = f"{hydra_config_namespace}_run_{index}"
hydra_environment_config_name = f"{hydra_run_config_name}_environment"
hydra_policy_config_name = f"{hydra_run_config_name}_policy"
environment = _compose_typed_config_from_yaml_selector(
environment = _build_environment_cfg_from_yaml_values(
config_store,
hydra_environment_config_name,
run_name,
"environment",
environment_values,
environment_cfg_types,
ArenaEnvironmentCfg,
)
policy_cfg_types: dict[str, type[PolicyCfg]] = {}
if isinstance(policy_values, dict):
Expand Down Expand Up @@ -208,6 +207,71 @@ def _build_arena_run_cfg_from_yaml_values(
return run


def _build_environment_cfg_from_yaml_values(
config_store: ConfigStore,
hydra_environment_config_name: str,
run_name: str,
environment_values: dict[str, Any],
environment_cfg_types: dict[str, type[ArenaEnvironmentCfg]],
) -> ArenaEnvironmentCfg:
"""Build a Run's environment from a graph-spec YAML path or a typed selector.

When environment.type names a graph-spec YAML file it is built on the temporary
argparse compatibility path; otherwise the type selects a registered typed config.
"""
if _is_environment_graph_yaml_spec(environment_values):
env_graph_spec_yaml_path = _graph_spec_yaml_path(environment_values)
Comment thread
alexmillane marked this conversation as resolved.
per_run_overrides = {
field_name: value for field_name, value in environment_values.items() if field_name != "type"
}
return _graph_environment_cfg_from_yaml_values(env_graph_spec_yaml_path, per_run_overrides)
else:
return _compose_typed_config_from_yaml_selector(
config_store,
hydra_environment_config_name,
run_name,
"environment",
environment_values,
environment_cfg_types,
ArenaEnvironmentCfg,
)


# TODO(cvolk, 2026-07-07): [typed-config-migration] Delete this factory when graph-YAML
# environments have a typed configuration and no longer use the argparse compatibility path.
def _graph_environment_cfg_from_yaml_values(
env_graph_spec_yaml_path: str,
per_run_overrides: dict[str, Any],
) -> LegacyGraphEnvironmentCfg:
"""Create the temporary graph-YAML compatibility config from typed YAML Run values.

The path and environment values are stored structured and rendered into CLI tokens for
the existing graph-environment argparse path on demand at execution; the Run's
environment_builder section stays typed and is applied directly (see
build_arena_builder_from_legacy_graph).
"""
return LegacyGraphEnvironmentCfg(
enable_cameras=bool(per_run_overrides.get("enable_cameras", False)),
Comment thread
alexmillane marked this conversation as resolved.
Comment thread
alexmillane marked this conversation as resolved.
env_graph_spec_yaml_path=env_graph_spec_yaml_path,
per_run_overrides=dict(per_run_overrides),
)


def _is_environment_graph_yaml_spec(environment_values: Any) -> bool:
"""Return whether a Run's environment.type names a graph-spec YAML path."""
return _graph_spec_yaml_path(environment_values) is not None


def _graph_spec_yaml_path(environment_values: Any) -> str | None:
"""Return the environment.type value when it names a graph-spec YAML path, else None."""
if not isinstance(environment_values, dict):
return None
environment_type = environment_values.get("type")
if isinstance(environment_type, str) and environment_type.lower().endswith((".yaml", ".yml")):
return environment_type
return None


def _compose_typed_config_from_yaml_selector(
config_store: ConfigStore,
hydra_config_name: str,
Expand Down
17 changes: 15 additions & 2 deletions isaaclab_arena/hydra/typed_experiment_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from isaaclab_arena.assets.registries import EnvironmentRegistry, PolicyRegistry
from isaaclab_arena.evaluation.arena_experiment import ArenaExperimentCfg
from isaaclab_arena.evaluation.arena_run import ArenaRunCfg
from isaaclab_arena.evaluation.legacy_graph_environment_cli import LegacyGraphEnvironmentCfg


def serialize_arena_experiment_to_yaml(experiment_cfg: ArenaExperimentCfg) -> str:
Expand Down Expand Up @@ -43,9 +44,8 @@ def serialize_arena_experiment_to_yaml(experiment_cfg: ArenaExperimentCfg) -> st
assert isinstance(run_values, dict)
assert run_values.pop("name") == run_name

environment_type = environment_registry.get_factory_type_for_cfg(run_cfg.environment)
run_values["environment"] = _environment_yaml_values(environment_registry, run_cfg, run_values["environment"])
policy_type = policy_registry.get_policy_type_for_cfg(run_cfg.policy)
run_values["environment"] = {"type": environment_type.name, **run_values["environment"]}
policy_selector = policy_type.name
if not policy_type.__module__.startswith("isaaclab_arena.policy."):
policy_selector = f"{policy_type.__module__}.{policy_type.__qualname__}"
Expand All @@ -54,6 +54,19 @@ def serialize_arena_experiment_to_yaml(experiment_cfg: ArenaExperimentCfg) -> st
return yaml.safe_dump({"runs": run_values_by_name}, sort_keys=False)


def _environment_yaml_values(
environment_registry: EnvironmentRegistry,
run_cfg: ArenaRunCfg,
dumped_environment_values: dict[str, Any],
) -> dict[str, Any]:
"""Return one Run's environment section."""
if isinstance(run_cfg.environment, LegacyGraphEnvironmentCfg):
return {"type": run_cfg.environment.env_graph_spec_yaml_path, **run_cfg.environment.per_run_overrides}
else:
environment_type = environment_registry.get_factory_type_for_cfg(run_cfg.environment)
return {"type": environment_type.name, **dumped_environment_values}


def _to_yaml_values(value: Any) -> Any:
"""Convert structured-config leaf values into safe YAML primitives."""
if isinstance(value, dict):
Expand Down
102 changes: 102 additions & 0 deletions isaaclab_arena/tests/test_arena_experiment_config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

"""Test loading Arena Experiments at the evaluation boundary."""

import yaml
from pathlib import Path

import pytest
Expand All @@ -18,6 +19,8 @@
from isaaclab_arena.evaluation.arena_run import ArenaRunCfg
from isaaclab_arena.evaluation.experiment_runner import _assert_camera_support_enabled
from isaaclab_arena.evaluation.legacy_experiment_runner import legacy_json_experiment_requires_cameras
from isaaclab_arena.evaluation.legacy_graph_environment_cli import LegacyGraphEnvironmentCfg
from isaaclab_arena.hydra.typed_experiment_serializer import serialize_arena_experiment_to_yaml
from isaaclab_arena.policy.zero_action_policy import ZeroActionPolicyCfg
from isaaclab_arena.tests.utils.constants import TestConstants
from isaaclab_arena_environments.pick_and_place_maple_table_environment import PickAndPlaceMapleTableEnvironmentCfg
Expand Down Expand Up @@ -76,6 +79,105 @@ def test_load_typed_yaml_experiment_applies_overrides_and_device(monkeypatch):
assert all(run.environment_builder.device == "cuda:1" for run in runs.values())


def test_load_typed_yaml_experiment_with_graph_spec_environment(tmp_path, monkeypatch):
monkeypatch.setattr(arena_experiment_config_loader, "_registered_environment_cfg_types", lambda: {})
monkeypatch.setattr(
arena_experiment_config_loader,
"_resolve_policy_cfg_type_from_name_or_class_path",
lambda policy_name_or_class_path: {"zero_action": ZeroActionPolicyCfg}[policy_name_or_class_path],
)
config_path = tmp_path / "experiment.yaml"
config_path.write_text(
"""
runs:
graph_run:
environment:
type: robolab/tasks/banana_in_bowl.yaml
enable_cameras: true
pick_up_object: banana
policy:
type: zero_action
environment_builder:
num_envs: 2
language_instruction: Pick up the banana.
rollout_limit:
num_episodes: 4
""",
encoding="utf-8",
)

experiment_cfg = load_arena_experiment_from_config_file(config_path, device="cuda:1")
run = experiment_cfg.runs["graph_run"]

assert isinstance(run.environment, LegacyGraphEnvironmentCfg)
# Only environment values are stored (and later become CLI tokens at execution); the
# environment_builder section stays typed and reaches the argparse path directly.
assert run.environment.env_graph_spec_yaml_path == "robolab/tasks/banana_in_bowl.yaml"
assert run.environment.per_run_overrides == {"enable_cameras": True, "pick_up_object": "banana"}
assert run.environment_builder.num_envs == 2
assert run.environment_builder.device == "cuda:1"
assert run.environment_builder.language_instruction == "Pick up the banana."
assert run.rollout_limit.num_episodes == 4


def test_graph_spec_environment_serializes_to_reloadable_yaml(tmp_path, monkeypatch):
monkeypatch.setattr(arena_experiment_config_loader, "_registered_environment_cfg_types", lambda: {})
monkeypatch.setattr(
arena_experiment_config_loader,
"_resolve_policy_cfg_type_from_name_or_class_path",
lambda policy_name_or_class_path: {"zero_action": ZeroActionPolicyCfg}[policy_name_or_class_path],
)
config_path = tmp_path / "experiment.yaml"
config_path.write_text(
"""
runs:
graph_run:
environment:
type: robolab/tasks/banana_in_bowl.yaml
enable_cameras: true
pick_up_object: banana
policy:
type: zero_action
environment_builder:
num_envs: 2
rollout_limit:
num_episodes: 4
""",
encoding="utf-8",
)
experiment_cfg = load_arena_experiment_from_config_file(config_path, device="cuda:1")

serialized_experiment = serialize_arena_experiment_to_yaml(experiment_cfg)
serialized_values = yaml.safe_load(serialized_experiment)
serialized_environment = serialized_values["runs"]["graph_run"]["environment"]
assert serialized_environment == {
"type": "robolab/tasks/banana_in_bowl.yaml",
"enable_cameras": True,
"pick_up_object": "banana",
}

serialized_path = tmp_path / "serialized_experiment.yaml"
serialized_path.write_text(serialized_experiment, encoding="utf-8")
assert load_arena_experiment_from_config_file(serialized_path, device="cuda:1") == experiment_cfg


def test_typed_graph_camera_run_requires_prelaunch_camera_flag():
run_cfg = ArenaRunCfg(
name="graph_run",
environment=LegacyGraphEnvironmentCfg(
env_graph_spec_yaml_path="robolab/tasks/banana_in_bowl.yaml",
enable_cameras=True,
),
policy=ZeroActionPolicyCfg(),
)
experiment_cfg = ArenaExperimentCfg(runs={run_cfg.name: run_cfg})

with pytest.raises(AssertionError, match="enable environment cameras"):
_assert_camera_support_enabled(experiment_cfg, enable_cameras=False)

_assert_camera_support_enabled(experiment_cfg, enable_cameras=True)


def test_policy_config_type_resolves_from_dotted_class_path():
policy_cfg_type = arena_experiment_config_loader._resolve_policy_cfg_type_from_name_or_class_path(
"isaaclab_arena.policy.zero_action_policy.ZeroActionPolicy"
Expand Down
Loading
Loading