Quickstart¶
This goes beyond the minimal Getting started manual and shows the main ways to run a world.
Standalone¶
# windowed, real time
.venv/bin/roqsim sim <world.yaml>
# headless, as fast as possible, stop after N steps, print timing
.venv/bin/roqsim sim <world.yaml> --headless --pacing asap --steps 1000 --profile
# scaled real time (4x)
.venv/bin/roqsim sim <world.yaml> --pacing 4.0
# reproducible noise, recorded, rendered to video afterwards
.venv/bin/roqsim sim <world.yaml> --seed 7 --record run.npz --video run.webm
The target is a world YAML, an MJCF scene, or a <pkg>:<name> reference resolved from an installed
package (roqsim sim roqsim_mobile:turtlebot4_demo).
Pacing is realtime (default), a numeric factor, or asap. It is what the run asks for and
not what it gets: the achieved factor is a property of the host’s load, a step that is already late
is never made up, and the runner warns at the end of a run it could not pace. So a duration a trial
must actually get is counted on simulated time — the recording’s t, or /clock for a node —
and never on a wall clock, which buys a varying amount of it. Headless works anywhere, offscreen
sensors included: import roqsim picks a backend that exists on this machine (egl where there
is a render device, osmesa where there is not), so MUJOCO_GL need not be set by hand
— set it only to override that choice. To record a run, add --record — see
Recording a run below.
Note
Windowed GL defaults. On many Linux + GL-driver combinations the windowed launch aborts with
gladLoadGL error. When opening a window the runner applies two overridable defaults before any
GL loads:
Preloads ``libGLEW`` for the viewer. The passive viewer always opens its window via glfw, and that context is what needs libGLEW in the global symbol namespace. The runner locates it portably (via
ldconfig— no arch-specific path) and re-exec’s itself once withLD_PRELOADset, so no manualexport LD_PRELOAD=…is needed. No-op headless, with noDISPLAY, when the environment already preloads GLEW, or when GLEW is absent. Opt out withROQSIM_NO_GL_PRELOAD=1.Defaults ``MUJOCO_GL=egl`` — but only the offscreen (camera) renderer.
MUJOCO_GLdoes not affect the viewer window (always glfw); it selects the backend cameras render with. Forcing the cameras onto glfw too is what clashes with the window’s glfw context and producesgladLoadGL erroron camera worlds; giving cameras their own egl context avoids it. Override by settingMUJOCO_GLyourself (e.g.MUJOCO_GL=glfw). In practiceimport roqsimhas already chosen (see “Choosing a GL backend” in Interfaces), and it has to: by the time the runner’smainruns, mujoco is imported and the backend is bound. This default only still decides the windowed case underROQSIM_NO_GL_SELECT.
Because the runner preloads libGLEW itself, do not also export LD_PRELOAD=…libGLEW by hand.
A stale libGLEW preload lingering in the shell, combined with the egl default, drags GLX into
the same process as MuJoCo’s PyOpenGL EGL backend and crashes import mujoco with
undefined symbol: eglQueryString. If you see that, clear it with export -n LD_PRELOAD.
Keys in the window¶
F1 lists the keys roqsim adds to the window, and leaves the list up until F1 comes again. MuJoCo’s own help opens with it, in the opposite corner, listing Simulate’s keys rather than these.
Moving the camera¶
The window has two navigation modes and F10 switches between them. The mode is named in the lower-left corner for a couple of seconds whenever it changes, and once when the window opens.
Mouse (the mode a run opens in) is MuJoCo’s viewer exactly as it ships: drag to orbit, right-drag to pan, wheel to zoom. Nothing of roqsim’s is in the way, so all three keep their full reach — which is what an inspection driven by the mouse alone needs.
Flight is what F10 opts into, for getting inside a building-sized world rather than circling it
from outside. The arrow keys fly the free camera: ↑/↓ travel along the direction you are looking
(look down and you descend), ←/→ strafe level, Page Up/Down rise and drop, and holding Shift
flies three times faster. It applies to the free camera only — a track\ ed camera (below) owns
its own pose.
The drag stays MuJoCo’s own orbit in flight too, but it turns about a point held ~1.5 m in front of
the eye rather than wherever the world framed its camera, so it reads as looking around rather than
circling the room. That is done by re-spelling the camera — a free camera’s lookat and
distance are just how its eye position is written down, so pulling them in renders the identical
image and only moves the pivot, which is not drawn. The cost is that MuJoCo’s pan and zoom scale with
distance and therefore cover much less ground per drag; in flight the arrow keys are the way to
travel, and F10 hands the reach back. Because the pivot is only a re-spelling, neither switch moves
the picture: you come out of flight looking at exactly what you were looking at.
Not WASD, which would be the obvious choice: MuJoCo’s Simulate binds W, A, S, D,
E and Q to visualization flags (wireframe, static bodies, select point, …), so those keys
would toggle rendering as they moved. Its arrow bindings only step the physics while it is paused,
which leaves them free during a run. (The scene-review window of roqsim_scene_builder is not
MuJoCo’s window and does use WASD.)
Initial view¶
A world can set how the interactive viewer’s camera opens via an optional sim.view block
(windowed runs only; ignored when headless). Give any subset — omitted keys keep MuJoCo’s
model-derived default:
sim:
view:
lookat: [0.0, 0.4, 0.95] # point the camera orbits (world metres)
distance: 3.4 # metres from lookat
azimuth: 130 # degrees around +z
elevation: -20 # degrees; negative looks down
A fixed manipulation cell, for instance, sets these for a 3/4 view of the arm at its working height.
sim.view is the camera and nothing else: an unknown key there is an error, not a no-op.
Simulate’s two side panels are hidden by default and are not world config — which camera a world wants is part of the scene, whereas wanting the panels is a property of one interactive session, so they are run-level flags:
roqsim sim world.yaml --left-ui --right-ui
Either way Tab / Shift+Tab still toggle them at runtime.
Driving the robot by hand¶
The right panel’s sliders write data.ctrl — but a world’s controller plugin normally rewrites it
every tick, so a slider drag is undone before you see it. --manual-control stands every
controller down for the run and leaves ctrl to you:
roqsim sim world.yaml --manual-control # implies --right-ui, where the sliders are
It is a run-level switch, not world config: which controller a world wires up is part of the
experiment, whereas driving it by hand is a property of one interactive session. It applies to
every controller in the world (diff_drive, omni_drive, arm_controller,
spot_locomotion, g1_locomotion) — they keep serving their endpoints and tracking odometry, they just stop
stamping ctrl. Each still seeds ctrl once at reset, so the sliders open at the robot’s home
pose rather than at zero.
What the sliders mean is the actuator’s own units: wheel velocity for a diff_drive base, joint
position for an arm or Spot’s legs, and torque for the G1’s leg motors — a G1 left at zero torque
simply folds. Manual control needs the window, so it is refused under --headless.
Following a mobile robot¶
A static camera is fine for a fixed cell or a fixed arm, but a mobile robot drives out of frame. Add
track to attach the camera to it — MuJoCo’s tracking camera keeps the named body centred while
distance / elevation / azimuth stay yours to orbit and zoom with the mouse:
sim:
view:
track: robot # an entity name (as spawned) or a raw MJCF body name
follow_heading: true # chase cam: stay behind the robot as it turns
azimuth: 180 # with follow_heading, an offset from the robot's yaw
elevation: -20
distance: 3.4
track on its own keeps the azimuth world-fixed: the robot stays centred but spins in view as it
turns. follow_heading: true re-aims the camera at the robot’s heading each frame, so it rides
behind the robot; azimuth is then an offset (180 = directly behind, 0 = head-on). Orbiting
with the mouse still works under follow_heading — the new angle is folded into the offset, so it
holds relative to the robot rather than being overwritten on the next frame. lookat is ignored
while tracking (MuJoCo drives it).
The name given to track is resolved against the entity registry first, so you write the robot’s
name from its spawn plugin (track: robot) rather than its MJCF body prefix; any body name in the
compiled model also works. This works under both drivers — the standalone runner and the
scenario-execution adapter’s viewer window.
Saving the view you framed¶
Framing is done with the eye, not with numbers — so rather than transcribing a pose into sim.view
by hand, fly the camera until the shot is right and press F8. A dialog shows the values and the
world YAML they would go into; confirm, and that world’s sim.view block is rewritten:
Save the current camera view to
/…/roqsim_scenes/src/roqsim_scenes/worlds/depot.yaml
lookat: [3.2, -1.05, 0.9]
distance: 6.414
azimuth: 142.5
elevation: -21.0
This replaces the world's sim.view block.
Only that block is touched. The world is edited as text, not reparsed and dumped, so comments,
key order and one-line view: {…} styling all survive — a save shows up as a diff on the four value
lines and nothing else. The result is re-parsed and compared before it is written, so a bad edit is an
error rather than a mangled world.
A tracking view saves what it can mean: track and follow_heading are carried over,
lookat is dropped (MuJoCo drives it), and under follow_heading the saved azimuth is the
offset behind the robot rather than the live world-frame angle — which is the half a world can state,
and the half that still means the same thing after the robot turns.
Two things to know. A saved free-camera distance is usually ~1.5 m, because the arrow-key walk
holds the mouse pivot that far in front of the eye (above): the pair lookat/distance still
names the same eye position and renders the identical image, it just no longer reads as “how far back
from the scene”. And F8 needs a world YAML to write into — a run started from an MJCF scene or a bare
model reference has none, and says so rather than picking a file.
Checking a world before running it¶
roqsim check loads a world as far as it goes and reports every problem it found, in the order
the loader would hit them – so a world with three bad keys reports three, and a mount that does not
exist is named rather than discovered by a run that dies quietly:
roqsim check world.yaml
roqsim check roqsim_mobile:husky_demo --json # the same report, for a script
It runs five stages – resolve, inputs, config, build, configure – and says
which one it reached, because “the config is wrong” and “the config is fine and the model refused the
name a plugin asked for” send a reader to different files. It does not step the simulation: a world
that passes can still behave wrongly, but it cannot fail to start, which is the failure worth
catching before a campaign queues a thousand of them.
With no problems it prints the inventory instead: the entities that registered, the endpoints they
publish with their topics and types, and the model’s totals. That is what the next thing gets written
against – a scenario that drives robot, a bridge that expects scan – without opening the
world file and its manifests to work out what is in there.
Recording a run¶
--record samples MuJoCo state while the run proceeds, so a run can be looked at afterwards —
from any camera, at any resolution, as a still or a video — without re-running it:
roqsim sim world.yaml --record run.npz
roqsim sim world.yaml --record --capture-fps 10 # a slower rate, a smaller file
roqsim render --state run.npz --out last.png # where it ended up
roqsim render --state run.npz --at 12.5 --out t.png # one moment
roqsim render --state run.npz --out run.webm # the whole run as video
With nothing said about the camera, a recording is shown as the whole scene from above: every
geom in the world, angled rather than top-down, as close as the field of view allows – and with the
ceiling removed where the world had one, since a view from above would otherwise be a view of a
roof. That is the video you get with no flags at all. A --view, --focus or --camera
frames it otherwise and keeps the world as it is (--no-ceiling is then yours to add), and a
recording made in a window keeps the camera the person watched it through.
Those three commands name no world, and that is not a shorthand: a recording carries the resolved
component tree rather than a recipe, so it rebuilds from itself and needs only the model packages it
referred to. What ran is recorded outright — including the actuator table, which says what control
law and gains every joint ran under and whether each value came from the model or from the world’s
actuators: block, so a result can state its gains without anyone reopening the MJCF.
Without --at you get the last sample, and the command says so on stderr along with the sample’s
time — that is a choice you did not make, so it is not made silently. A video render reports progress the
same way: one line rewritten in place at a terminal, and a handful of lines when the output is a log.
Starting where the run starts moving¶
A run recorded from a live stack opens with the robot standing still while its nodes come up, a map
arrives and a plan is computed. --from onset skips that, and roqsim state --onset reports it
as JSON without drawing anything:
roqsim render --state run.npz --from onset --out clip.mp4
roqsim state --state run.npz --onset
The hard part is not the threshold, it is which velocity to read. A robot is spawned a little above
the floor and drops onto it, so something is moving at t=0 in almost every recording — and the drop is
sometimes faster than the drive that follows, so ranking motion by speed picks the fall. What separates
them is direction: a robot that falls moves along z and rocks about x/y, while a robot that
drives moves along x/y and turns about z.
So the signal is chosen by what the robot is, and the fall is not in it at all:
- A mobile base
is read by the planar motion of its base —
vx,vyand yaw rate. The fall isvzand the settle is roll and pitch, so none of it is in the signal.- A fixed base
is read by the velocity of its actuated joints. A bolted-down arm does not fall.
Which one a recording gets is read off the model rather than off any name: a robot is mobile iff the kinematic root of its actuated joints carries a free joint. Reaching the base through the actuators is what makes a world with props work — a parcel on a conveyor is a free body too, but it is the root of no actuated joint, so it is never mistaken for a base.
--onset-select overrides the choice with planar, actuated, a comma-separated list of joint
names, or any. any reads every velocity and needs no world rebuild, which is what lets it
answer for a recording whose world can no longer be built — at the cost of seeing the spawn drop, so
it reports kind: any to say the answer is the weaker one.
A run that never moved says so rather than reporting a time. moved means a sustained crossing
was found, not that the peak was high: a single sample of motion — a contact tick, a teleport — is not
a run in which anything drove, and reading the peak alone would clip a stationary robot.
onset is a moment, and a moment goes anywhere a time does: --from onset-1, --to onset+20,
--at onset+5 all resolve against the same table, found once.
Moving the camera: chase, path, and a person flying it¶
Three ways to say where the camera is during a clip, and they compose.
A chase camera is the world’s own track view, stated on the command line:
roqsim render --state run.npz --from onset --no-ceiling \
--view track=robot follow_heading=true distance=2.5 elevation=-20 azimuth=180 --out clip.mp4
track attaches lookat to the robot’s body; follow_heading re-aims the camera each frame so
azimuth is an angle behind the robot (180 is directly behind) rather than a compass bearing.
A camera path moves it along keyframes, from a file or inline as JSON:
# orbit.yaml -- one orbit around the driving robot, then settle behind it
ease: smoothstep # linear | smoothstep
wrap: false # let azimuth sweep past 180 (a full orbit)
keyframes: # t: seconds, `onset`, or `onset+N`
- {t: onset, azimuth: 180, distance: 2.5}
- {t: onset+8, azimuth: 540, distance: 4.5, elevation: -35}
- {t: onset+11, azimuth: 540, distance: 2.5, elevation: -20}
roqsim render --state run.npz --from onset --view track=robot follow_heading=true \
--camera-path orbit.yaml --out clip.mp4
Each key – lookat, distance, azimuth, elevation – is its own track: it interpolates
between the keyframes that state it and holds beyond them, and a key no keyframe states is never
written, so the base camera keeps it. That is what lets an azimuth-only path orbit a chase camera
while the robot’s own turning still counts: under follow_heading the path animates the angle
behind the robot, not a bearing. A keyframe may instead say where to stand and where to look, in
metres – {t: 0, eye: [9, -6, 5], target: [4, 2, 0.5]} – which converts to the orbit keys at
load. Azimuth takes the shortest arc between keyframes unless wrap: false.
Checking a path costs a still, not a clip. --at with --camera-path draws the frame the
clip would have at that moment, --check prints the resolved keyframes without drawing, and
roqsim state --state run.npz --at T says where the robot is when choosing an eye:
roqsim render --state run.npz --camera-path orbit.yaml --check
roqsim render --state run.npz --camera-path orbit.yaml --at onset+4 --out look.png
That loop – where is the robot, write keyframes, look, adjust – is what makes a path something an agent can author as readily as a person.
A camera take is a person as the camera operator. In the replay window (below), Shift+F9 starts a take; play, fly the camera with the mouse and the flight keys;
Shift+F9 ends it. What lands is a clip in the shots file – from/to spanning the take and a
camera_path file beside the shots file holding the flown camera, one keyframe per frame – so
roqsim render reproduces the flight exactly as it was flown. Scrubbing back and playing again
overwrites that stretch rather than doubling it, and a take that never played is dropped and says so.
Drawing on the frames¶
--overlay paints an inset on every frame after the scene is rendered: clock puts the simulated
time in a corner, and installed packages add their own – a navigation package’s costmap, say –
under the roqsim.render_overlays entry-point group (see Interfaces). A bare name takes the
defaults; options ride along as JSON, anchor, width and margin being the ones every
overlay shares:
roqsim render --state run.npz --overlay clock --out clip.mp4
roqsim render --state run.npz --overlay '{"clock": {"anchor": "bottom-left", "format": "{t:.1f} s"}}' --out clip.mp4
roqsim render --overlay list
An overlay that nothing installed registers is refused before the world is built, naming what is available.
Rendering fewer frames than the recording holds¶
roqsim render draws one frame per sample and never decimates, so every frame in a video is a state
the simulation actually had. A recording captured at 250 Hz therefore draws eight frames for every one
that survives to a 30 fps video. --decimate drops them first instead:
roqsim state --state run.npz --decimate 8 --out thin.npz
The rows that remain are untouched, so the invariant holds; what changes is how many there are and the declared rate that says so. That rate stays exact because it is a numerator and a denominator — 250 Hz by 8 is 31.25 fps, not a rounded 31.
F9 records a take, in any windowed run — with or without --record. Press it once to start
and again to stop; the window title carries [REC] while one is running, and takes are numbered
(run.npz, run-2.npz, …) beside the --record path, or beside the default when the run was
started without one. It is the way to capture the interesting minute of a long run rather than all
of it, and what a take holds is what --record holds, so roqsim render --state reads it the
same way.
Replaying a run¶
Hand roqsim sim a recording instead of a world and it plays the run back, in the window a live run
uses — the same free camera, the same flight keys, the same visualization toggles. Nothing is
simulated: every frame is a state restored out of the file, so what the window shows is what happened.
roqsim sim run.npz # from the start
roqsim sim run.npz --at 12.5 # open at one moment
roqsim sim run.npz --no-transport-window
roqsim sim run.npz --world cell/world.yaml # a world that loaded files from beside itself
The extension is what selects it, as it does for a mesh target and for roqsim render’s output.
Options that drive a live simulation — --record, --steps, --pacing, --ros and their
kind — are refused by name rather than ignored, and a time outside the recording is refused the way
roqsim render --at refuses one. A recording rebuilds its world from its own provenance; where
that world loaded a plugin or a model by path from beside itself and those files are not beside the
recording, --world names the world to rebuild from, as roqsim render’s target does. The
provenance check still refuses one that does not match, and a shot taken in such a replay names the
world too, so its render rebuilds the same way.
A replay opens two windows. MuJoCo’s own takes a key callback and nothing else — no mouse
callback, no way to add a widget — so the slider, the timestamp box and the buttons are a small
window beside it, which is also what drives the replay. Without a display for that window, or with
--no-transport-window, the overlay bar and the keys carry it on their own: F11/F12 scrub (hold
Shift to jump), F8 plays and pauses, F9 writes a shot, Shift+F9 starts and ends a camera
take (Moving the camera: chase, path, and a person flying it). F1 lists what the run actually has.
A shot is why a replay exists. A figure needs one moment of one run seen from one place, and
neither half can be guessed at a command line: --at is a number nobody knows until they have
watched the run, and a camera is a pose nobody writes by hand. Pressing Add shot appends a document
to a shots file naming the sample, the camera, and the recording it came from — which
roqsim render draws later, at whatever size the figure wants:
roqsim sim run.npz --shots shots.yaml --render-size 1920x1080
python3 -c "import yaml,subprocess as s;from roqsim.shots import read_shots,render_args
[s.run(['roqsim','render',*render_args(d)]) for d in read_shots('shots.yaml')]"
roqsim.shots.render_args() is the one place those flags are built, so the picture the window’s
Add + PNG button draws and the picture a figure script draws are the same command. Two of its rules
matter to anyone reading a shot: the render names no world (a recording rebuilds from its own
resolved tree only while no target is passed), and a shot framed by hand in a world whose sim.view
tracks the robot carries track: null and follow_heading: false, because --view merges over
that view and tracking would otherwise quietly ignore the lookat that was chosen.
A clip is a shot with a range where a shot has a moment: from/to instead of at, and
video instead of png. It carries the same state, view, size and source, plus
two keys a still has no use for – camera_path (a file, or the keyframe document inline) and
overlays (a list, in drawing order) – and roqsim.shots.render_args() turns both into the
flags above. A camera take writes one; so can a person or an agent.
--capture-fps is samples per simulated second, so a recording plays back at 1× sim time
whatever pacing the run used. Samples can only be taken on a physics step, so the rate is snapped onto
the world’s step grid and the command says so if that moved it noticeably; a fraction like 500/17
is accepted, which is what makes any rate it suggests something you can type back.
Recording costs the run almost nothing, which is why nothing is drawn while the loop runs. Measured over
30 s of sim with --pacing asap (so wall time is the loop cost): the default 25 fps is
indistinguishable from not recording; 500 fps — every single step on a 0.002 timestep — costs about
5.5%. A rendered frame, by contrast, is 2–6 ms depending on how much of the world is in shot (it is
scene-geometry bound, so resolution barely matters), against ~0.001 ms for a state sample: three orders
of magnitude, which is the whole reason the picture is made afterwards.
It costs the run almost no memory either, which is why an hour-long recording is a disk question and
not a “will this fit” one: each sample is written to a <recording>.npz.part stream as it is taken,
and close() packs that stream into the archive and removes it. Measured against holding the run in
memory, the loop cost is the same to within noise. The rate is otherwise a disk decision: at 25 fps a
mobile manipulator produces about 0.25 MB of samples per simulated minute, and the archive deflates that
by a factor that depends on the world — 70% of raw for a bare mobile robot, 11% for a world of
pedestrians — because a state vector repeats: most of a world stands still, so most of a record is the
record before it.
The file is a plain .npz, so anything with numpy can read it — np.load("run.npz") gives a JSON
meta member and a structured samples member with both clocks per sample: t is simulated
seconds and w is wall seconds elapsed since the recording started (meta["wall_clock_origin"]
names that zero). w is deliberately not a Unix timestamp — it is monotonic, so an NTP step mid-run
cannot make it go backwards, and it keeps nanosecond resolution that a float64 holding 1.7e9 does not.
Both are kept because neither is derivable from the other: their ratio is the run’s real-time factor,
which is exactly what varies. Use t to ask what the physics did and w to ask what the run cost —
a stall on a slow sensor, or the gap where a viewer sat paused, exists only in w. For arbitrary
computations over a run, use roqsim.recording:
from roqsim.recording import open_recording
rec = open_recording("run.npz")
rec.real_time_factor # simulated seconds per real second, over the whole recording
for sample in rec.range(8.0, 20.0):
sample.sim_time, sample.wall_time, sample.index, sample.data
... # any numpy/mujoco computation over a real restored state
--record is for a run you launch yourself. A run launched for you — an orchestrator starting this
world through a ROS launch file, where the command line belongs to that file — asks for the same thing
through the environment, which both drivers honour:
export ROQSIM_RECORD=run.npz # same as --record, and an explicit flag still wins
export ROQSIM_CAPTURE_FPS=25 # same as --capture-fps
export ROQSIM_CAPTURE_EXPORT_DIR=capture # also write a browser run capture (below)
export ROQSIM_SIM_POSES=1 # also stream sim_poses.csv (below)
The pose series¶
ROQSIM_SIM_POSES streams a plain sim_poses.csv beside the recording: one row per sample per
named body — robot bases, links, wheels, attached tools and workpieces, props and walkers — with
the world pose as a quaternion and the world twist read from the solver via
mj_objectVelocity:
timestamp,wall_time,frame,position.x/y/z,orientation.x/y/z/w,twist.linear.x/y/z,twist.angular.x/y/z
Every named body, and not only the ones parented to the world, because what a trial is judged on is often welded below a robot — a tool on a flange, a workpiece in a gripper — and a reader cannot know in advance which. The price is rows: a manipulator world writes several times as many as a mobile one. Sites and unnamed bodies have no row (the run log counts the unnamed ones and names their parents); the recording itself is the complete state, from which those are derivable.
Two reasons it exists rather than leaving callers to difference the recording. A velocity obtained by differencing positions is only ever as good as the interval it is divided by, and a consumer reading poses off a transport is dividing by arrival times — quantised by whatever gates the clock, jittered by delivery — so a constant speed comes out alternating. And a stepped run publishes nothing at all, so this is the only pose series it has.
Beside it goes entities.json, a one-line roster of what those rows are — each entity’s name,
kind, body and whether it is currently present, from the registry. The pose record cannot
carry kind: a body name does not say whether it is a robot, a prop or a walker, and nothing
recovers that from the model. It is rewritten whenever the roster changes, so a trial that spawns an
obstacle mid-run is described by it rather than by the world it started in.
timestamp is exact simulated seconds. One convention worth knowing: mj_step integrates qpos
and leaves xpos holding the pose from before that integration, so a row is a coherent snapshot of
timestamp - dt carrying the label timestamp — deliberately the same one-step lag the
ground_truth_pose plugin publishes with, so the two describe the same instant. It cancels in every
derivative.
Like the clock map and unlike the .npz, it is flushed per row, so a run killed outright still leaves
everything up to the last sample.
A relative path is anchored to RUN_OUTPUT_DIR (this run’s own result directory) or, failing that,
OUTPUT_DIR (the job’s), so a run’s artifacts land beside its other results instead of
wherever the launch left the working directory; otherwise it resolves against the working directory as
usual. Deliberately not SCENARIO_OUTPUT_DIR: that is the root shared by every run of a batch, so
anchoring a per-run file there gives one path that each run of a sweep overwrites in turn. Recording stays a session
concern either way — the same footing as sim.headless, which the world YAML rejects on purpose — so
there is no route to it through the world.
A recording also converts to a browser run capture — the motion half of replaying a run in a web
viewer, alongside the geometry roqsim export web emits:
roqsim export capture --state run.npz --out capture/
It writes capture.json + capture.bin: one track per joint value and one per body pose that
actually moved, each keyed by the name the scene descriptor uses, so the two artifacts address each other
without either knowing about MuJoCo. The format is the consumer’s — whichever tool replays these is
where it is defined — and roqsim is one producer of it, the same relationship this package has with
URDF and SRDF.
A run stopped any of the normal ways writes its recording on the way out: closing the viewer window, one
Ctrl+C, or a SIGTERM — which is how a supervised run ends, whether that is docker stop, a
container teardown, a scheduler eviction or a supervisor’s timeout. Only SIGKILL loses it: an .npz
writes its index at the end, so there is nothing to read. Such a run leaves its <recording>.npz.part
sample stream behind, buffered up to whatever the kill interrupted; nothing reads it, and the archive’s
absence remains the signal that the run did not end on purpose.
The recording is written before the capture is derived from it, so a problem with the browser artifact costs you the numbers as well.
Exporting a model as one mesh¶
export web, export urdf, export srdf and export moveit all keep a model as a body tree,
because their consumers animate or plan it. A second class of consumer wants the opposite – one rigid
mesh and nothing else. A model-based 6D pose estimator takes a single mesh and returns the pose of that
mesh’s frame; a CAD tool imports one body and knows nothing about joints:
roqsim export mesh --model turtlebot4 --out robot.obj --sidecar robot.json
roqsim export mesh --model turtlebot4 --out robot.3mf --units mm # for CAD
roqsim export mesh --world w.yaml --prefix ur10e_ --out arm.ply # one robot out of a world
The output’s extension picks the format – .stl, .obj, .ply, .3mf – and --format
overrides it, which is also the only way to ask for ASCII STL, since both STL flavours share an
extension. An extension nothing recognises is a usage error rather than a guess.
The frame is the contract. Vertices come out in the frame of one body – --frame, defaulting to
the root of the selection, which for a mobile base is its own base_link because the wheels hang off
it – so a pose estimated against the mesh IS the pose of that body, with nothing to compose onto it.
Every geom transform is composed back through the body tree from the compiled model rather than read
from the world, so exporting a robot from a bare model and from a world that spawns it somewhere odd
gives the same file.
Group 3 is excluded by default: the convention here is that group-3 geometry is collision-only, and a
chassis-swallowing collision cylinder is not a shape the robot has. --groups 3 asks for exactly
that envelope instead, which is the small clean solid to design a mount against.
Formats differ in what they can carry, and it matters:
Format |
Colour |
Structure |
Good for |
|---|---|---|---|
|
none |
one merged mesh |
the universally readable lowest common denominator; states no unit anywhere |
|
|
one merged mesh |
an estimator or renderer that matches on appearance (keep the |
|
per-vertex RGB |
one merged mesh |
the same, in one self-contained file with no sidecar to lose |
|
base materials |
one named object per geom |
CAD: parts stay separable and named, and the unit is stated in the file |
3MF is the one to hand a CAD tool. It is an OPC package (a zip of three XML members, written with the
standard library – no dependency), and unlike the others it does not merge the selection: each geom
becomes a named, coloured <object> assembled into one build item, so left_wheel_cylinder is
something a designer can select and hide rather than anonymous triangles inside a lump. It also
declares unit="millimeter", which removes the guess that makes a 0.35 m robot import 0.35 mm tall.
One caveat: 3MF’s specification wants manifold objects, and packaging does not make a non-watertight
source mesh manifold – consumers repair it, a strict conformance checker will complain.
Units are metres by default, MuJoCo’s own; --units mm is the CAD convention. Since an STL states no
unit anywhere, the choice is always recorded in the JSON summary (and in the file’s header or unit
attribute where the format has one).
Watertightness is reported, not claimed. A merged robot mesh is usually not a closed solid: shipped
visual meshes may have boundary or non-manifold edges, and two geoms that touch do not fuse. That is
fine for rendering and for pose estimation, which need a silhouette and an appearance; a CAD tool may
need a repair pass before mesh -> solid. So the summary counts boundary and non-manifold edges per
geom rather than asserting a solid, and --weld (default 1e-6 m) first merges vertices that are
merely duplicated, which is the common reason a mesh that looks closed is not.
Checking a run is healthy¶
Three things can go wrong with a run and raise no error anywhere: the simulation never starts
stepping, it steps far slower than realtime, or a robot stands still for the whole trial. The process
is up, the log is quiet and the exit status is 0, and the run produced nothing worth analysing.
roqsim health is the check for exactly those three, and nothing else — everything else a run can
get wrong already reports itself.
roqsim health <run-dir> # judge the record as it stands
roqsim health <run-dir> --watch # follow a live run until something is wrong
roqsim health <run-dir> --json # one document: findings, skips, and state
It is a separate process that reads the two records above, and it changes nothing about a run. A
check that lived inside the simulator would share the simulator’s failure modes, and one that spoke
over the ROS bridge could not diagnose a broken bridge; this reads files, so it can also say something
true about a run that is wedged or already dead. The clock map answers checks 2 and 3, and
sim_poses.csv answers check 1.
# |
check |
level |
|---|---|---|
1 |
every watched robot moves ≥ 1 cm per 60 s of sim time |
warn |
2 |
sim time starts advancing within 60 s |
error |
3 |
sim time advances ≥ 5 s per 60 s of wall time (0.083x) |
error |
Check 1 is only a warning because a robot standing still is often correct — waiting on a pedestrian, a
perception-only run, a manipulator-only phase — and a channel that interrupts healthy runs is one
nobody reads. Exit status is 0 when nothing is wrong (warnings are still printed), 5 on an
error-level finding, and 2 when the checks could not run at all. Exiting on a finding is the point:
a backgrounded command’s output is invisible until it exits.
Which of those bodies is a robot comes from the roster. sim_poses.csv names every
named body and cannot say which is which, so the recorder writes entities.json beside it
from the entity registry — name, kind, body, present — and check 1 watches the entities of
kind robot that are currently there. That is what makes one command correct for every world:
a check whose names have to be passed per world is a check that is absent from the run that needed
it. --robot remains as an override, for a run with no roster and for watching something the
registry does not call a robot.
An absent entity is deliberately not watched: its body stays in the compiled model, so the
recorder still writes rows for it, and a robot the trial has not brought in yet is standing still
entirely correctly. With no roster and no --robot, check 1 reports itself skipped and says
which of the two reasons applies — and it warns about a named robot that never appears in the record,
rather than passing one it never looked at.
The same roster is what lets --json’s state block carry a kind per entity, which the pose
record cannot: “the robot has not moved” and “the furniture has not moved” are different readings of
the same row.
The precondition. Both records exist only while a run is recording, so a run without
ROQSIM_RECORD (and ROQSIM_SIM_POSES for check 1) cannot be checked. That is reported and exits
2; it never reads as health.
Who runs it, when a supervisor does. A run harness executes this command itself, in the running
container, on a bounded interval while somebody is watching the run — and reads --json: the
findings, and level in particular, are the contract it acts on. An error-level finding is
what such a supervisor ends a run on. So the exit code and the document are a public interface, and check slugs are names other
software matches on rather than prints. Nothing is pushed from inside the run and nothing is written
into a run’s output by this: it is read on demand and answered.
What one check costs does not grow with the run. Every check judges the newest minute, so each record is entered at the first row inside that window and the rows before it are never read; only what arrives afterwards is read incrementally, and the report notes how much of a long record was left unread. That is what makes it safe to run as a fresh process on every poll, inside the simulator’s own container and memory budget: a reader that parsed a whole record each time would cost more on every poll for as long as the run lasted. A verdict is accordingly about the run now – a robot that stood still earlier and has moved since is not reported.
Silence means different things live and after the fact, which is why the two modes differ. Both
records are sampled on simulated-time boundaries, so a frozen simulation writes nothing at all and
the only evidence of a stall is that nothing arrives. --watch is watching a run it expects to
continue, so that gap counts against it — and it stops without complaint when the .npz appears,
since that file is written by close() and so means the run ended rather than stopped. A one-shot
check has no such premise: it judges the span the record covers, because what happened after the last
row is not in the file. Without that split, every finished run would be reported as a stall a minute
after it ended.
Getting numbers out of a run¶
roqsim state reads the same recording as numbers rather than pixels — poses, joint values, contacts,
sensors:
roqsim state --state run.npz --check # what does this recording offer?
roqsim state --state run.npz --at 12.5 --body base_link # one moment -> JSON
roqsim state --state run.npz --body base_link --out b.csv # whole run -> CSV
roqsim state --state run.npz --joint 'arm_*' --from 8 --to 20 --out arm.csv
roqsim state --state run.npz --sensor front --out scan.npz # the world's own lidar, re-run
--at snaps to the nearest recorded sample and tells you which one it used, so you can see it landed a
few milliseconds off rather than assume it did not. Names accept globs, and a selector that matches
nothing is an error naming the near misses — never an empty column.
Every row leads with both of the recording’s clocks — sim_time and wall_time (elapsed seconds
from the start of the recording, with its origin restated in the CSV header) — so “what was this run
doing when it slowed down?” is answerable from the same file as “where was the robot at t = 12.5”.
An .npz of array series carries them as the times and wall_times members.
--sensor re-runs a sensor the world declares, configured exactly as the world configured it, so
--check is how you see what is on offer. Its output shape decides the file: a few values become CSV
columns, a scan becomes an .npz array, and an image is refused with a pointer to
roqsim render --camera. A re-run sensor is deterministic and gets the noise that moment would have had
(the recording carries the run’s seed), but it is not bit-identical to what the live run published at
that timestamp — live, the sensor fires between recorded samples, so the value published then was
computed a moment earlier.
Rendering a picture¶
roqsim render writes an image of a world without opening a window, so it works over SSH, in a
container, and from a script. It takes the same targets roqsim sim does — a world YAML, a baked
.xml scene, a model reference — plus a raw mesh:
roqsim render world.yaml --out shot.png
roqsim render roqsim_assets:industrial_table --out table.png # one model, auto-framed
roqsim render prop.obj --out prop.png # a mesh, before it is a model
The camera is the world’s own sim.view (above). --view overrides it one key at a time, so you
can re-aim without editing the world; the keys are exactly the ones sim.view accepts:
roqsim render world.yaml --out top.png --view elevation=-85 distance=45
roqsim render world.yaml --out eye.png --view lookat=-3.2,-1.3,1.9 azimuth=200
roqsim render world.yaml --out robot.png --focus robot # find an unblocked angle for me
roqsim render indoor.yaml --out room.png --no-ceiling # see into a roofed world
roqsim render world.yaml --out shot.png --size 1920x1080 --show
--focus searches for a viewpoint with a clear line of sight, which is what you want indoors where
a wall is usually between the default camera and the thing you care about. --show opens the result.
Rendering needs an offscreen GL backend, which import roqsim already selected for this machine;
override it with MUJOCO_GL=egl (GPU) or MUJOCO_GL=osmesa (CPU). If mujoco was somehow
imported before roqsim — the one case that selection cannot reach — roqsim render says exactly
that rather than failing obscurely.
A vector value takes either spelling — lookat=-3.2,-1.3,1.9 or lookat="-3.2 -1.3 1.9", MJCF’s
— so a pose can be pasted back in whichever form you have it.
The command prints one line of JSON describing what it rendered, including the camera it used — in the
same vocabulary --view takes, so you can copy it back to reproduce a shot exactly.
From scenario-execution¶
roqsim implements scenario-execution’s SimulationInterface. Point the world file at it via an
environment variable and load the adapter by module:Class:
export ROQSIM_WORLD=roqsim_mobile/src/roqsim_mobile/worlds/turtlebot4_demo.yaml
scenario_execution --simulation roqsim.scenario_adapter:MujocoSim <scenario.osc>
scenario-execution owns the loop and calls dt / setup / reset / step / shutdown;
one behavior-tree tick advances one mj_step.
By default the adapter runs headless. To show the interactive viewer window, pass a headless
scenario parameter set to "False". This needs a display (the viewer window is always glfw,
independent of MUJOCO_GL); with no DISPLAY set, reset() raises a clear DisplayError
instead of crashing in the native viewer init. Unlike the standalone runner, the adapter does not
apply the runner’s windowed GL defaults (the libGLEW preload and its re-exec); set them yourself if
a windowed adapter run hits gladLoadGL error on a camera world. The offscreen backend is not
among them — that one is chosen by import roqsim, so the adapter gets it like everything else,
which is what makes a stepped run able to render at all.
The adapter can also leave a browser scene descriptor next to the run: set
ROQSIM_SCENE_EXPORT_DIR and every (re)built world is exported as scene.json +
scene.bin (+ textures) into that directory after reset() — the same format
roqsim export web produces, but of the exact simulated world (world_overrides
included), captured at its true initial pose. A relative path resolves against the scenario’s
output_dir (which scenario-execution passes to setup(); under a run harness that is the run’s
result directory, so the descriptor ships as an ordinary run artifact for web viewers), falling back to
the process working directory.
It records on the same environment contract the standalone runner uses (ROQSIM_RECORD,
ROQSIM_CAPTURE_FPS, ROQSIM_CAPTURE_EXPORT_DIR — see Recording a run), with a
relative path anchored to the scenario’s output_dir here, since scenario-execution passes one. That
is what turns a run into something replayable: the descriptor above is the world’s geometry, the
recording is what moved in it, and the run capture is that motion in the form a browser reads.
What a scenario can ask the simulation¶
scenario_execution_roqsim ships the substrate’s own OSC actions — import osc.roqsim:
entity_moved(entities: ['parcel'], threshold: 0.05, mode: displacement_mode!z, dwell: 8.0)
entity_rotated(entities: ['crate'], angle: 0.5)
set_model_override(instance: 'grip_fault') # ...and `active: false` restores it
entity_moved / entity_rotated succeed once the named entities have been displaced (or turned)
from where they were when the action started — net displacement, not path length, unlike
osc.ros’s odometry_distance_traveled. set_model_override applies or restores a
model_override fault (§9.2) and fails the trial when the plugin reports the write changed
nothing, so a run cannot record an unfaulted outcome under a faulted label.
All three work in a stepped run and in a ROS run, unedited: the transport is chosen from what the
runner offered. In-process they read MujocoSim.context (entity poses from data.xpos, the fault
through the model_override:<name> blackboard handle, writes queued with ctx.post); over ROS they
use simulation_interfaces/GetEntityState and <instance>/override. Both are keyed on the same
entity and instance names, which is what makes one scenario serve both — see the package’s README
for why TF is deliberately not the ROS pose source. None of them can run under remote(): a remote
server is handed no simulation.
ROS 2 bridge¶
source /opt/ros/jazzy/setup.bash
source ros2_ws/install/setup.bash
.venv/bin/python -m roqsim_ros_bridge.run_bridge \
--world ros2_ws/src/roqsim_ros_bridge/worlds/turtlebot_ros2.yaml
# in another sourced shell:
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist '{linear: {x: 0.2}, angular: {z: 0.3}}'
ros2 topic echo /odom
ros2 topic echo /scan
ros2 service call /get_simulator_features simulation_interfaces/srv/GetSimulatorFeatures
The bridge publishes /clock; run other nodes with use_sim_time:=true. See
nav2 example for a full navigation stack.