inspect-robots-isaacsim 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ build/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .mypy_cache/
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: inspect-robots-isaacsim
3
+ Version: 0.1.0
4
+ Summary: IsaacSim / Isaac Lab embodiment adapter for Inspect Robots — run Inspect Robots evals against an Isaac Lab simulation.
5
+ Project-URL: Homepage, https://github.com/robocurve/inspect-robots/tree/main/plugins/inspect-robots-isaacsim
6
+ Project-URL: Repository, https://github.com/robocurve/inspect-robots
7
+ Author: Inspect Robots contributors
8
+ License-Expression: MIT
9
+ Keywords: evaluation,inspect_robots,isaac-lab,isaac-sim,robotics,vla
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: inspect-robots>=0.2
18
+ Requires-Dist: numpy>=1.24
19
+ Provides-Extra: dev
20
+ Requires-Dist: mypy>=1.11; extra == 'dev'
21
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Requires-Dist: ruff>=0.6; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # inspect-robots-isaacsim
27
+
28
+ An [Isaac Lab](https://isaac-sim.github.io/IsaacLab/) (Isaac Sim) **embodiment**
29
+ plugin for [Inspect Robots](https://github.com/robocurve/inspect-robots) — the "Inspect AI
30
+ for robotics".
31
+
32
+ Inspect Robots factors a robotics eval into two swappable inputs: a `Policy` (the VLA
33
+ "brain") and an `Embodiment` (the "body + world"). This package supplies the
34
+ second one, backed by a real Isaac Lab physics simulation, so you can run any
35
+ compatible VLA against your Isaac Sim setup.
36
+
37
+ The default profile is a **7-DoF Franka Panda under joint-position control** with
38
+ a binary gripper (action dimension `= num_arm_joints + 1 = 8`).
39
+
40
+ ## Install
41
+
42
+ This package installs and registers on any machine, but `reset()`/`step()` need
43
+ a working **Isaac Lab** environment (NVIDIA Omniverse + GPU) — Isaac Sim is not a
44
+ PyPI dependency and is imported lazily.
45
+
46
+ ```bash
47
+ # inside the conda/venv that already has isaaclab + isaacsim available
48
+ pip install inspect-robots inspect-robots-isaacsim
49
+ ```
50
+
51
+ ## Use it
52
+
53
+ The embodiment is discovered through the `inspect_robots.embodiments` entry point, so
54
+ it appears in the CLI without any import:
55
+
56
+ ```bash
57
+ inspect-robots list embodiments # -> includes "isaacsim"
58
+
59
+ inspect-robots run \
60
+ --task my-benchmark \
61
+ --policy my-vla \
62
+ --embodiment isaacsim \
63
+ -E task_id=Isaac-Lift-Cube-Franka-v0 \
64
+ -E headless=true
65
+ ```
66
+
67
+ Or programmatically:
68
+
69
+ ```python
70
+ from inspect_robots import eval
71
+
72
+ logs = eval("my-benchmark", "my-vla", "isaacsim")
73
+ print(logs[0].results.metrics)
74
+ ```
75
+
76
+ Constructing directly (e.g. for a non-Franka arm or extra cameras):
77
+
78
+ ```python
79
+ from inspect_robots_isaacsim import IsaacSimEmbodiment
80
+
81
+ emb = IsaacSimEmbodiment(
82
+ task_id="Isaac-Open-Drawer-Franka-v0",
83
+ num_arm_joints=7,
84
+ cameras=[("base_rgb", 224, 224), ("wrist_rgb", 224, 224)],
85
+ control_hz=30.0,
86
+ headless=True,
87
+ )
88
+ ```
89
+
90
+ ## Compatibility
91
+
92
+ Inspect Robots fail-fast-checks the `(policy, embodiment)` pair before any rollout. To
93
+ run against this embodiment your policy must emit **8-D `joint_pos` actions**
94
+ (`control_mode="joint_pos"`, `gripper="binary"`) and require only observation
95
+ keys this task provides. The mock `cubepick` policies (2-D `eef_delta_pos`) are
96
+ intentionally **incompatible** — bring a Franka-trained VLA.
97
+
98
+ ### Mapping your task
99
+
100
+ Isaac Lab tasks vary in their observation-dict layout and success signal. The
101
+ constructor exposes hooks so you don't edit the adapter:
102
+
103
+ | Argument | Purpose |
104
+ |---|---|
105
+ | `obs_group` | top-level obs-dict group to read (default `"policy"`) |
106
+ | `image_keys` / `state_keys` | map Inspect Robots keys → your task's raw dict keys |
107
+ | `success_info_key` | where the task reports success in `info` (default `"success"`) |
108
+ | `num_arm_joints` | arm DoF; action dim is this `+ 1` for the gripper |
109
+
110
+ ## Memory & GPU hygiene
111
+
112
+ Long unattended evals should not creep in RAM or VRAM. This adapter is built to
113
+ hold nothing per step, but a few usage rules keep a full run leak-free:
114
+
115
+ - **Free the simulator when done.** `eval()` closes what it resolves: an
116
+ embodiment looked up by **registry name** (e.g. `embodiment="isaacsim"`) is
117
+ closed when the run finishes. An embodiment object *you* construct is yours
118
+ to close — an open `SimulationApp` holds GPU memory until the process exits,
119
+ so use it as a context manager (or `close()` in a `finally`):
120
+
121
+ ```python
122
+ with IsaacSimEmbodiment() as emb:
123
+ eval("my-bench", "my-vla", emb)
124
+ # GPU + sim torn down here
125
+ ```
126
+
127
+ `close()` is idempotent and safe to call before launch or twice.
128
+
129
+ - **One simulator per process.** Isaac Sim is a hard process singleton; if you
130
+ construct several embodiments the adapter reuses the one live `SimulationApp`
131
+ rather than launching (and leaking) a second.
132
+
133
+ - **Stream frames to disk for long episodes.** Inspect Robots keeps each step's
134
+ observation — including camera frames — in the per-trial record. Pass
135
+ `store_frames=True` to `eval()` so frames go to disk side-cars instead of RAM.
136
+ (Per-trial records are released after each scene is scored, so there is no
137
+ cross-trial accumulation regardless.)
138
+
139
+ - **Skip rendering you don't need.** If your scorer is state/oracle based, build
140
+ the embodiment with `cameras=()` to avoid allocating images at all.
141
+
142
+ The adapter never stores tensors on `self`, and copies observations off Isaac's
143
+ reused buffers (`.astype`), so a step loop holds no growing references — a
144
+ `tracemalloc` regression test asserts flat RAM over thousands of steps.
145
+
146
+ ## Develop / test
147
+
148
+ The test suite runs **without Isaac** (it checks spaces, semantics, protocol
149
+ conformance, compatibility, and registry wiring):
150
+
151
+ ```bash
152
+ pip install -e ".[dev]"
153
+ pytest -q
154
+ ```
155
+
156
+ ## License
157
+
158
+ MIT.
@@ -0,0 +1,133 @@
1
+ # inspect-robots-isaacsim
2
+
3
+ An [Isaac Lab](https://isaac-sim.github.io/IsaacLab/) (Isaac Sim) **embodiment**
4
+ plugin for [Inspect Robots](https://github.com/robocurve/inspect-robots) — the "Inspect AI
5
+ for robotics".
6
+
7
+ Inspect Robots factors a robotics eval into two swappable inputs: a `Policy` (the VLA
8
+ "brain") and an `Embodiment` (the "body + world"). This package supplies the
9
+ second one, backed by a real Isaac Lab physics simulation, so you can run any
10
+ compatible VLA against your Isaac Sim setup.
11
+
12
+ The default profile is a **7-DoF Franka Panda under joint-position control** with
13
+ a binary gripper (action dimension `= num_arm_joints + 1 = 8`).
14
+
15
+ ## Install
16
+
17
+ This package installs and registers on any machine, but `reset()`/`step()` need
18
+ a working **Isaac Lab** environment (NVIDIA Omniverse + GPU) — Isaac Sim is not a
19
+ PyPI dependency and is imported lazily.
20
+
21
+ ```bash
22
+ # inside the conda/venv that already has isaaclab + isaacsim available
23
+ pip install inspect-robots inspect-robots-isaacsim
24
+ ```
25
+
26
+ ## Use it
27
+
28
+ The embodiment is discovered through the `inspect_robots.embodiments` entry point, so
29
+ it appears in the CLI without any import:
30
+
31
+ ```bash
32
+ inspect-robots list embodiments # -> includes "isaacsim"
33
+
34
+ inspect-robots run \
35
+ --task my-benchmark \
36
+ --policy my-vla \
37
+ --embodiment isaacsim \
38
+ -E task_id=Isaac-Lift-Cube-Franka-v0 \
39
+ -E headless=true
40
+ ```
41
+
42
+ Or programmatically:
43
+
44
+ ```python
45
+ from inspect_robots import eval
46
+
47
+ logs = eval("my-benchmark", "my-vla", "isaacsim")
48
+ print(logs[0].results.metrics)
49
+ ```
50
+
51
+ Constructing directly (e.g. for a non-Franka arm or extra cameras):
52
+
53
+ ```python
54
+ from inspect_robots_isaacsim import IsaacSimEmbodiment
55
+
56
+ emb = IsaacSimEmbodiment(
57
+ task_id="Isaac-Open-Drawer-Franka-v0",
58
+ num_arm_joints=7,
59
+ cameras=[("base_rgb", 224, 224), ("wrist_rgb", 224, 224)],
60
+ control_hz=30.0,
61
+ headless=True,
62
+ )
63
+ ```
64
+
65
+ ## Compatibility
66
+
67
+ Inspect Robots fail-fast-checks the `(policy, embodiment)` pair before any rollout. To
68
+ run against this embodiment your policy must emit **8-D `joint_pos` actions**
69
+ (`control_mode="joint_pos"`, `gripper="binary"`) and require only observation
70
+ keys this task provides. The mock `cubepick` policies (2-D `eef_delta_pos`) are
71
+ intentionally **incompatible** — bring a Franka-trained VLA.
72
+
73
+ ### Mapping your task
74
+
75
+ Isaac Lab tasks vary in their observation-dict layout and success signal. The
76
+ constructor exposes hooks so you don't edit the adapter:
77
+
78
+ | Argument | Purpose |
79
+ |---|---|
80
+ | `obs_group` | top-level obs-dict group to read (default `"policy"`) |
81
+ | `image_keys` / `state_keys` | map Inspect Robots keys → your task's raw dict keys |
82
+ | `success_info_key` | where the task reports success in `info` (default `"success"`) |
83
+ | `num_arm_joints` | arm DoF; action dim is this `+ 1` for the gripper |
84
+
85
+ ## Memory & GPU hygiene
86
+
87
+ Long unattended evals should not creep in RAM or VRAM. This adapter is built to
88
+ hold nothing per step, but a few usage rules keep a full run leak-free:
89
+
90
+ - **Free the simulator when done.** `eval()` closes what it resolves: an
91
+ embodiment looked up by **registry name** (e.g. `embodiment="isaacsim"`) is
92
+ closed when the run finishes. An embodiment object *you* construct is yours
93
+ to close — an open `SimulationApp` holds GPU memory until the process exits,
94
+ so use it as a context manager (or `close()` in a `finally`):
95
+
96
+ ```python
97
+ with IsaacSimEmbodiment() as emb:
98
+ eval("my-bench", "my-vla", emb)
99
+ # GPU + sim torn down here
100
+ ```
101
+
102
+ `close()` is idempotent and safe to call before launch or twice.
103
+
104
+ - **One simulator per process.** Isaac Sim is a hard process singleton; if you
105
+ construct several embodiments the adapter reuses the one live `SimulationApp`
106
+ rather than launching (and leaking) a second.
107
+
108
+ - **Stream frames to disk for long episodes.** Inspect Robots keeps each step's
109
+ observation — including camera frames — in the per-trial record. Pass
110
+ `store_frames=True` to `eval()` so frames go to disk side-cars instead of RAM.
111
+ (Per-trial records are released after each scene is scored, so there is no
112
+ cross-trial accumulation regardless.)
113
+
114
+ - **Skip rendering you don't need.** If your scorer is state/oracle based, build
115
+ the embodiment with `cameras=()` to avoid allocating images at all.
116
+
117
+ The adapter never stores tensors on `self`, and copies observations off Isaac's
118
+ reused buffers (`.astype`), so a step loop holds no growing references — a
119
+ `tracemalloc` regression test asserts flat RAM over thousands of steps.
120
+
121
+ ## Develop / test
122
+
123
+ The test suite runs **without Isaac** (it checks spaces, semantics, protocol
124
+ conformance, compatibility, and registry wiring):
125
+
126
+ ```bash
127
+ pip install -e ".[dev]"
128
+ pytest -q
129
+ ```
130
+
131
+ ## License
132
+
133
+ MIT.
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "inspect-robots-isaacsim"
7
+ version = "0.1.0"
8
+ description = "IsaacSim / Isaac Lab embodiment adapter for Inspect Robots — run Inspect Robots evals against an Isaac Lab simulation."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Inspect Robots contributors" }]
13
+ keywords = ["robotics", "isaac-sim", "isaac-lab", "inspect_robots", "vla", "evaluation"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ "Typing :: Typed",
21
+ ]
22
+ # NOTE: `isaacsim` / `isaaclab` are intentionally NOT declared here. Isaac Sim and
23
+ # Isaac Lab are not installable from PyPI as ordinary wheels — they are provided by
24
+ # the user's NVIDIA Omniverse / Isaac Lab environment (and need a GPU). This adapter
25
+ # imports them lazily so the package installs, registers, and is discoverable
26
+ # (`inspect-robots list embodiments`) on any machine; only `reset()`/`step()` require Isaac.
27
+ dependencies = ["inspect-robots>=0.2", "numpy>=1.24"]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=8.0", "pytest-cov>=5.0", "mypy>=1.11", "ruff>=0.6"]
31
+
32
+ # Entry-point discovery: an installed inspect-robots-isaacsim appears in `inspect-robots list`
33
+ # without being imported first. The registry calls this factory by the name `isaacsim`.
34
+ [project.entry-points."inspect_robots.embodiments"]
35
+ isaacsim = "inspect_robots_isaacsim:isaacsim_embodiment"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/robocurve/inspect-robots/tree/main/plugins/inspect-robots-isaacsim"
39
+ Repository = "https://github.com/robocurve/inspect-robots"
40
+
41
+ # In the Inspect Robots monorepo this resolves the `inspect_robots` dependency to the in-repo
42
+ # core package instead of PyPI. Harmless in a standalone checkout (uv ignores a
43
+ # workspace source when there is no workspace root).
44
+ [tool.uv.sources]
45
+ inspect-robots = { workspace = true }
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/inspect_robots_isaacsim"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ include = ["src/inspect_robots_isaacsim", "tests", "README.md"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+
56
+ [tool.mypy]
57
+ strict = true
58
+
59
+ # Isaac Sim / Isaac Lab / torch are not installed in lint/test environments and
60
+ # ship no stubs; they are imported lazily inside methods. Don't fail strict on them.
61
+ [[tool.mypy.overrides]]
62
+ module = ["isaaclab.*", "isaaclab_tasks.*", "gymnasium.*", "torch.*"]
63
+ ignore_missing_imports = true
@@ -0,0 +1,36 @@
1
+ """inspect-robots-isaacsim — an Isaac Lab (Isaac Sim) embodiment for Inspect Robots.
2
+
3
+ Install this alongside a working Isaac Lab environment and the ``isaacsim``
4
+ embodiment becomes available to Inspect Robots::
5
+
6
+ inspect-robots list embodiments # -> includes "isaacsim"
7
+ inspect-robots run --task my-task --policy my-vla --embodiment isaacsim \
8
+ -E task_id=Isaac-Lift-Cube-Franka-v0
9
+
10
+ or programmatically::
11
+
12
+ from inspect_robots import eval
13
+ eval("my-task", "my-vla", "isaacsim")
14
+
15
+ The embodiment is discovered via the ``inspect_robots.embodiments`` entry point, so it
16
+ shows up without being imported first.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import Any
22
+
23
+ from inspect_robots_isaacsim.embodiment import IsaacSimEmbodiment
24
+
25
+ __all__ = ["IsaacSimEmbodiment", "isaacsim_embodiment"]
26
+
27
+ __version__ = "0.1.0"
28
+
29
+
30
+ def isaacsim_embodiment(**kwargs: Any) -> IsaacSimEmbodiment:
31
+ """Factory the Inspect Robots registry calls (entry point ``isaacsim``).
32
+
33
+ Accepts the same keyword arguments as :class:`IsaacSimEmbodiment`; the CLI
34
+ forwards ``-E key=value`` pairs here.
35
+ """
36
+ return IsaacSimEmbodiment(**kwargs)
@@ -0,0 +1,327 @@
1
+ """An ``Embodiment`` adapter that wraps an Isaac Lab (Isaac Sim) environment.
2
+
3
+ This is the "body + world" half of a Inspect Robots eval, backed by a real physics
4
+ simulation. It conforms to :class:`inspect_robots.Embodiment` (the runtime-checkable
5
+ protocol), so once installed it can be paired with any compatible
6
+ :class:`inspect_robots.Policy` and run through ``inspect_robots.eval``.
7
+
8
+ Design mirrors Inspect Robots's own ``RerunSink``: the heavy, GPU-bound, non-PyPI
9
+ dependencies (``isaacsim`` / ``isaaclab`` / ``torch``) are imported **lazily,
10
+ inside methods**. Constructing the adapter and reading ``.info`` therefore work
11
+ on any machine (so ``inspect-robots list embodiments`` and the fail-fast compatibility
12
+ check run without Isaac); only :meth:`reset` / :meth:`step` actually launch the
13
+ simulator.
14
+
15
+ Default profile: a 7-DoF **Franka Panda** under **joint-position** control with a
16
+ binary gripper (action dim ``= num_arm_joints + 1``). Everything is configurable
17
+ so any Isaac Lab manipulation task can be wrapped.
18
+
19
+ Version note: Isaac Lab's gym wrapper returns a dict observation grouped by
20
+ ``"policy"`` and batched over ``num_envs``. The obs/action *group keys* and the
21
+ ``success`` signal differ across tasks; the constructor exposes ``obs_group``,
22
+ ``image_keys``, ``state_keys`` and ``success_info_key`` so you can map your task
23
+ without editing this file.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from collections.abc import Mapping, Sequence
29
+ from typing import TYPE_CHECKING, Any
30
+
31
+ import numpy as np
32
+
33
+ from inspect_robots import (
34
+ ActionSemantics,
35
+ Box,
36
+ CameraSpec,
37
+ EmbodimentInfo,
38
+ ObservationSpace,
39
+ Observation,
40
+ Scene,
41
+ StateField,
42
+ StateSpec,
43
+ StepResult,
44
+ )
45
+ from inspect_robots.spaces import CANONICAL_STATE_UNITS
46
+
47
+ if TYPE_CHECKING:
48
+ from inspect_robots import Action
49
+
50
+ # Isaac Lab simulators expose a privileged success oracle, are resettable and
51
+ # seedable, and can render — but they are NOT self-paced (they step as fast as the
52
+ # GPU allows; Inspect Robots owns wall-clock pacing).
53
+ _DEFAULT_CAPABILITIES = frozenset({"seedable", "resettable", "privileged_success", "renderable"})
54
+
55
+ # Isaac Sim allows exactly ONE SimulationApp per process. Track it module-side so a
56
+ # second embodiment instance reuses the live app instead of launching a duplicate
57
+ # (which crashes) or leaking one. Cleared by close().
58
+ _ACTIVE_APP: Any | None = None
59
+
60
+
61
+ def _missing_isaac(exc: ImportError) -> RuntimeError:
62
+ return RuntimeError(
63
+ "Isaac Sim / Isaac Lab is not importable in this environment "
64
+ f"({exc}). The inspect-robots-isaacsim adapter needs a working Isaac Lab "
65
+ "install (NVIDIA Omniverse + GPU). Constructing the embodiment and "
66
+ "reading .info work without it, but reset()/step() require it. "
67
+ "See: https://isaac-sim.github.io/IsaacLab/"
68
+ )
69
+
70
+
71
+ def _default_state_fields(num_arm_joints: int) -> tuple[StateField, ...]:
72
+ """Canonical proprioception for a Franka-like arm (keys/units from Inspect Robots)."""
73
+
74
+ def unit(key: str) -> str:
75
+ return CANONICAL_STATE_UNITS.get(key, "")
76
+
77
+ return (
78
+ StateField("joint_pos", (num_arm_joints,), unit("joint_pos")),
79
+ StateField("joint_vel", (num_arm_joints,), unit("joint_vel")),
80
+ StateField("eef_pos", (3,), unit("eef_pos")),
81
+ StateField("eef_quat", (4,), unit("eef_quat")),
82
+ StateField("gripper", (1,), unit("gripper")),
83
+ )
84
+
85
+
86
+ class IsaacSimEmbodiment:
87
+ """Wrap an Isaac Lab gym task as a Inspect Robots :class:`~inspect_robots.Embodiment`.
88
+
89
+ Parameters
90
+ ----------
91
+ task_id:
92
+ The Isaac Lab / gymnasium task id, e.g. ``"Isaac-Lift-Cube-Franka-v0"``.
93
+ num_arm_joints:
94
+ Number of actuated arm joints (Franka Panda = 7). The action is
95
+ ``num_arm_joints`` joint targets plus one binary gripper command, so the
96
+ action dimension is ``num_arm_joints + 1``.
97
+ cameras:
98
+ Camera streams the task renders, as ``(name, height, width[, channels])``
99
+ tuples. Match the names your policy requires (Inspect Robots remaps if needed).
100
+ headless / device:
101
+ Forwarded to Isaac's ``AppLauncher`` / env. ``headless=True`` is required
102
+ on machines without a display (the usual eval box).
103
+ obs_group / image_keys / state_keys / success_info_key:
104
+ How to read the task's observation dict and success flag (see module docs).
105
+ name / supported_setups / supported_target_kinds:
106
+ Surfaced on ``EmbodimentInfo`` for logging and R7 scene-realizability
107
+ checks. Empty ``supported_*`` means "unconstrained".
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ task_id: str = "Isaac-Lift-Cube-Franka-v0",
113
+ *,
114
+ num_arm_joints: int = 7,
115
+ cameras: Sequence[tuple[str, int, int] | tuple[str, int, int, int]] = (
116
+ ("base_rgb", 224, 224, 3),
117
+ ),
118
+ control_hz: float = 30.0,
119
+ headless: bool = True,
120
+ device: str = "cuda:0",
121
+ obs_group: str = "policy",
122
+ image_keys: Mapping[str, str] | None = None,
123
+ state_keys: Mapping[str, str] | None = None,
124
+ success_info_key: str = "success",
125
+ name: str = "isaacsim",
126
+ supported_setups: Sequence[str] = (),
127
+ supported_target_kinds: Sequence[str] = (),
128
+ ) -> None:
129
+ if num_arm_joints < 1:
130
+ raise ValueError("num_arm_joints must be >= 1")
131
+
132
+ self.task_id = task_id
133
+ self.num_arm_joints = num_arm_joints
134
+ self.headless = headless
135
+ self.device = device
136
+ self.obs_group = obs_group
137
+ # Map Inspect Robots obs keys -> the task's raw dict keys. Identity by default.
138
+ self.image_keys = dict(image_keys or {})
139
+ self.state_keys = dict(state_keys or {})
140
+ self.success_info_key = success_info_key
141
+
142
+ camera_specs = tuple(CameraSpec(c[0], c[1], c[2], *(c[3:] or (3,))) for c in cameras)
143
+ action_dim = num_arm_joints + 1 # arm joint targets + 1 binary gripper
144
+
145
+ self.info = EmbodimentInfo(
146
+ name=name,
147
+ action_space=Box(
148
+ shape=(action_dim,),
149
+ semantics=ActionSemantics(
150
+ control_mode="joint_pos",
151
+ rotation_repr="none",
152
+ gripper="binary",
153
+ frame="base",
154
+ ),
155
+ ),
156
+ observation_space=ObservationSpace(
157
+ cameras=camera_specs,
158
+ state=StateSpec(fields=_default_state_fields(num_arm_joints)),
159
+ ),
160
+ control_hz=control_hz,
161
+ is_simulated=True,
162
+ capabilities=_DEFAULT_CAPABILITIES,
163
+ supported_setups=frozenset(supported_setups),
164
+ supported_target_kinds=frozenset(supported_target_kinds),
165
+ )
166
+
167
+ # Lazily-initialised Isaac handles (None until first reset()).
168
+ self._app: Any | None = None
169
+ self._env: Any | None = None
170
+ self._torch: Any | None = None
171
+
172
+ # ------------------------------------------------------------------ #
173
+ # Lazy Isaac bring-up (app launch and env creation are separable so the
174
+ # simulator boot can be validated independently of any task registry)
175
+ # ------------------------------------------------------------------ #
176
+ def _ensure_app(self) -> Any:
177
+ """Launch the Isaac Sim ``SimulationApp`` on first use and return it.
178
+
179
+ If another instance already launched the app this process, reuse it —
180
+ Isaac Sim is a hard process singleton, so launching twice would crash.
181
+ """
182
+ global _ACTIVE_APP
183
+ if self._app is not None:
184
+ return self._app
185
+ try:
186
+ import torch
187
+ except ImportError as exc: # pragma: no cover - exercised only without Isaac
188
+ raise _missing_isaac(exc) from exc
189
+ self._torch = torch
190
+
191
+ if _ACTIVE_APP is not None:
192
+ self._app = _ACTIVE_APP
193
+ return self._app
194
+
195
+ try:
196
+ from isaaclab.app import AppLauncher
197
+ except ImportError as exc: # pragma: no cover - exercised only without Isaac
198
+ raise _missing_isaac(exc) from exc
199
+ self._app = AppLauncher(headless=self.headless, device=self.device).app
200
+ _ACTIVE_APP = self._app
201
+ return self._app
202
+
203
+ def _ensure_env(self) -> Any:
204
+ """Build the gym env on first use (boots the app if needed)."""
205
+ if self._env is not None:
206
+ return self._env
207
+ self._ensure_app()
208
+ # Importing the tasks registers the gym ids; must happen AFTER the app boots.
209
+ import gymnasium as gym
210
+ import isaaclab_tasks # noqa: F401 (registers Isaac-* gym ids)
211
+
212
+ self._env = gym.make(self.task_id, num_envs=1, render_mode="rgb_array")
213
+ return self._env
214
+
215
+ # ------------------------------------------------------------------ #
216
+ # Embodiment protocol
217
+ # ------------------------------------------------------------------ #
218
+ def reset(self, scene: Scene, *, seed: int | None = None) -> Observation:
219
+ env = self._ensure_env()
220
+ obs, _info = env.reset(seed=seed)
221
+ return self._to_observation(obs, scene.instruction)
222
+
223
+ def step(self, action: Action) -> StepResult:
224
+ env = self._ensure_env()
225
+ torch = self._torch
226
+ assert torch is not None # set alongside _env
227
+ tensor = torch.as_tensor(
228
+ np.asarray(action.data, dtype=np.float32), device=self.device
229
+ ).reshape(1, -1)
230
+ obs, reward, terminated, truncated, info = env.step(tensor)
231
+
232
+ success = self._read_success(info, terminated)
233
+ term = bool(_scalar(terminated))
234
+ return StepResult(
235
+ observation=self._to_observation(obs, None),
236
+ reward=float(_scalar(reward)),
237
+ terminated=term,
238
+ termination_reason="success" if (term and success) else None,
239
+ truncated=bool(_scalar(truncated)),
240
+ info={"success": success},
241
+ )
242
+
243
+ def close(self) -> None:
244
+ """Release the gym env and Isaac Sim app (and the GPU memory they hold).
245
+
246
+ Idempotent: safe to call before launch or twice. ``eval()`` closes what
247
+ it resolves — an embodiment looked up by registry name is closed when
248
+ the run finishes, but an embodiment object you construct is yours to
249
+ close. Use it as a context manager (or call ``close()`` in a
250
+ ``finally``) to guarantee the simulator is torn down and GPU memory is
251
+ freed when a run ends.
252
+ """
253
+ global _ACTIVE_APP
254
+ if self._env is not None:
255
+ self._env.close()
256
+ self._env = None
257
+ if self._app is not None:
258
+ self._app.close()
259
+ if _ACTIVE_APP is self._app:
260
+ _ACTIVE_APP = None
261
+ self._app = None
262
+ self._torch = None
263
+
264
+ def __enter__(self) -> IsaacSimEmbodiment:
265
+ return self
266
+
267
+ def __exit__(self, *exc_info: object) -> None:
268
+ self.close()
269
+
270
+ # ------------------------------------------------------------------ #
271
+ # Translation: Isaac Lab dict/tensors <-> Inspect Robots types
272
+ # ------------------------------------------------------------------ #
273
+ def _to_observation(self, raw: Any, instruction: str | None) -> Observation:
274
+ group = raw[self.obs_group] if isinstance(raw, Mapping) and self.obs_group in raw else raw
275
+ images: dict[str, np.ndarray] = {}
276
+ state: dict[str, np.ndarray] = {}
277
+
278
+ for cam in self.info.observation_space.cameras:
279
+ key = self.image_keys.get(cam.name, cam.name)
280
+ if isinstance(group, Mapping) and key in group:
281
+ images[cam.name] = _to_image(group[key])
282
+
283
+ if self.info.observation_space.state is not None:
284
+ for field in self.info.observation_space.state.fields:
285
+ key = self.state_keys.get(field.key, field.key)
286
+ if isinstance(group, Mapping) and key in group:
287
+ state[field.key] = _to_float_array(group[key])
288
+
289
+ return Observation(images=images, state=state, instruction=instruction)
290
+
291
+ def _read_success(self, info: Any, terminated: Any) -> bool:
292
+ if isinstance(info, Mapping) and self.success_info_key in info:
293
+ return bool(_scalar(info[self.success_info_key]))
294
+ # Fall back to "terminated implies success" when the task exposes no oracle.
295
+ return bool(_scalar(terminated))
296
+
297
+
298
+ # --------------------------------------------------------------------------- #
299
+ # Tensor/array helpers (kept torch-free at import time; operate duck-typed)
300
+ # --------------------------------------------------------------------------- #
301
+ def _np(value: Any) -> np.ndarray:
302
+ """Best-effort conversion of a (possibly GPU torch) value to a NumPy array."""
303
+ if hasattr(value, "detach"): # torch.Tensor
304
+ value = value.detach().cpu().numpy()
305
+ return np.asarray(value)
306
+
307
+
308
+ def _scalar(value: Any) -> float:
309
+ arr = _np(value).reshape(-1)
310
+ return float(arr[0]) if arr.size else 0.0
311
+
312
+
313
+ def _to_float_array(value: Any) -> np.ndarray:
314
+ arr = _np(value).astype(np.float64)
315
+ # Drop the leading num_envs axis (we run num_envs=1).
316
+ return arr[0] if arr.ndim >= 1 and arr.shape[0] == 1 else arr
317
+
318
+
319
+ def _to_image(value: Any) -> np.ndarray:
320
+ arr = _np(value)
321
+ if arr.ndim >= 1 and arr.shape[0] == 1: # drop num_envs axis
322
+ arr = arr[0]
323
+ if arr.ndim == 3 and arr.shape[0] in (1, 3, 4) and arr.shape[-1] not in (1, 3, 4):
324
+ arr = np.transpose(arr, (1, 2, 0)) # CHW -> HWC
325
+ if np.issubdtype(arr.dtype, np.floating):
326
+ arr = np.clip(arr * 255.0 if arr.max() <= 1.0 else arr, 0, 255)
327
+ return arr.astype(np.uint8)
@@ -0,0 +1,246 @@
1
+ """Tests for the Isaac Lab embodiment adapter.
2
+
3
+ These run on any machine — Isaac Sim is NOT required. They verify the parts that
4
+ matter before a simulator ever boots: the declared spaces/semantics, protocol
5
+ conformance, registry resolution, Inspect Robots compatibility checking, and that
6
+ calling reset() without Isaac fails with a clear, actionable error.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import numpy as np
14
+ import pytest
15
+
16
+ from inspect_robots import (
17
+ ActionSemantics,
18
+ Box,
19
+ Embodiment,
20
+ ObservationSpace,
21
+ PolicyConfig,
22
+ PolicyInfo,
23
+ Scene,
24
+ )
25
+ from inspect_robots.compat import check_compatibility
26
+ from inspect_robots.errors import CompatibilityError
27
+ from inspect_robots.types import ActionChunk, Observation
28
+
29
+ from inspect_robots_isaacsim import IsaacSimEmbodiment, isaacsim_embodiment
30
+
31
+ _FRANKA_SEM = ActionSemantics(
32
+ control_mode="joint_pos", rotation_repr="none", gripper="binary", frame="base"
33
+ )
34
+
35
+
36
+ class _FrankaPolicy:
37
+ """A minimal policy whose spaces match the default Franka profile (dim 8)."""
38
+
39
+ def __init__(self) -> None:
40
+ self.info = PolicyInfo(
41
+ name="franka-stub",
42
+ action_space=Box(shape=(8,), semantics=_FRANKA_SEM),
43
+ observation_space=ObservationSpace(state_keys=frozenset({"joint_pos"})),
44
+ )
45
+ self.config = PolicyConfig(action_horizon=1)
46
+
47
+ def reset(self, scene: Scene) -> None: ...
48
+
49
+ def act(self, observation: Observation) -> ActionChunk: # pragma: no cover - unused
50
+ from inspect_robots import Action
51
+
52
+ return ActionChunk(actions=[Action(data=np.zeros(8))])
53
+
54
+
55
+ def test_info_describes_franka_joint_pos() -> None:
56
+ emb = IsaacSimEmbodiment()
57
+ assert emb.info.name == "isaacsim"
58
+ assert emb.info.is_simulated is True
59
+ # 7 arm joints + 1 binary gripper command.
60
+ assert emb.info.action_space.dim == 8
61
+ sem = emb.info.action_space.semantics
62
+ assert sem is not None
63
+ assert sem.control_mode == "joint_pos"
64
+ assert sem.gripper == "binary"
65
+ assert "privileged_success" in emb.info.capabilities
66
+ assert "self_paced" not in emb.info.capabilities # sim is framework-paced
67
+
68
+
69
+ def test_action_dim_tracks_arm_joints() -> None:
70
+ assert IsaacSimEmbodiment(num_arm_joints=6).info.action_space.dim == 7
71
+
72
+
73
+ def test_rejects_bad_joint_count() -> None:
74
+ with pytest.raises(ValueError):
75
+ IsaacSimEmbodiment(num_arm_joints=0)
76
+
77
+
78
+ def test_observation_space_has_state_and_cameras() -> None:
79
+ emb = IsaacSimEmbodiment()
80
+ obs_space = emb.info.observation_space
81
+ assert "base_rgb" in obs_space.camera_names
82
+ assert {"joint_pos", "joint_vel", "eef_pos", "eef_quat", "gripper"} <= obs_space.state_keys
83
+
84
+
85
+ def test_satisfies_embodiment_protocol() -> None:
86
+ assert isinstance(IsaacSimEmbodiment(), Embodiment)
87
+
88
+
89
+ def test_factory_and_kwargs() -> None:
90
+ emb = isaacsim_embodiment(task_id="Isaac-Open-Drawer-Franka-v0", control_hz=20.0)
91
+ assert isinstance(emb, IsaacSimEmbodiment)
92
+ assert emb.task_id == "Isaac-Open-Drawer-Franka-v0"
93
+ assert emb.info.control_hz == 20.0
94
+
95
+
96
+ def test_compatible_with_matching_franka_policy() -> None:
97
+ report = check_compatibility(_FrankaPolicy(), IsaacSimEmbodiment())
98
+ assert report.ok, report.errors
99
+
100
+
101
+ def test_incompatible_with_2d_policy_fails_fast() -> None:
102
+ class _2DPolicy(_FrankaPolicy):
103
+ def __init__(self) -> None:
104
+ self.info = PolicyInfo(
105
+ name="cube2d",
106
+ action_space=Box(
107
+ shape=(2,),
108
+ semantics=ActionSemantics(control_mode="eef_delta_pos", frame="world"),
109
+ ),
110
+ )
111
+ self.config = PolicyConfig()
112
+
113
+ report = check_compatibility(_2DPolicy(), IsaacSimEmbodiment())
114
+ assert not report.ok
115
+ codes = {i.code for i in report.errors}
116
+ assert "action_dim" in codes
117
+ with pytest.raises(CompatibilityError):
118
+ report.raise_for_errors()
119
+
120
+
121
+ def test_reset_without_isaac_raises_clear_error() -> None:
122
+ emb = IsaacSimEmbodiment()
123
+ with pytest.raises(RuntimeError, match="Isaac Sim / Isaac Lab is not importable"):
124
+ emb.reset(Scene(id="s0", instruction="lift the cube"))
125
+
126
+
127
+ def test_close_is_safe_before_launch_and_idempotent() -> None:
128
+ emb = IsaacSimEmbodiment()
129
+ emb.close() # no env/app yet -> no-op, must not raise
130
+ emb.close() # second call must also be safe
131
+
132
+
133
+ def test_context_manager_calls_close() -> None:
134
+ closed = {"n": 0}
135
+
136
+ class _Emb(IsaacSimEmbodiment):
137
+ def close(self) -> None:
138
+ closed["n"] += 1
139
+
140
+ with _Emb() as emb:
141
+ assert isinstance(emb, IsaacSimEmbodiment)
142
+ assert closed["n"] == 1
143
+
144
+
145
+ # --------------------------------------------------------------------------- #
146
+ # Memory-safety: drive reset()/step() many times against a fake Isaac env and
147
+ # assert the adapter accumulates no state and leaks no RAM. Runs without Isaac.
148
+ # --------------------------------------------------------------------------- #
149
+ class _FakeIsaacEnv:
150
+ """A stand-in for the Isaac Lab gym env: dict obs batched over num_envs=1."""
151
+
152
+ def __init__(self, action_dim: int) -> None:
153
+ self.action_dim = action_dim
154
+ self.reset_calls = 0
155
+ self.step_calls = 0
156
+
157
+ def _obs(self) -> dict[str, dict[str, np.ndarray]]:
158
+ # Fresh arrays each call, exactly like Isaac handing back buffer reads.
159
+ return {
160
+ "policy": {
161
+ "base_rgb": np.zeros((1, 224, 224, 3), dtype=np.uint8),
162
+ "joint_pos": np.zeros((1, 7), dtype=np.float32),
163
+ "joint_vel": np.zeros((1, 7), dtype=np.float32),
164
+ "eef_pos": np.zeros((1, 3), dtype=np.float32),
165
+ "eef_quat": np.zeros((1, 4), dtype=np.float32),
166
+ "gripper": np.zeros((1, 1), dtype=np.float32),
167
+ }
168
+ }
169
+
170
+ def reset(self, seed: int | None = None) -> tuple[Any, dict[str, Any]]:
171
+ self.reset_calls += 1
172
+ return self._obs(), {}
173
+
174
+ def step(self, action: Any) -> tuple[Any, Any, Any, Any, dict[str, Any]]:
175
+ self.step_calls += 1
176
+ return (
177
+ self._obs(),
178
+ np.array([0.0]),
179
+ np.array([False]),
180
+ np.array([False]),
181
+ {"success": False},
182
+ )
183
+
184
+ def close(self) -> None: ...
185
+
186
+
187
+ class _FakeTorch:
188
+ @staticmethod
189
+ def as_tensor(value: Any, device: str | None = None) -> np.ndarray:
190
+ return np.asarray(value)
191
+
192
+
193
+ def _inject_fake(emb: IsaacSimEmbodiment) -> _FakeIsaacEnv:
194
+ fake = _FakeIsaacEnv(emb.info.action_space.dim)
195
+ emb._env = fake
196
+ emb._torch = _FakeTorch()
197
+ return fake
198
+
199
+
200
+ def test_step_translation_against_fake_env() -> None:
201
+ from inspect_robots import Action
202
+
203
+ emb = IsaacSimEmbodiment()
204
+ _inject_fake(emb)
205
+ emb.reset(Scene(id="s", instruction="lift"))
206
+ result = emb.step(Action(data=np.zeros(8)))
207
+ assert set(result.observation.images) == {"base_rgb"}
208
+ assert result.observation.images["base_rgb"].dtype == np.uint8
209
+ assert {"joint_pos", "eef_pos", "gripper"} <= set(result.observation.state)
210
+ assert result.terminated is False
211
+ assert result.info["success"] is False
212
+
213
+
214
+ def test_no_ram_leak_over_many_steps() -> None:
215
+ """RAM must stay flat over thousands of steps (caller drops each result)."""
216
+ import gc
217
+ import tracemalloc
218
+
219
+ from inspect_robots import Action
220
+
221
+ emb = IsaacSimEmbodiment()
222
+ fake = _inject_fake(emb)
223
+ act = Action(data=np.zeros(8, dtype=np.float32))
224
+
225
+ # Warm up so one-time allocations (interned strings, caches) settle.
226
+ emb.reset(Scene(id="s", instruction="lift"))
227
+ for _ in range(200):
228
+ emb.step(act)
229
+
230
+ gc.collect()
231
+ tracemalloc.start()
232
+ before = tracemalloc.take_snapshot()
233
+ for _ in range(3000):
234
+ emb.step(act) # result intentionally dropped each iteration
235
+ gc.collect()
236
+ after = tracemalloc.take_snapshot()
237
+ tracemalloc.stop()
238
+
239
+ grew = sum(s.size_diff for s in after.compare_to(before, "filename") if s.size_diff > 0)
240
+ # 3000 steps each allocating a 150KB image would be ~450MB if retained; a
241
+ # leak-free loop holds nothing, so allow only a small slack for allocator noise.
242
+ assert grew < 5_000_000, f"RAM grew {grew} bytes over 3000 steps; suspect a leak"
243
+ # The adapter itself must hold no per-step accumulation.
244
+ assert fake.step_calls == 3200
245
+ for value in vars(emb).values():
246
+ assert not isinstance(value, (list, dict)) or len(value) <= 1