Cartpole
Train a cart to keep its pole upright by pushing the cart left and right. This is the core Simulo training shape: a task that builds the scene and scores each step, and a job that trains it with PPO across many parallel environments.
Save these files in a folder called cartpole:
Show complete codecartpole/task.py
from __future__ import annotations
import mathfrom typing import Tuple
import simulo
# The cartpole robot - a validated, version-pinned global-catalog asset.# Capturing it at module level lets submit resolve this exact version before the# job runs; the worker mounts it and the Task below consumes the same handle.cartpole = simulo.Asset.from_registry("simulo/robot/cartpole:v1")
# Advanced: pick a different Simulo runtime with# App("name", runtime=simulo.Runtime.from_registry("simulo/gpu-rl:2026.06"));# see the Runtimes docs.app = simulo.App("cartpole")
# The ONE module-level heavy import - deferred under the runtime guard so discovery# records it as a remote import instead of resolving it.with app.runtime.imports(): import torch # noqa: F401 (resolved only in execution mode, on the worker)
@app.runtime.torch_jitdef _compute_rewards( rew_scale_alive: float, rew_scale_terminated: float, rew_scale_pole_pos: float, rew_scale_cart_vel: float, rew_scale_pole_vel: float, pole_pos: torch.Tensor, pole_vel: torch.Tensor, cart_pos: torch.Tensor, cart_vel: torch.Tensor, reset_terminated: torch.Tensor,) -> torch.Tensor: """JIT-compiled reward kernel (the classic cartpole balance reward).
``@app.runtime.torch_jit`` is a no-op marker at submit (no ``torch`` locally) and real ``torch.jit.script`` on the worker - so this lives at module level and is still torch-free to *define* during discovery (its body never runs at submit). """ pole_pos = pole_pos.squeeze() pole_vel = pole_vel.squeeze() cart_pos = cart_pos.squeeze() cart_vel = cart_vel.squeeze() reset_terminated = reset_terminated.squeeze()
rew_alive = rew_scale_alive * (1.0 - reset_terminated.float()) rew_termination = rew_scale_terminated * reset_terminated.float() rew_pole_pos = rew_scale_pole_pos * torch.square(pole_pos) rew_cart_vel = rew_scale_cart_vel * torch.abs(cart_vel) rew_pole_vel = rew_scale_pole_vel * torch.abs(pole_vel)
reward: torch.Tensor = rew_alive + rew_termination + rew_pole_pos + rew_cart_vel + rew_pole_vel # Keep the (num_envs,) per-env reward contract even when num_envs == 1: the # squeezes above collapse a single-env batch to a 0-d scalar, which breaks # per-env consumers (e.g. the MCAP recorder's per-env /reward indexing). return reward.view(-1)
class CartpoleTask(simulo.Task): """Balance a pole on a cart by applying horizontal forces to the cart.
Observation (4-dim): pole angle, pole angular velocity, cart position, cart velocity. Action (1-dim): scaled horizontal force on the cart.
Defined at module level: ``simulo.Task`` is a torch-free contract stand-in at submit and the real ``simulo.core.Task`` on the worker, so the same class authors lean and trains heavy. """
observation_dim = 4 action_dim = 1
episode_length_s = 5.0 action_scale = 100.0 # [N]
max_cart_pos = 3.0 # [m] initial_pole_angle_range = (-0.25, 0.25) # fraction of pi [rad]
rew_scale_alive = 1.0 rew_scale_terminated = -2.0 rew_scale_pole_pos = -1.0 rew_scale_cart_vel = -0.01 rew_scale_pole_vel = -0.005
# Framework-injected at runtime by ``simulo.core.Task`` / ``LearningEnv`` # (declared here only so the type checker sees the names the methods read; the # annotations are PEP 563 strings and never shadow the inherited values). device: str max_episode_length: int episode_length_buf: torch.Tensor reset_terminated: torch.Tensor
def __init__(self, with_camera: bool = False): super().__init__() self._with_camera = with_camera
def build(self, scene: simulo.Scene) -> None: scene.add(simulo.Terrain.plane(name="ground"), at="/", per_environment=False) scene.add( simulo.Light.dome(name="light", intensity=2000.0, color=(0.75, 0.75, 0.75)), at="/", per_environment=False, ) self.robot = simulo.Robot(asset=cartpole, initial_pose=simulo.Pose.identity()) scene.add(self.robot, at="/World/Robot")
# Playback adds one world-frame camera. Training keeps the default off, # so its scene and render cost remain unchanged. if self._with_camera: scene.add( simulo.Camera( width=640, height=480, data_types=["rgb"], update_period=1.0 / 30.0, offset=simulo.SensorOffset.look_at( pos=(0.0, 0.0, 5.0), target=(0.0, 0.0, 0.0) ), ), at="/World/overhead_cam", per_environment=False, )
def on_start(self, env: simulo.LearningEnv) -> None: self._cart_dof_idx = self.robot.find_joints("slider_to_cart") self._pole_dof_idx = self.robot.find_joints("cart_to_pole")
def get_observations(self) -> torch.Tensor: pole_idx = self._pole_dof_idx[0] cart_idx = self._cart_dof_idx[0] joint_pos = self.robot.state.joint_positions joint_vel = self.robot.state.joint_velocities pole_pos = joint_pos[:, pole_idx].view(-1, 1) pole_vel = joint_vel[:, pole_idx].view(-1, 1) cart_pos = joint_pos[:, cart_idx].view(-1, 1) cart_vel = joint_vel[:, cart_idx].view(-1, 1) return torch.cat((pole_pos, pole_vel, cart_pos, cart_vel), dim=-1)
def get_rewards(self) -> torch.Tensor: joint_pos = self.robot.state.joint_positions joint_vel = self.robot.state.joint_velocities return _compute_rewards( self.rew_scale_alive, self.rew_scale_terminated, self.rew_scale_pole_pos, self.rew_scale_cart_vel, self.rew_scale_pole_vel, joint_pos[:, self._pole_dof_idx[0]], joint_vel[:, self._pole_dof_idx[0]], joint_pos[:, self._cart_dof_idx[0]], joint_vel[:, self._cart_dof_idx[0]], self.reset_terminated, )
def get_dones(self) -> Tuple[dict[str, torch.Tensor], torch.Tensor]: pole_idx = self._pole_dof_idx[0] cart_idx = self._cart_dof_idx[0] joint_pos = self.robot.state.joint_positions truncated = self.episode_length_buf >= self.max_episode_length - 1 cart_out = torch.abs(joint_pos[:, cart_idx]) > self.max_cart_pos pole_fallen = torch.abs(joint_pos[:, pole_idx]) > math.pi / 2 terminated = {"cart left the track": cart_out, "pole fell": pole_fallen} return terminated, truncated
def apply_actions(self, actions: torch.Tensor) -> None: self.robot.set_joint_effort_target(self.action_scale * actions, joint_ids=self._cart_dof_idx)
def reset_idx(self, env_ids: torch.Tensor) -> None: num_resets = len(env_ids) if num_resets == 0: return self.robot.reset(env_ids) pole_idx = self._pole_dof_idx[0] # Start from the default joint positions for the selected environments. joint_pos = self.robot.default_joint_positions[env_ids] random_angles = torch.empty(num_resets, device=self.device).uniform_( self.initial_pole_angle_range[0] * math.pi, self.initial_pole_angle_range[1] * math.pi, ) joint_pos[:, pole_idx] += random_angles # Apply the randomized starting positions and default velocities together. joint_vel = self.robot.default_joint_velocities[env_ids] self.robot.set_joint_state(joint_pos, velocities=joint_vel, env_ids=env_ids)Show complete codecartpole/train.py
from __future__ import annotations
from typing import Any
import simulo
from task import CartpoleTask, app
@app.job( type="train", system=simulo.SystemType.TIER_1, timeout=8 * 60 * 60, retries=2,)def train(num_envs: int = 4096, max_iterations: int = 200) -> dict[str, Any]: """Train Cartpole with PPO.""" env = simulo.LearningEnv( task=CartpoleTask(), num_envs=num_envs, device="cuda", env_spacing=4.0, headless=True, seed=42, ) trainer = simulo.RLTrainer(env=env, algorithm="PPO", device="cuda", seed=42) stats = trainer.train(max_iterations=max_iterations) trainer.close() env.close() return {"num_envs": num_envs, **stats}Show complete codecartpole/eval.py
from __future__ import annotations
import math
import simulo
from task import CartpoleTask, app
@app.job(type="eval", system=simulo.SystemType.TIER_1, timeout=8 * 60 * 60)def evaluate( policy: simulo.PolicyCheckpoint, episodes: int = 100, num_envs: int = 100, actions: str = "best",): return simulo.evaluate( CartpoleTask(), policy, episodes=episodes, num_envs=num_envs, actions=actions )
@app.successdef balanced(task): """Count an episode when the pole remains upright until the time limit.""" pole_angle = task.robot.state.joint_positions[ :, task.robot.find_joints("cart_to_pole")[0] ] lasted = task.check("lasted to the time limit", task.survived()) upright = task.check( "pole within 10 degrees of upright", pole_angle.abs() < math.radians(10) ) return lasted & uprightShow complete codecartpole/play.py
from __future__ import annotations
from task import CartpoleTask, app
import simulo
@app.job(type="play", system=simulo.SystemType.TIER_1, timeout=15 * 60)def play(policy: simulo.PolicyCheckpoint, episodes: int = 3) -> None: """Play a policy: simulo run play.py --policy policy_x:best.""" env = simulo.LearningEnv( task=CartpoleTask(with_camera=True), num_envs=1, device="cuda", dt=1.0 / 120.0, physics_steps_per_action=2, env_spacing=4.0, headless=True, seed=42, enable_cameras=True, ) player = simulo.RLPlayer(env=env, policy=policy, device="cuda") try: player.play( num_episodes=episodes, record=simulo.RecordConfig(include_video=True) ) finally: player.close() env.close()The task observes 4 values (pole angle, pole angular velocity, cart position, cart velocity) and emits 1 action, the horizontal force on the cart. An episode ends when the pole tips past 90 degrees, when the cart leaves the track, or after 5 seconds.
Run it
Section titled “Run it”simulo run cartpole/train.py --num-envs 512 --max-iterations 600 --tag baselineThe flags override the job defaults, --num-envs 4096 and
--max-iterations 200. --tag baseline labels the job so you can find it later, and you
can repeat --tag to add more labels. The job asks for a Tier 1 GPU, has an
8-hour timeout, and is retried up to 2 times after a failure. All attempts
share the one timeout.
While it trains, the logs show Checkpoint written at iteration N/600 as
training progresses and Best checkpoint saved at iteration N (reward=…) each
time the mean episode reward improves. When it finishes, simulo run prints the policy’s checkpoints and
the next commands to try:
Checkpoints: best (iteration <n>) latest (iteration 600)Next: simulo policy get policy_brisk-heron-4f8k2m:best simulo run cartpole/eval.py --policy policy_brisk-heron-4f8k2m:best simulo run cartpole/train.py --max-iterations 1200 --from policy_brisk-heron-4f8k2m:best simulo export policy_brisk-heron-4f8k2m:bestsimulo result prints what the job returned, including best_reward and
iterations. simulo policy list lists the policy with its best and
latest checkpoints.
To keep training the same policy, start a new job from one of its checkpoints.
--max-iterations is the total, so this run trains the 300 iterations the
checkpoint doesn’t have yet:
simulo run cartpole/train.py --num-envs 512 --max-iterations 900 \ --from policy_brisk-heron-4f8k2m:latestEvaluate it
Section titled “Evaluate it”The success rule counts an episode only when the pole lasts to the time limit
and stays within 10 degrees of upright. Run simulo run cartpole/eval.py --policy <policy-id>:best; see Evaluate a trained policy.
Play it back
Section titled “Play it back”play.py runs a checkpoint back through the same task and records what
happens. It builds CartpoleTask(with_camera=True), which adds one overhead
camera on top of the training scene:
simulo run cartpole/play.py --policy policy_brisk-heron-4f8k2m:bestPLAY job_swift-otter-8h4q2tPolicy: policy_brisk-heron-4f8k2m:best (iteration 600)...[RLPlayer] Completed: 3 episodes, <steps> stepsNext: simulo recordings job_swift-otter-8h4q2tDownload the recording the command names. It holds the policy’s actions and the camera’s video:
simulo recordings job_swift-otter-8h4q2tSee Play a policy and record it for what the checkpoint’s task-compatibility checks do and what to do when they fail.
What to reuse
Section titled “What to reuse”Taskkeeps scene construction, observations, rewards, dones, actions, and resets in one class you can test on its own.with app.runtime.imports():defersimport torchuntil the job runs on the GPU, so submitting stays fast and needs no GPU libraries on your machine.- A training job gets its
bestandlatestcheckpoints automatically. The job body only builds the environment, trains, and returns its statistics. - The camera in
play.pyonly exists in playback; training stays headless and unchanged.
See Tasks and RL training for the lifecycle behind the task methods, Continue training for starting a job from a checkpoint, and Policies and checkpoints for what a checkpoint holds.