Skip to content

Introduce a Custom Environment ​

TIP

Install with pip install telekinesis-rlbotics — see Install Support for Reinforcement Learning for the simulator backends.

SUMMARY

Nothing in the library is tied to a simulator. Implement VecEnv — three methods and a handful of attributes — and the runner trains against it.

For a humanoid or quadruped already in mjlab or Isaac Lab there is no VecEnv to write: subclass the adapter instead, and override only what your task needs.

Start from this stub ​

Copy it, fill the blocks, delete the NotImplementedErrors as you go.

python
import torch
from tensordict import TensorDict

from telekinesis.rlbotics.envs.base import VecEnv


class MyEnv(VecEnv):
    """One environment stepped in parallel across num_envs copies."""

    def __init__(self, num_envs: int = 64, device: str = "cpu") -> None:
        # --- required by the runner ---
        self.num_envs = num_envs
        self.num_actions = ...              # fill: usually the joint count
        self.device = torch.device(device)
        self.max_episode_length = ...       # fill: steps before the time limit cuts an episode
        self.cfg = {}                       # fill: your own config, for logging

        # --- optional ---
        # Set these only if actions really are bounded (e.g. joint limits). export() then bakes
        # the clipping and scaling into the ONNX graph. Leave them off for raw joint targets.
        # self.action_low = torch.full((self.num_actions,), -1.0, device=self.device)
        # self.action_high = torch.full((self.num_actions,), 1.0, device=self.device)

        # Needed only for learn(init_at_random_ep_len=True)
        self.episode_length_buf = torch.zeros(num_envs, dtype=torch.long, device=self.device)

        self.reset()

    def _observations(self) -> TensorDict:
        """Build the observation groups. The names here are what obs_groups refers to."""
        raise NotImplementedError(
            # fill: one entry per group, each shaped (num_envs, ...)
            # return TensorDict(
            #     {
            #         "policy": ...,        # what the robot can measure
            #         "privileged": ...,    # optional: what only the simulator knows
            #     },
            #     batch_size=(self.num_envs,),
            #     device=self.device,
            # )
        )

    def reset(self) -> TensorDict:
        """Reset every environment and return the observation they start from."""
        raise NotImplementedError  # fill

    def get_observations(self) -> TensorDict:
        """Return the current observations without stepping."""
        return self._observations()

    def step(self, actions: torch.Tensor):
        """Apply one batch of actions and report the outcome."""
        # 1. Advance the simulation
        raise NotImplementedError  # fill

        # 2. Reward and episode end
        # rewards = ...                                  # (num_envs,)
        # terminated = ...                               # (num_envs,) bool: the task failed
        # time_outs = self.episode_length_buf >= self.max_episode_length
        # dones = terminated | time_outs

        # 3. Reset the finished environments now, so every step returned is a real transition
        # finished = dones.nonzero(as_tuple=False).squeeze(-1)
        # if finished.numel() > 0:
        #     self._respawn(finished)

        # 4. time_outs kept separate from dones, so a truncated episode is bootstrapped
        # return self._observations(), rewards, dones, {"time_outs": time_outs}

Train it:

python
from telekinesis.rlbotics.config import MLPConfig, OnPolicyRunnerConfig
from telekinesis.rlbotics.runner import create_runner

env = MyEnv(num_envs=64)
cfg = OnPolicyRunnerConfig(
    obs_groups={"actor": ["policy"], "critic": ["policy", "privileged"]},
    num_learning_iterations=300,
    seed=0,                 # reproducible while you are debugging
    check_for_nan=True,     # catches a bad reward or observation before it reaches the models
)
runner = create_runner(env=env, runner_cfg=cfg, device="cpu")
runner.learn()
runner.export()

What the runner reads ​

AttributeRequiredWhat it is
num_envsyesEnvironments stepped in parallel. 1 for a real robot
num_actionsyesSize of the action vector one environment expects
deviceyesDevice the observations, rewards and dones are placed on
max_episode_lengthyesSteps before the time limit cuts an episode off
cfgyesYour own configuration, for logging and introspection
episode_length_bufoptionalSteps each environment is into its episode
action_low / action_highoptionalAction bounds. Set them and export() folds the scaling into the graph
render() / render_fpsoptionalVideo recording. Without them log_video: true warns and writes nothing
close() / seed(seed)optionalCleanup, and seeding the simulation — runner.seed covers only the networks and sampling

VecEnv is an interface, not a base class that holds state: it has no __init__, and these are declarations of what gets read. That is deliberate — an environment usually does not own these numbers, it reads them off the simulator it wraps.

Warnings ​

Reset inside step

Every step the runner receives has to be a real transition, so finish an episode and reset it in the same call, returning the observation the new episode starts from.

The bootstrap value is unaffected: the algorithm uses the value it computed from the observation it acted on, before the step.

Keep time_outs out of the failure signal

dones is True for both reasons — the robot fell over, or the clock ran out — while extras["time_outs"] is True for the time limit alone.

Without the split, the policy is taught that surviving to the limit is as bad as falling over. The run still trains; it just trains worse, which is why this one is easy to miss.

mjlab Environment ​

mjlab combines Isaac Lab's manager-based API with MuJoCo Warp. If your robot is already a registered mjlab task, MjlabVecEnv reads everything off it — num_actions from action_manager.total_action_dim, the observation groups from observation_manager.compute(), the episode limit from the task config — and there is nothing to write.

Subclass it when you need to change how the task is presented:

python
import torch
from tensordict import TensorDict

from telekinesis.rlbotics.envs.mjlab_env import MjlabVecEnv


class MyMjlabEnv(MjlabVecEnv):
    """An mjlab task with something added on top."""

    def __init__(self, task: str, num_envs: int = 4096, device: str = "auto", **kwargs) -> None:
        super().__init__(task=task, num_envs=num_envs, device=device, **kwargs)
        # fill: anything your task needs on top, e.g. a command curriculum or buffers of your own
        raise NotImplementedError

    def _observations(self, obs: dict[str, torch.Tensor]) -> TensorDict:
        """Reshape or extend what the task publishes."""
        observations = super()._observations(obs)
        # fill: e.g. add a group of your own, or concatenate two the task publishes separately
        # observations["privileged"] = ...
        raise NotImplementedError

    def step(self, actions: torch.Tensor):
        """Wrap a step — extra logging, action shaping, a custom termination."""
        obs, rewards, dones, extras = super().step(actions)
        # fill: e.g. add a reward term, or extras["log"] entries
        raise NotImplementedError

Authoring a new task is mjlab's own job rather than RLBotics': you configure a scene, an ObservationManager with named groups, an ActionManager, a RewardManager, a TerminationManager and an EventManager for randomisation. See mujocolab.github.io/mjlab. Once registered, point a config at it:

yaml
env:
  framework: mjlab
  id: My-Humanoid-Task
  num_envs: 4096
  device: auto
  clip_actions: 1.0
  # nconmax: 50000        # raise on "nconmax overflow" in a contact-heavy scene

Truncations only reach the algorithm on an infinite-horizon task

The adapter forwards them as extras["time_outs"] when cfg.is_finite_horizon is false. On a finite-horizon task the limit is part of the task, so no bootstrap happens.

That is correct for a task with a real deadline, and wrong if you meant a locomotion task to run indefinitely — check which one your task config declares.

Actions are joint targets, so there are no action bounds

The adapter exposes none, which means clipping is env.clip_actions and an exported policy carries the observation normalization but no action scaling. Your deployment applies the same clip — see action scaling.

Isaac Lab Environment ​

Same managers, on Isaac Sim. Before writing anything, check the Available Environments — the locomotion set covers ANYmal B/C/D, Unitree A1/Go1/Go2, Spot, and the H1, G1 and Digit humanoids on flat and rough terrain, and RLBotics ships 31 configs across them.

python
import torch
from tensordict import TensorDict

from telekinesis.rlbotics.envs.isaaclab_env import IsaacLabVecEnv


class MyIsaacLabEnv(IsaacLabVecEnv):
    """An Isaac Lab task with something added on top."""

    def __init__(self, task: str, num_envs: int = 4096, device: str = "auto", **kwargs) -> None:
        # Isaac Sim is launched inside here, before the task is imported
        super().__init__(task=task, num_envs=num_envs, device=device, **kwargs)
        # fill
        raise NotImplementedError

    def _observations(self, obs: dict[str, torch.Tensor]) -> TensorDict:
        """Reshape or extend what the task publishes."""
        observations = super()._observations(obs)
        # fill
        raise NotImplementedError

    def step(self, actions: torch.Tensor):
        obs, rewards, dones, extras = super().step(actions)
        # fill
        raise NotImplementedError

For a new task, Isaac Lab's Creating a Manager-Based RL Environment is the reference: ManagerBasedRLEnvCfg with ObservationGroupCfg / ObservationTermCfg, RewardTermCfg, TerminationTermCfg and EventTermCfg.

yaml
env:
  framework: isaaclab
  id: My-Humanoid-Task-v0
  num_envs: 4096
  device: auto
  headless: true

Import order

Isaac Sim has to be running before any Isaac Lab task is imported, and the adapter launches it in its own constructor. Nothing from isaaclab_tasks may be imported above that — including your own task module, if it imports from there.

Naming the observation groups ​

Whichever path you took, the group names are the vocabulary obs_groups uses:

BackendGroups published
Gymnasiumobservation
mjlabactor, plus a privileged critic, plus camera on the vision tasks
Isaac Labpolicy, plus critic when the task is set up asymmetrically
YoursWhatever you put in the TensorDict

For a legged robot the split is what the robot can measure versus what only the simulator knows.

Actor group — everything a real robot could produce on its own: base angular velocity from the IMU, projected gravity (orientation without an absolute yaw, so the policy does not learn a compass), joint positions and velocities from the encoders, the previous action, and the commanded velocity. For the Unitree G1's 29 joints that is 99 dimensions: base_lin_vel[0:3], base_ang_vel[3:6], projected_gravity[6:9], joint_pos[9:38], joint_vel[38:67], actions[67:96], command[96:99].

Privileged critic group — true base velocity rather than an estimate, per-foot contact forces and air time, a terrain height scan around the robot, the disturbance forces you are applying as randomisation. Only the actor is exported, so none of this costs anything at deployment: why the critic gets its own set.

A set naming several groups concatenates them and requires each to be flat. A set naming one group keeps its shape, which is what a CNNConfig needs for camera observations.

A left-right symmetric robot has a free invariance

Telling PPO about it is worth real sample efficiency, and is what stops a policy settling into a limp. It needs a mirror function over the layout above, which only you can write — symmetry covers deriving one from the joint names and, more importantly, verifying it. Rough terrain is not mirror-symmetric in its dynamics even when the observation map is right.

Validate before a long run ​

python
from telekinesis.rlbotics.envs.base import observation_spec

env = MyEnv(num_envs=4)
observation_spec(env)                      # {"policy": (48,), "privileged": (235,)}

obs, rewards, dones, extras = env.step(torch.zeros(4, env.num_actions))
rewards.shape, dones.shape                 # both (4,)
extras["time_outs"].shape                  # (4,)

Turn check_for_nan off once the environment works

It checks every environment output for NaN, which is what you want on a first run — but each check forces a GPU-to-CPU sync, so leaving it on noticeably caps utilization on a GPU-resident environment.

Wrapping a real robot ​

The same interface describes hardware: num_envs=1, max_episode_length from your safety timeout, observations assembled from your sensors in a fixed order, and step writing joint targets to the controller. Two things differ — reset means whatever your homing routine is, and the loop is wall-clock bound rather than compute bound, so num_steps_per_env becomes a real-time budget.

Inference on hardware needs no VecEnv at all

VecEnv is for training against a robot. To only run a trained policy, the exported file takes numpy in and gives numpy out — see Deploy a Policy.

Need something other than PPO?
The protocol the runner drives, how to subclass PPO, and a worked SAC implementation.
Introduce a Custom Algorithm →