Simulation overview¶
from strands_robots import Robot
sim = Robot("so100") # preferred factory; 60+ actions as an AgentTool
For walkthroughs see Simulation overview.
World¶
| Action | Key params | Notes |
|---|---|---|
create_world |
timestep=0.002, gravity=[0,0,-9.81], ground_plane=True |
Implicit on Robot() |
load_scene |
scene_path |
Replace world with MJCF |
reset |
- | State to t=0, keep model |
get_state |
- | Sim time, joint positions, object poses |
destroy |
- | Tear down model, data, executor |
export_xml |
- | Serialise model to MJCF string |
Scene-MJCF¶
| Action | Notes |
|---|---|
replace_scene_mjcf(xml) |
Swap entire world XML |
patch_scene_mjcf(ops) |
Incremental patches, no full recompile |
raycast(origin, direction, ...) |
Single ray–mesh intersection |
multi_raycast(origin, directions, ...) |
Batch ray–mesh intersections from one origin; all-or-nothing, a direction it cannot cast refuses the batch |
Robots¶
| Action | Key params |
|---|---|
add_robot |
robot_name, position=[0,0,0], data_config=None, urdf_path=None |
remove_robot |
name |
list_robots |
- |
get_robot_state |
name → joint positions, velocities, torques |
Objects¶
| Action | Key params |
|---|---|
add_object |
name, shape="box"\|"sphere"\|"cylinder"\|"plane"\|"mesh", size, position=[x,y,z], color=[r,g,b,a], orientation=[w,x,y,z], mass=0.1, is_static=False, mesh_path=None - plane requires is_static=True |
remove_object |
name |
move_object |
name, position, orientation (NOT pos/quat) |
list_objects |
- |
Cameras¶
| Action | Key params |
|---|---|
add_camera |
name, position, target, fov=60.0, width=640, height=480 - no attach_to/fovy/lookat |
remove_camera |
name |
list_cameras |
- renderable camera names, "default" first, incl. model + user cameras |
Robot-URDF cameras are auto-discovered on add_robot.
sim.list_cameras() returns every name render / start_recording accepts -
the built-in "default" free view first, then all model-defined and
add_camera cameras. It equals sim.describe()["cameras"] and matches the
Newton backend, so a rollout rig can be enumerated instead of guessed.
Discover the scene-construction surface
add_robot, add_object, remove_object, add_camera,
remove_camera, and list_cameras are all listed in
sim.describe()["methods"], so an agent can learn how to build a scene
(robot, manipulanda, camera rig) before a rollout from one describe()
call instead of guessing method names.
Rendering¶
| Action | Notes |
|---|---|
render(camera_name="default", width=None, height=None) |
PNG in content[...]["image"]["source"]["bytes"]; no frame key |
render_depth(camera_name="default", width=None, height=None) |
Viewable grayscale depth PNG image block (near=bright, far=dark) + metric depth_min/depth_max (meters) in the json block |
render_all(cameras=None, width=None, height=None) |
One image block per camera (multi-view snapshot) |
get_world_point(camera_name="default", pixels=[[u, v], ...]) |
Ground picked pixels to metric world coordinates via the depth buffer; point is the median over the valid samples, points aligns with the input pixels |
open_viewer / close_viewer |
Interactive MuJoCo passive viewer |
Get a numpy frame
sim.get_observation(robot_name)[camera_name] → np.uint8 (H, W, 3)
Discover the render surface
render, render_depth, render_all, and get_world_point are all
listed in sim.describe()["methods"], so an agent can enumerate the full
rendering surface in one call instead of guessing method names.
Physics¶
| Action | Key params |
|---|---|
step |
n_steps=1 (max 100 000/call) |
set_gravity |
gravity=[x,y,z] or a scalar z-component |
set_timestep |
timestep |
get_contacts / get_contact_forces |
- . get_contacts lists every geom pair inside the detection range (margin + gap) and marks each one active - MuJoCo hands only the pairs inside margin to the solver, so a pair between the two thresholds is a proximity report carrying no force. Contact predicates count only active pairs; get_contact_forces gives the load a touching pair carries |
apply_force |
body_name, force, torque, point - latched on that body and re-applied every step until the next apply_force for it, so several bodies can hold wrenches at once (force=[0,0,0] stops one, reset() stops all) |
get_jacobian |
body_name or site_name or geom_name |
get_mass_matrix |
- |
inverse_dynamics |
- (compensation torques to hold the current qpos/qvel) |
forward_kinematics |
body_name (optional) |
save_state / load_state |
name - snapshot/restore full physics. A checkpoint is valid only for the model it was taken against: any scene mutation that swaps the compiled model (add_object, add_robot, add_camera, remove_camera, remove_robot, patch_scene_mjcf, replace_scene_mjcf) invalidates it, and load_state then returns a structured error instead of writing a state vector whose indices now mean something else. Save a fresh checkpoint after mutating the scene |
set_joint_positions |
positions (dict or ordered list), robot_name (optional) - write qpos directly + run FK (teleport / set an initial pose, bypassing actuators) |
set_joint_velocities |
velocities (dict or ordered list), robot_name (optional) - write qvel directly (set an initial dynamic state) |
get_energy |
- |
get_sensor_data |
sensor_name (optional) |
Numeric domain of the state writers
set_joint_positions, set_joint_velocities and the apply_force
vectors take finite real numbers - a python or NumPy scalar - and refuse a
boolean. float(True) is 1.0, so a True would be written as 1 radian,
1 rad/s or 1 N and the call would report success; nan / inf are refused
because mj_forward propagates a nan across the whole kinematic state
and an inf velocity blows up the integrator. Each write is
all-or-nothing, so a refused value leaves qpos / qvel and every latched
wrench untouched. This is the same domain the scene-construction vectors
(add_object, add_camera) and send_action enforce - one
library, one answer to "is this a usable number".
The same domain applies to the world-configuration parameters
set_gravity / create_world(gravity=...), set_timestep /
create_world(timestep=...), the mass on set_body_properties and
add_object, the randomize ranges and the set_obs_noise magnitudes all
refuse a boolean for the same reason, as do the vectors raycast,
multi_raycast and set_geom_properties take (a ray origin and direction, a
geom size and friction, an rgba colour).
Passing one is not a near miss. set_gravity(True) would have configured a
gravity of +1 m/s^2, pointing up, and set_timestep(True) a 1-second
integration step - each reported as status="success". The check is on the
type, not the value: 1, 1.0 and numpy scalars remain accepted
everywhere, so set_timestep(1.0) is still a legal (if unusual) request.
Both spellings are refused - a python bool and a numpy.bool_. The second
matters more in practice, because it is what a comparison such as
gripper > 0.5 produces, and because numpy.bool_ is not a bool subclass
an isinstance(x, bool) guard silently misses it.
Component count of a vector parameter
Every vector parameter (position, target, origin, force, torque,
point, gravity, direction, orientation, color, get_world_point's
pixels, and send_action's ordered-vector form) is checked for its
component count before it is read, and a value that carries no readable
count is refused with a structured error like any other. That includes a
0-d NumPy array or torch tensor - np.mean(...), np.array(0.5), a
squeezed observation slice - which declares __len__ and then raises
from it, so it is reported as "not a vector of N numbers" rather than
escaping as a bare len() of unsized object. Correctly sized NumPy arrays
are accepted throughout, so an observation slice can be passed straight
through.
Discover the sim-state surface
get_state plus the checkpoint (save_state / load_state) and
direct pose-setting (set_joint_positions / set_joint_velocities)
methods are all listed in sim.describe()["methods"], so an agent can
learn how to snapshot/restore the world and set a deterministic initial
condition from one describe() call - no method-name guessing.
Actions¶
send_action(action, robot_name=None, n_substeps=1) writes actuator/joint targets and advances physics. action accepts either form:
| Form | Binding |
|---|---|
{joint_or_actuator_name: value} mapping |
applied by name; unresolved keys are reported in an unresolved_keys JSON block so a caller can self-correct (no silent drop) |
ordered numeric vector (list / tuple / 1-D numpy array) |
bound positionally to robot_action_keys(robot_name) (the robot's actuator keys) in declaration order - the same convention replay_episode uses |
A vector lets a policy's raw action chunk drive the arm directly without first zipping it into a dict. It binds to robot_action_keys (not robot_joint_names) because those are the keys send_action resolves and the ordering the LeRobotDataset recorder writes the action column in; the two coincide unless a robot has passive/mimic joints or a tendon gripper. The vector length must match the robot's actuator count exactly; a mismatch (or a non-numeric / scalar / string action) returns a structured status="error" dict naming the actuator count and order, rather than crashing or silently truncating commands. Use a mapping to target a subset of actuators.
Each action value must be a finite number, and must not be a boolean. nan / inf are refused because they are not clamped into the actuator's range - MuJoCo discards the step and resets every robot in the scene while reporting success. A bool (or numpy.bool_) is refused because float(True) is 1.0, and each drive reads 1.0 in its own units: a 1-radian target on a joint-position drive, a full-travel command on a normalized or tendon drive (a [0, 255] tendon gripper reads it as fully open), and an out-of-range value that is silently clamped where ctrlrange excludes 1 - so the same True commands a different pose on every actuator. Send the command in the actuator's own units; for a binary gripper, its endpoint value rather than a flag. This is the domain the teleop wire validator already enforces on an input frame, and InputReceiver applies those frames through send_action.
Policy¶
| Action | Key params |
|---|---|
run_policy |
robot_name (required), policy_provider="mock", policy_config={}, policy_object=None, instruction="", duration=10.0, control_frequency=50.0, action_horizon=8, n_steps=None, seed=None, async_rtc=None, rtc_inference_timeout_s=None |
start_policy |
same args, async/non-blocking |
stop_policy |
robot_name (optional, defaults to "") |
list_policies_running |
- |
run_multi_policy |
policies={robot: Policy}, instructions, duration, n_steps |
eval_policy |
robot_name (optional; auto-resolves the sole robot like run_policy), n_episodes=1, max_steps=300, success_fn=None, async_rtc=False, rtc_inference_timeout_s=None, video=None |
When a policy is run via run_policy / eval_policy / run_multi_policy, the simulation configures the policy's output keys with the robot's action keys via set_robot_state_keys(robot_action_keys(robot_name)). robot_action_keys returns the actuator short-names that send_action resolves - which are not always the robot's joints. Robots with passive / mimic finger joints (no driving actuator) or a tendon-driven gripper (an actuator with no matching joint name) have an actuator set distinct from their joint set, so keying a policy by robot_joint_names would emit keys that resolve to nothing and leave those DOFs unmoved. The default robot_action_keys mirrors robot_joint_names for backends whose actuators match their joints.
The step horizon is given either as duration (seconds) or as n_steps (duration = n_steps / control_frequency; n_steps wins when both are set, and the legacy max_steps is an alias for n_steps). A non-positive n_steps or control_frequency is rejected up front with a structured status="error" dict naming the bad parameter - start_policy validates synchronously before the background rollout starts, so a malformed horizon never returns a false "started" success. eval_policy likewise rejects a non-positive n_episodes, max_steps, or control_frequency at the entry point (before create_policy), so a typo cannot produce a "successful" evaluation over zero or negative episodes. The same entry-point check covers the two provider keyword bags: policy_config (splatted into create_policy) and policy_kwargs (splatted into policy.get_actions) must be dicts, so a policy_config="host=127.0.0.1" string returns a structured error naming the parameter instead of a bare TypeError from the splat - and, on the start_policy path, instead of a false "started" for a rollout that never produced an action.
action_horizon (how many actions are consumed from each policy chunk before it is re-queried) is validated the same way at every entry point, so a horizon the rollout cannot run - 0, a negative value, a float, nan - is a structured error rather than a value silently clamped to 1. run_multi_policy additionally accepts per-robot mappings (instructions={robot: text}, action_horizon={robot: horizon}): a key must name a robot driven by that call (i.e. a key of policies), because an unmatched key cannot be applied to anything - a robot omitted from a mapping keeps its documented default.
Pass seed= to run_policy / start_policy for a reproducible single rollout: it reseeds Python / NumPy / torch / cuDNN and forwards policy.reset(seed=...), so a stochastic policy (VLA action-chunk sampling, diffusion noise) produces the same trajectory on re-run of the same scene. Without a seed the rollout draws from the process-global RNG and can differ run to run. eval_policy already seeds per episode via the same mechanism.
Async-RTC chunk pipeline (latency masking)¶
async_rtc overlaps policy inference with action execution: while the current action chunk drains, the next get_actions runs on a single background worker (using a fresh mid-chunk observation) and is atomically swapped in when the current chunk runs out. A policy whose inference latency is at most one chunk's execution time then pays (almost) zero visible stall at the chunk seam - the same way an async real-time controller hides inference latency on real hardware.
async_rtc=True (inference <= chunk execution):
chunk N exec |####============|
prefetch N+1 |~~~~~~~| <- fires at ~50% of chunk N
chunk N+1 exec |####========| <- ready at the seam: HIT, no stall
async_rtc=False (synchronous chunk-then-drain):
chunk N exec |####|
infer N+1 |~~~~~~~| <- the loop stalls here every seam
chunk N+1 exec |####|
Auto-enable rule. async_rtc=None (the default) resolves the flag from policy.is_chunk_emitting(): chunk-emitting VLA / flow-matching policies (pi0, pi0.5, pi0-FAST, SmolVLA, MolmoAct2) get the overlap automatically, while single-step policies (MockPolicy, classical planners) stay on the synchronous loop, where overlap would gain nothing. An explicit async_rtc=True / async_rtc=False always wins over the auto-resolution. Policy.is_chunk_emitting() defaults to execution_horizon > 1; LerobotLocalPolicy additionally reports True for an RTC model or a checkpoint that must be driven via predict_action_chunk (MolmoAct2). See LeRobot Local -> RTC.
Hardening. If a prefetched chunk arrives empty, the runner degrades to one synchronous re-query before erroring (a transient hiccup does not kill an otherwise-healthy rollout). When a prefetch blocks at the seam (inference slower than chunk execution) the runner logs a starvation warning so you can shorten the chunk or fire the prefetch earlier. Set rtc_inference_timeout_s to bound a stuck inference: the swap then returns a structured status="error" result (carrying the telemetry below) instead of waiting for every remaining chunk - bounded by the single in-flight inference the executor joins on shutdown (Python cannot forcibly kill a running worker thread).
Telemetry. Every run_policy result {"json": {...}} block carries six RTC fields so latency masking is provable from the payload, not the logs:
| Field | Meaning |
|---|---|
rtc_async_enabled |
Whether the overlap pipeline ran (the resolved async_rtc) |
rtc_chunks_acquired |
Chunks the rollout acquired (cold start + swaps + re-queries) |
rtc_prefetch_hits |
Seams where the next chunk was already computed (stall hidden) |
rtc_prefetch_blocks |
Seams where the runner had to wait for inference (seam starved) |
rtc_avg_inference_ms |
Mean get_actions wall time across the rollout |
rtc_max_inference_ms |
Slowest get_actions wall time |
A healthy masked rollout shows rtc_prefetch_hits near the chunk count and rtc_prefetch_blocks == 0; persistent blocks mean inference is slower than chunk execution and the seam cannot be fully hidden.
Async-RTC in eval_policy (opt-in). The success-rate eval path (eval_policy / evaluate(success_fn=...)) accepts the same async_rtc and rtc_inference_timeout_s, but defaults to async_rtc=False. The synchronous eval pauses the world during inference, so the success-rate is bit-stable and reproducible (the policy always sees the seam observation). Setting async_rtc=True evaluates a chunk-emitting policy under the realistic control latency it faces in deployment: the prefetch feeds the policy a slightly staler (mid-chunk) observation at the seam, so the measured success-rate can shift - that is the point, it measures robustness to inference latency. Either way the eval {"json": {...}} payload now carries the same six rtc_* fields (inference timing is reported even on the synchronous path). async_rtc=True is rejected on the benchmark/spec path (evaluate_benchmark / evaluate(spec=...)), which stays synchronous for bit-stable reproducibility; use run_policy(async_rtc=...) for benchmark-style wall-clock latency masking.
run_policy returns a {"json": {...}} content block alongside the human-readable text, mirroring eval_policy. The json block carries the rollout facts as typed fields - robot_name, policy, instruction, n_steps, elapsed_s, stopped_early, action_errors, video_path (None when no MP4 was written), video_frames, sim_time_s (when the backend reports it) and the six rtc_* async-RTC telemetry fields above - so an agent can read the outcome programmatically (did it move? how many steps? was inference masked?) without regex-parsing the prose. The status reflects whether the robot moved, not merely whether every key resolved: a run where no step resolved any key (the robot never moved) returns status="error", while a run where some keys resolve every step - e.g. a policy trained on a superset embodiment that emits one extra key the robot lacks - is operational and returns status="success" with a non-fatal N/M action steps had unresolved keys note and a partial_action_failure_rate.
eval_policy accepts the same video={...} recording config as run_policy (path enables it, plus fps / camera / width / height - an unknown key or a non-positive size is a caller error, never silently ignored), but writes one MP4 per episode with _ep{i} inserted into the filename (eval.mp4 -> eval_ep0.mp4, eval_ep1.mp4, ...), so a multi-episode evaluation can be watched to see why episodes fail rather than only read as an aggregate success_rate. The written files are listed in the result json video_paths; the output path is validated and the camera probed up-front, so a bad camera fails the eval immediately instead of after N episodes of empty MP4s. evaluate_benchmark accepts the same video={...} config and records one MP4 per episode too, so a benchmark evaluation can be watched to see why episodes fail. Frames are captured synchronously on the eval thread (render is read-only over mjData), so recording does not perturb the bit-stable benchmark rollout.
| replay_episode | repo_id, robot_name=None, episode=0 |
Discover the benchmark scoring surface
evaluate_benchmark, list_benchmarks, register_benchmark_from_file,
and register_builtin_benchmarks are listed in sim.describe()["methods"], so an agent that can run a
policy from one describe() call can also discover how to score it
against a success/failure/dense_reward benchmark - and author a new
benchmark spec at runtime - without guessing the method names.
Built-in benchmarks. sim.register_builtin_benchmarks() (or the module
function strands_robots.simulation.register_builtin_benchmarks()) registers
the benchmarks shipped with the library so they appear in list_benchmarks()
and run via evaluate_benchmark(...) without hand-authoring a spec. It ships
go2_walk_forward - a canonical velocity-tracking locomotion task for the
Unitree Go2: succeed by walking the base past x = 2 m (base_beyond_x), fail
on a topple (base_tipped) or a height collapse (base_below_z), and shape on
a dense base_velocity_tracking (exp-kernel twist tracking) + base_height +
base_orientation reward. Registration is opt-in (mirrors the on-demand LIBERO
suite), so importing the library mutates no registry. builtin_benchmark_specs()
returns the spec dicts to copy/fork as a starting point for your own task.
Recording¶
| Action | Notes |
|---|---|
start_recording(repo_id, task="", fps=30, ...) |
LeRobot v3 (parquet+MP4); requires [lerobot] extra |
save_episode() |
Flush the current rollout as one episode; call once per run_policy to record N episodes instead of one merged episode |
stop_recording(output_path=None) |
Finalise dataset (flushes any trailing rollout) |
get_recording_status |
Episode, frame count, output dir |
start_cameras_recording(...) |
Plain MP4 via imageio-ffmpeg; [sim-mujoco] only, no lerobot |
stop_cameras_recording / get_cameras_recording_status |
- |
Randomize¶
| Action | Key params |
|---|---|
randomize |
randomize_colors=True, randomize_lighting=True, randomize_physics=False, randomize_positions=False, position_noise=0.02, color_range=(0.1,1.0), friction_range=(0.5,1.5), mass_range=(0.5,2.0), seed=None |
Destructive - writes into model arrays. Recompile scene to undo.
Registry¶
| Action | Notes |
|---|---|
list_urdfs |
Loaded URDFs/MJCFs in current world |
register_urdf(name, path) |
Register additional asset |
get_features(robot_name=None) |
Joint / actuator / camera / robot names of the scene (scoped to one robot with robot_name) - the source of truth for the action keys a policy must emit, and the feature schema used for recording |
Discover the expected action keys
get_features is listed in sim.describe()["methods"], so an agent can
find it from one describe() call. When a policy's emitted action keys
resolve to no actuator, run_policy fails fast with an error that names
get_features(robot_name=...) as the way to inspect the keys the robot
actually expects - the recommended method and the discovery surface agree.
See also¶
- World building - composing scenes.
- Domain randomization -
randomizedistributions. - Architecture