Skip to content

Commit ccd4af0

Browse files
committed
Add Hydra experiment configuration example
Compose typed experiments from YAML and adapt them to the existing eval-runner Job and Namespace APIs. Signed-off-by: Clemens Volk <cvolk@nvidia.com>
1 parent 4815893 commit ccd4af0

14 files changed

Lines changed: 1043 additions & 109 deletions

CONTEXT.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Isaac Lab-Arena
2+
3+
Isaac Lab-Arena defines composable robotics environments and evaluates policies against them.
4+
5+
## Language
6+
7+
**Environment Configuration**:
8+
Declarative values that specialize one environment provider for an Arena Experiment.
9+
_Avoid_: Hydra environment, environment wrapper
10+
11+
**Environment Provider**:
12+
A named recipe that turns an environment configuration into an assembled Arena environment.
13+
_Avoid_: Runtime environment, Hydra environment
14+
15+
**Arena Environment**:
16+
An assembled embodiment, scene, and task ready to be passed to an environment builder.
17+
_Avoid_: Environment configuration, environment provider
18+
19+
**Gym Environment**:
20+
The instantiated simulation interface used for reset and step operations.
21+
_Avoid_: Arena environment configuration
22+
23+
**Arena Experiment**:
24+
A portable, declarative evaluation condition pairing an environment configuration with a policy,
25+
rollout, variations, and repetition count. It contains no dispatch state or results.
26+
_Avoid_: Job, runtime execution, simulation application
27+
28+
**Evaluation Job**:
29+
A runtime work item derived from an Arena Experiment and tracked through its execution lifecycle.
30+
_Avoid_: Experiment configuration, suite
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
"""Arena policy-evaluation configuration and runners."""
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright (c) 2026, The Isaac Lab Arena Project Developers (https://github.com/isaac-sim/IsaacLab-Arena/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: Apache-2.0
5+
6+
"""Typed configuration for Arena experiments."""
7+
8+
from __future__ import annotations
9+
10+
from dataclasses import dataclass, field
11+
from typing import Any
12+
13+
from isaaclab_arena.environments.arena_environment_cfg import ArenaEnvironmentCfg
14+
15+
16+
@dataclass
17+
class EnvironmentBuilderCfg:
18+
"""Configure how Arena builds the selected environment."""
19+
20+
num_envs: int = 1
21+
env_spacing: float = 30.0
22+
seed: int = 42
23+
solve_relations: bool = True
24+
placement_seed: int | None = None
25+
resolve_on_reset: bool | None = None
26+
random_yaw_init: bool = False
27+
disable_fabric: bool = False
28+
mimic: bool = False
29+
presets: str | None = None
30+
31+
def __post_init__(self) -> None:
32+
assert self.num_envs > 0, "num_envs must be greater than zero"
33+
34+
35+
@dataclass
36+
class RolloutCfg:
37+
"""Configure an evaluation rollout."""
38+
39+
num_steps: int = 2
40+
41+
def __post_init__(self) -> None:
42+
assert self.num_steps > 0, "num_steps must be greater than zero"
43+
44+
45+
@dataclass
46+
class PolicyCfg:
47+
"""Select and configure an evaluation policy."""
48+
49+
type: str = "zero_action"
50+
parameters: dict[str, Any] = field(default_factory=dict)
51+
52+
def __post_init__(self) -> None:
53+
assert self.type, "policy type must not be empty"
54+
55+
56+
@dataclass
57+
class ArenaExperimentCfg:
58+
"""Configure one portable Arena evaluation experiment."""
59+
60+
name: str
61+
environment: ArenaEnvironmentCfg
62+
"""Concrete registered environment configuration selected during composition."""
63+
environment_builder: EnvironmentBuilderCfg = field(default_factory=EnvironmentBuilderCfg)
64+
policy: PolicyCfg = field(default_factory=PolicyCfg)
65+
rollout: RolloutCfg = field(default_factory=RolloutCfg)
66+
num_rebuilds: int = 1
67+
variations: dict[str, Any] = field(default_factory=dict)
68+
69+
def __post_init__(self) -> None:
70+
assert self.name, "experiment name must not be empty"
71+
assert self.num_rebuilds > 0, "num_rebuilds must be greater than zero"

isaaclab_arena/evaluation/eval_runner.py

Lines changed: 128 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,133 @@ def _run_in_chunks(args_cli: argparse.Namespace, master_cfg: dict) -> None:
212212
sys.exit(returncode)
213213

214214

215+
def evaluate_jobs(
216+
args_cli: argparse.Namespace,
217+
jobs: list[Job | dict],
218+
*,
219+
environment_loader=None,
220+
) -> None:
221+
"""Evaluate jobs inside an active ``SimulationAppContext``.
222+
223+
Args:
224+
args_cli: Evaluation-runner options shared by every job.
225+
jobs: Resolved jobs or legacy job dictionaries.
226+
environment_loader: Optional callable that builds one job's environment.
227+
"""
228+
job_manager = JobManager(jobs)
229+
metrics_logger = MetricsLogger()
230+
231+
job_manager.print_jobs_info()
232+
233+
# One reverse-dated run directory shared by all jobs; each job gets a subdirectory within it.
234+
# Always dated so every run produces its own report dir, recording or not.
235+
# TODO(alexmillane): Currently each chunk produces its own output directory.
236+
# We should use the same output directory for all chunks in the future.
237+
run_output_dir = timestamped_run_dir(args_cli.output_base_dir)
238+
239+
if args_cli.record_viewport_video:
240+
os.makedirs(run_output_dir, exist_ok=True)
241+
print(f"[INFO] Video recording enabled. Videos will be saved to: {run_output_dir}")
242+
243+
for job in job_manager:
244+
if job is None:
245+
continue
246+
env = None
247+
policy = None
248+
249+
metrics_per_run: list[MetricsDataCollection] = []
250+
251+
# num_episodes is the total across rebuilds, so split it over the rebuilds.
252+
num_episodes_per_rebuild = _split_episodes_across_rebuilds(job.num_episodes, job.num_rebuilds, job.name)
253+
254+
# Rebuild the environment and re-run the rollout job.num_rebuilds times, then
255+
# aggregate the metrics across rebuilds into a single result.
256+
for rebuild_idx in range(job.num_rebuilds):
257+
try:
258+
job_output_dir = os.path.join(run_output_dir, job.name)
259+
260+
# Per-job video output directory; cameras are tagged with the rebuild index.
261+
video_cfg = VideoRecordingCfg(
262+
record_viewport_video=args_cli.record_viewport_video,
263+
record_camera_video=args_cli.record_camera_video,
264+
video_base_dir=job_output_dir,
265+
camera_name_prefix=f"robot-cam-rebuild{rebuild_idx}",
266+
)
267+
if environment_loader is None:
268+
env = load_env(
269+
job.arena_env_args,
270+
job.name,
271+
variations=job.variations,
272+
render_mode=video_cfg.render_mode,
273+
language_instruction=job.language_instruction,
274+
)
275+
else:
276+
env = environment_loader(job, video_cfg.render_mode)
277+
278+
# Write per-episode results to disk.
279+
# TODO: Aggregate the per-episode records across rebuilds into a single file,
280+
# as is done for the metrics below.
281+
results_path = os.path.join(job_output_dir, f"episode_results_rebuild{rebuild_idx}.jsonl")
282+
env.unwrapped.episode_recorder.set_job_name(job.name)
283+
env.unwrapped.episode_recorder.set_output_path(results_path)
284+
285+
policy = get_policy_from_job(job)
286+
287+
# Episodes allotted to this rebuild (None when the job is length-driven by steps).
288+
num_episodes_this_rebuild = num_episodes_per_rebuild[rebuild_idx]
289+
290+
# Resolve simulation length: num_steps and num_episodes are mutually exclusive.
291+
# Priority: job config -> policy length -> CLI default
292+
if job.num_steps is None and num_episodes_this_rebuild is None:
293+
if policy.has_length():
294+
job.num_steps = policy.length()
295+
else:
296+
job.num_steps = args_cli.num_steps
297+
298+
env = wrap_env_for_video(env, video_cfg, job.num_steps, num_episodes_this_rebuild)
299+
300+
metrics = rollout_policy(
301+
env,
302+
policy,
303+
num_steps=job.num_steps,
304+
num_episodes=num_episodes_this_rebuild,
305+
)
306+
307+
job_manager.complete_job(job, metrics=metrics, status=Status.COMPLETED)
308+
309+
# users may not specify metrics for a task, although it's not recommended
310+
if metrics is not None:
311+
metrics_per_run.append(metrics)
312+
313+
except Exception as e:
314+
job_manager.complete_job(job, metrics={}, status=Status.FAILED)
315+
print(f"Job {job.name} failed with error: {e}")
316+
print(f"Traceback: {traceback.format_exc()}")
317+
if not args_cli.continue_on_error:
318+
raise
319+
320+
finally:
321+
try:
322+
_close_job_resources(policy, env)
323+
finally:
324+
policy = None
325+
env = None
326+
collect_garbage_and_clear_cuda_cache()
327+
328+
# Aggregate the metrics from the different experiments into a single view.
329+
if metrics_per_run:
330+
aggregated_metrics = aggregate_metrics(metrics_per_run)
331+
metrics_logger.append_job_metrics(job.name, aggregated_metrics)
332+
333+
job_manager.print_jobs_info()
334+
metrics_logger.print_metrics()
335+
336+
# Write HTML report.
337+
report_path = build_report(run_output_dir)
338+
if args_cli.serve_evaluation_report:
339+
serve_until_ctrl_c(report_path.parent, args_cli.evaluation_report_port, report_path.name)
340+
341+
215342
def main():
216343
args_parser = get_isaaclab_arena_cli_parser()
217344
args_cli, unknown = args_parser.parse_known_args()
@@ -252,115 +379,7 @@ def main():
252379
enable_cameras_if_required(eval_jobs_config, args_cli)
253380

254381
with SimulationAppContext(args_cli):
255-
job_manager = JobManager(eval_jobs_config["jobs"])
256-
metrics_logger = MetricsLogger()
257-
258-
job_manager.print_jobs_info()
259-
260-
# One reverse-dated run directory shared by all jobs; each job gets a subdirectory within it.
261-
# Always dated so every run produces its own report dir, recording or not.
262-
# TODO(alexmillane): Currently each chunk produces its own output directory.
263-
# We should use the same output directory for all chunks in the future.
264-
run_output_dir = timestamped_run_dir(args_cli.output_base_dir)
265-
266-
if args_cli.record_viewport_video:
267-
os.makedirs(run_output_dir, exist_ok=True)
268-
print(f"[INFO] Video recording enabled. Videos will be saved to: {run_output_dir}")
269-
270-
for job in job_manager:
271-
if job is None:
272-
continue
273-
env = None
274-
policy = None
275-
276-
metrics_per_run: list[MetricsDataCollection] = []
277-
278-
# num_episodes is the total across rebuilds, so split it over the rebuilds.
279-
num_episodes_per_rebuild = _split_episodes_across_rebuilds(job.num_episodes, job.num_rebuilds, job.name)
280-
281-
# Rebuild the environment and re-run the rollout job.num_rebuilds times, then
282-
# aggregate the metrics across rebuilds into a single result.
283-
for rebuild_idx in range(job.num_rebuilds):
284-
try:
285-
job_output_dir = os.path.join(run_output_dir, job.name)
286-
287-
# Per-job video output directory; cameras are tagged with the rebuild index.
288-
video_cfg = VideoRecordingCfg(
289-
record_viewport_video=args_cli.record_viewport_video,
290-
record_camera_video=args_cli.record_camera_video,
291-
video_base_dir=job_output_dir,
292-
camera_name_prefix=f"robot-cam-rebuild{rebuild_idx}",
293-
)
294-
env = load_env(
295-
job.arena_env_args,
296-
job.name,
297-
variations=job.variations,
298-
render_mode=video_cfg.render_mode,
299-
language_instruction=job.language_instruction,
300-
)
301-
302-
# Write per-episode results to disk.
303-
# TODO: Aggregate the per-episode records across rebuilds into a single file,
304-
# as is done for the metrics below.
305-
results_path = os.path.join(job_output_dir, f"episode_results_rebuild{rebuild_idx}.jsonl")
306-
env.unwrapped.episode_recorder.set_job_name(job.name)
307-
env.unwrapped.episode_recorder.set_output_path(results_path)
308-
309-
policy = get_policy_from_job(job)
310-
311-
# Episodes allotted to this rebuild (None when the job is length-driven by steps).
312-
num_episodes_this_rebuild = num_episodes_per_rebuild[rebuild_idx]
313-
314-
# Resolve simulation length: num_steps and num_episodes are mutually exclusive.
315-
# Priority: job config -> policy length -> CLI default
316-
if job.num_steps is None and num_episodes_this_rebuild is None:
317-
if policy.has_length():
318-
job.num_steps = policy.length()
319-
else:
320-
job.num_steps = args_cli.num_steps
321-
322-
env = wrap_env_for_video(env, video_cfg, job.num_steps, num_episodes_this_rebuild)
323-
324-
metrics = rollout_policy(
325-
env,
326-
policy,
327-
num_steps=job.num_steps,
328-
num_episodes=num_episodes_this_rebuild,
329-
)
330-
331-
job_manager.complete_job(job, metrics=metrics, status=Status.COMPLETED)
332-
333-
# users may not specify metrics for a task, although it's not recommended
334-
if metrics is not None:
335-
metrics_per_run.append(metrics)
336-
337-
except Exception as e:
338-
job_manager.complete_job(job, metrics={}, status=Status.FAILED)
339-
print(f"Job {job.name} failed with error: {e}")
340-
print(f"Traceback: {traceback.format_exc()}")
341-
if not args_cli.continue_on_error:
342-
raise
343-
344-
finally:
345-
try:
346-
_close_job_resources(policy, env)
347-
finally:
348-
policy = None
349-
env = None
350-
collect_garbage_and_clear_cuda_cache()
351-
352-
# Aggregate the metrics from the different experiments into a single view.
353-
if metrics_per_run:
354-
aggregated_metrics = aggregate_metrics(metrics_per_run)
355-
metrics_logger.append_job_metrics(job.name, aggregated_metrics)
356-
357-
job_manager.print_jobs_info()
358-
metrics_logger.print_metrics()
359-
360-
# Write HTML report.
361-
report_path = build_report(run_output_dir)
362-
if args_cli.serve_evaluation_report:
363-
serve_until_ctrl_c(report_path.parent, args_cli.evaluation_report_port, report_path.name)
382+
evaluate_jobs(args_cli, eval_jobs_config["jobs"])
364383

365384

366385
if __name__ == "__main__":

0 commit comments

Comments
 (0)