Source code for isaaclab.envs.leapp_deployment_env

# 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

"""Deployment environment that runs LEAPP-exported policies in simulation.

This environment bypasses all Isaac Lab managers (observation, action, reward, etc.)
and instead wires scene entity data properties and ``CommandManager`` outputs directly
to a LEAPP ``InferenceManager``, then writes the model outputs back to the
corresponding scene entities.  All I/O resolution is driven by the
``isaaclab_connection`` field in the LEAPP YAML.
"""

from __future__ import annotations

import inspect
import logging
from dataclasses import dataclass
from typing import Any, cast

import torch
import yaml

try:
    from leapp import InferenceManager
except ImportError as e:
    raise ImportError("LEAPP package is required for policy deployment testing. Install with: pip install leapp") from e

from isaaclab.managers import CommandManager, EventManager
from isaaclab.scene import InteractiveScene
from isaaclab.sim import SimulationContext
from isaaclab.sim.utils.stage import use_stage

logger = logging.getLogger(__name__)


# ══════════════════════════════════════════════════════════════════
# I/O spec dataclasses
# ══════════════════════════════════════════════════════════════════


@dataclass
class StateInputSpec:
    """Read a property from a scene entity's data object."""

    entity_name: str
    property_name: str
    joint_ids: list[int] | None = None


@dataclass
class CommandInputSpec:
    """Read a command tensor from ``CommandManager``."""

    command_term_name: str


@dataclass
class WriteOutputSpec:
    """Write a tensor to a scene entity method, optionally indexed by joint."""

    entity_name: str
    method_name: str
    value_param: str
    joint_ids: list[int] | None = None


# ══════════════════════════════════════════════════════════════════
# Connection-string helpers
# ══════════════════════════════════════════════════════════════════


def _resolve_joint_ids(element_names: list | None, entity: Any) -> list[int] | None:
    """Convert ``element_names[0]`` joint names to integer joint indices.

    Args:
        element_names: LEAPP element-name metadata for the tensor, or ``None``
            when the tensor does not define named elements.
        entity: Scene entity that may provide ``joint_names`` and
            ``find_joints()`` for name-to-index resolution.

    Returns:
        Joint indices matching ``element_names[0]``, or ``None`` when no
        slicing is needed because all joints are selected, the tensor is not
        joint-indexed, or the entity does not support joint lookup.
    """
    if element_names is None or not hasattr(entity, "find_joints"):
        return None

    # leapp tensor semantics will always store the array in a nested list of lists.
    # NOTE: this is added in explicitly to handle partial joint application. currently
    # this environment does not handle element reordering yet. Thus, this function
    # is specialized to handle joints, hence reading index 0.
    joint_names = element_names[0]
    if not isinstance(joint_names, list) or not joint_names:
        return None
    entity_joint_names = list(entity.joint_names)
    # Only resolve indices when the leading element-name axis actually refers
    # to a subset of this articulation's joints. Other tensors can use axis
    # labels like ["x", "y", "z"] or body names in the first axis.
    matching_joint_names = [name for name in joint_names if name in entity_joint_names]
    if not matching_joint_names:
        return None
    if len(matching_joint_names) != len(joint_names):
        raise ValueError(
            f"LEAPP element names mix joint and non-joint labels for an articulation-backed tensor: {joint_names}"
        )
    if joint_names == entity_joint_names:
        return None
    joint_ids, _ = entity.find_joints(joint_names, preserve_order=True)
    return joint_ids


def _first_param_name(method: Any) -> str:
    """Return the name of the first non-self parameter of *method*.

    Expects a bound method — ``inspect.signature`` on a bound method
    already excludes ``self``, so ``params[0]`` is the first real parameter.

    Args:
        method: Bound method whose first callable parameter should be
            inspected.

    Returns:
        The name of the first non-``self`` parameter.
    """
    params = list(inspect.signature(method).parameters.values())
    if not params:
        raise TypeError(f"{method} has no parameters")
    return params[0].name


# ══════════════════════════════════════════════════════════════════
# LeappDeploymentEnv
# ══════════════════════════════════════════════════════════════════


[docs] class LeappDeploymentEnv: """Runs a LEAPP-exported policy in an Isaac Lab scene. The environment sets up the simulation scene and physics from a standard Isaac Lab config, then wires raw sensor/command data to a LEAPP ``InferenceManager`` and writes the model outputs back to the corresponding scene entities. I/O wiring is driven entirely by the ``isaaclab_connection`` metadata field in the LEAPP YAML. Each connection string encodes the type of access, the scene entity name, and the property or method to call: - ``state:{entity}:{property}`` -- read ``scene[entity].data.{property}`` - ``command:{name}`` -- read ``command_manager.get_command(name)`` - ``write:{entity}:{method}`` -- call ``scene[entity].{method}(tensor, ...)`` No observation, action, reward, termination, or curriculum managers are used. The LEAPP model already contains all pre/post-processing. """
[docs] def __init__(self, cfg: Any, leapp_yaml_path: str): """Initialize the deployment environment. Args: cfg: A ``ManagerBasedRLEnvCfg`` (or compatible) task config. leapp_yaml_path: Path to the LEAPP ``.yaml`` pipeline description. """ cfg.scene.num_envs = 1 cfg.validate() self.cfg = cfg self._is_closed = False self._leapp_yaml_path = leapp_yaml_path self._step_count = 0 self._sim_step_counter = 0 self.extras: dict = {} # ── Simulation + scene ──────────────────────────────────── self.sim = SimulationContext(cfg.sim) if "cuda" in self.sim.device: torch.cuda.set_device(self.sim.device) with use_stage(self.sim.stage): self.scene = InteractiveScene(cfg.scene) with use_stage(self.sim.stage): self.sim.reset() self.scene.update(dt=self.physics_dt) self.has_rtx_sensors = bool(self.sim.get_setting("/isaaclab/render/rtx_sensors")) # ── EventManager (optional, for resets) ─────────────────── self.event_manager: EventManager | None = None if hasattr(cfg, "events") and cfg.events is not None: self.event_manager = EventManager(cfg.events, cast(Any, self)) # ── CommandManager (optional, for command/* inputs) ─────── self.command_manager: CommandManager | None = None if hasattr(cfg, "commands") and cfg.commands is not None: self.command_manager = CommandManager(cfg.commands, cast(Any, self)) # ── LEAPP InferenceManager ──────────────────────────────── self.inference = InferenceManager(leapp_yaml_path) # ── Parse YAML and resolve I/O mappings ─────────────────── with open(leapp_yaml_path) as f: self._leapp_desc = yaml.safe_load(f) self._input_mapping: dict[str, StateInputSpec | CommandInputSpec] = {} self._output_mapping: dict[str, WriteOutputSpec] = {} self._resolve_io() logger.info( "LeappDeploymentEnv ready — %d inputs, %d outputs mapped", len(self._input_mapping), len(self._output_mapping), ) if self.sim.has_gui and getattr(self.cfg, "ui_window_class_type", None) is not None: self._window = self.cfg.ui_window_class_type(self, window_name="IsaacLab") else: self._window = None
# ── Properties ──────────────────────────────────────────────── @property def num_envs(self) -> int: return 1 @property def physics_dt(self) -> float: return self.cfg.sim.dt @property def step_dt(self) -> float: return self.cfg.sim.dt * self.cfg.decimation @property def device(self) -> str: return self.sim.device # ── I/O Resolution ──────────────────────────────────────────── def _resolve_io(self): """Build ``_input_mapping`` and ``_output_mapping`` from LEAPP metadata. Parses the ``isaaclab_connection`` field in the loaded LEAPP YAML and resolves each declared input/output to the corresponding scene entity, command term, and optional joint index selection. """ pipeline = self._leapp_desc["pipeline"] for node_name, input_names in pipeline["inputs"].items(): node = self.inference.nodes[node_name] desc_by_name = {d["name"]: d for d in node.input_descriptions} for input_name in input_names: desc = desc_by_name[input_name] connection = desc.get("isaaclab_connection") if connection is None: continue key = f"{node_name}/{input_name}" parts = connection.split(":") conn_type = parts[0] if conn_type == "state": entity_name, prop_name = parts[1], parts[2] entity = self.scene[entity_name] jids = _resolve_joint_ids(desc.get("element_names"), entity) self._input_mapping[key] = StateInputSpec( entity_name=entity_name, property_name=prop_name, joint_ids=jids, ) elif conn_type == "command": command_name = parts[1] if self.command_manager is None: raise RuntimeError( f"LEAPP input '{key}' requires command '{command_name}' but no " "CommandManager is available (cfg.commands is None)." ) self._input_mapping[key] = CommandInputSpec(command_term_name=command_name) else: logger.warning("Unknown connection type '%s' for input '%s'", conn_type, key) for node_name, output_names in pipeline["outputs"].items(): node = self.inference.nodes[node_name] desc_by_name = {d["name"]: d for d in node.output_descriptions} for output_name in output_names: desc = desc_by_name[output_name] connection = desc.get("isaaclab_connection") if connection is None: continue key = f"{node_name}/{output_name}" parts = connection.split(":") conn_type = parts[0] if conn_type == "write": entity_name, method_name = parts[1], parts[2] entity = self.scene[entity_name] jids = _resolve_joint_ids(desc.get("element_names"), entity) value_param = _first_param_name(getattr(entity, method_name)) self._output_mapping[key] = WriteOutputSpec( entity_name=entity_name, method_name=method_name, value_param=value_param, joint_ids=jids, ) else: logger.warning("Unknown connection type '%s' for output '%s'", conn_type, key) # ── Read / Write ────────────────────────────────────────────── def _read_inputs(self) -> dict[str, torch.Tensor]: """Read all mapped inputs from scene entities and command manager. Returns: A mapping from ``"node_name/tensor_name"`` to the tensor value that should be passed to the LEAPP inference pipeline. """ inputs: dict[str, torch.Tensor] = {} for key, spec in self._input_mapping.items(): if isinstance(spec, StateInputSpec): entity = self.scene[spec.entity_name] value = getattr(entity.data, spec.property_name).torch if spec.joint_ids is not None: value = value[:, spec.joint_ids] inputs[key] = value elif isinstance(spec, CommandInputSpec): command_manager = self.command_manager assert command_manager is not None inputs[key] = command_manager.get_command(spec.command_term_name) return inputs def _write_outputs(self, outputs: dict[str, torch.Tensor]): """Write model outputs to scene entities. Args: outputs: Model outputs keyed by ``"node_name/tensor_name"`` as returned by :meth:`step` and ``InferenceManager.run_policy()``. """ for key, tensor in outputs.items(): spec = self._output_mapping.get(key) if spec is None: continue entity = self.scene[spec.entity_name] method = getattr(entity, spec.method_name) if spec.joint_ids is not None: method(**{spec.value_param: tensor, "joint_ids": spec.joint_ids}) else: method(**{spec.value_param: tensor}) # ── Public API ────────────────────────────────────────────────
[docs] def reset(self) -> dict[str, torch.Tensor]: """Reset the scene and inference state. Returns: The initial input tensors (for logging / debugging). """ env_ids = torch.arange(self.num_envs, dtype=torch.int32, device=self.device) self.scene.reset(env_ids) if self.event_manager is not None and "reset" in self.event_manager.available_modes: self.event_manager.apply(mode="reset", env_ids=env_ids, global_env_step_count=self._step_count) if self.command_manager is not None: self.command_manager.reset(env_ids) self.scene.write_data_to_sim() self.sim.forward() self.scene.update(dt=self.physics_dt) # If RTX sensors are present, rerender after reset to refresh their outputs. if self.has_rtx_sensors and getattr(self.cfg, "num_rerenders_on_reset", 0) > 0: for _ in range(self.cfg.num_rerenders_on_reset): self.sim.render() if getattr(self.cfg, "wait_for_textures", False) and self.has_rtx_sensors: assets_loading = getattr(self.sim.physics_manager, "assets_loading", None) if callable(assets_loading): while assets_loading(): self.sim.render() self.inference.reset() return self._read_inputs()
[docs] def step(self, external_inputs: dict[str, torch.Tensor] | None = None) -> dict[str, torch.Tensor]: """Run one environment step: read -> infer -> write -> physics. Args: external_inputs: Optional overrides keyed by ``"ModelName/input_name"``. Takes precedence over auto-resolved state/command values. Returns: The dict of pipeline outputs from ``InferenceManager.run_policy()``. """ self._step_count += 1 # 1. Update commands if self.command_manager is not None: self.command_manager.compute(dt=self.step_dt) # 2. Read inputs inputs = self._read_inputs() # 3. Merge external overrides if external_inputs is not None: inputs.update(external_inputs) # 4. Infer with torch.inference_mode(): outputs = self.inference.run_policy(inputs) # 5. Write outputs to scene entities self._write_outputs(outputs) # 6. Decimation loop is_rendering = self.sim.is_rendering for _ in range(self.cfg.decimation): self._sim_step_counter += 1 self.scene.write_data_to_sim() self.sim.step(render=False) if self._sim_step_counter % self.cfg.sim.render_interval == 0 and is_rendering: self.sim.render() self.scene.update(dt=self.physics_dt) return outputs
[docs] def close(self): """Clean up the environment and release simulator-owned resources.""" if not self._is_closed: self.sim.stop() if self.command_manager is not None: del self.command_manager if self.event_manager is not None: del self.event_manager del self.scene self.sim.clear_instance() if self._window is not None: self._window = None self._is_closed = True