Available plugins¶
Built-in plugins by package. Reference each by its short name in a world file (or by
module:Class / file.py:Class for your own — see Interfaces).
The static environment — ground, light, enclosure — is one slot, filled either by a world
definition selected with sim.world (default empty_room; see Architecture & porting playbook) or by a
scene plugin that builds its own, such as the mobile floorplan or heightfield. A fixed cell
needs no scene plugin. Naming both is refused, and so is carrying two such plugins: two grounds
compile into a scene that looks right and is not the one you wrote.
Which route a given environment takes follows from whether it is baked or computed. A world definition is a builder or an MJCF file — fixed geometry, chosen by name. A plugin builds from a config, so a sweep can vary it and each run records the values it used. An environment whose shape is an experiment’s variable is therefore a plugin.
floorplan takes its building from one of two sources, and you pick by what you have. A
mesh: is right when the building already exists as geometry (imported from CAD, or produced by
Floorplan-DSL); its json-ld supplies exact colliders. Wall segments are right when the walls
are the experiment’s variable:
components:
- floorplan:
lines:
- {id: 0, x0_m: 0.0, y0_m: 0.0, x1_m: 6.0, y1_m: 0.0}
- {id: 1, x0_m: 6.0, y0_m: 0.0, x1_m: 6.0, y1_m: 4.0}
doors: [{line_id: 0, t: 0.5, width_m: 0.9}]
height: 2.5
floorplan: rooms.json reads the same shape from the file roqsim scenes dxf-to-floorplan and
the scene-builder’s sketch window write, which is the usual way in. Segments build one box per wall
— visible and collidable, since a box is already convex, so there is no hidden companion collider
to disagree with what is drawn. A corridor width is then an ordinary config value a sweep varies and
the run’s provenance records, rather than a mesh baked ahead of time that nothing downstream can
tell apart from another mesh. Naming both sources, or neither, is refused. The wall arithmetic is
shared with the mesh baker and the plan-view renderer, so a preview, a baked world and this plugin
cut the same openings.
Any endpoint-producing plugin below accepts an optional topics: map to set an endpoint’s ROS
topic. An absolute name hardwires it, overriding the namespace+default — e.g. topics: {image:
/camera/color/image_raw}; a relative name renames it inside the namespace — e.g. topics: {scan:
scan2} for a robot’s second scanner. See Architecture & porting playbook › Hardwired topics.
Core (roqsim)
bumper
Flags: parallel_safe
Observation plugin: a bumper – which ZONE of a body is being pushed, every step.
Config:
bumper:
# The entity watched is the one this entry is NESTED UNDER -- there is no key for it, and
# declaring it at the top of a document is refused (`requires_owner`).
body: "" # base body override; default: the entity's registered base body
namespace: "" # transport scope for the endpoints
zones: # bearing sectors, radians in the base frame, counter-clockwise from
front: [-0.52, 0.52] # +x: {<zone>: [from, to]}. A sector with from > to wraps through
left: [0.52, 1.57] # +/-pi, so a rear zone is `[2.6, -2.6]`.
geoms: [] # geom NAMES that ARE the bumper (default: the entity's whole subtree,
# like contact_monitor). A real bumper is one shell; a contact on the
# chassis roof presses no switch, so a model that names its shell
# geoms lists them here.
geom_prefixes: [] # geom name prefixes that are the bumper
ignore: [floor] # geom NAMES that never count (default: ['floor'])
ignore_prefixes: [] # geom name prefixes that never count (e.g. ['ground'])
min_force: 1.0 # N; contacts below this normal force are ignored (numerical grazing)
rate_hz: 62.0 # endpoint publish rate
One out endpoint per zone, named bumper/<zone>, reads a bool: is that zone pressed this
step. The ROS 2 backend hint publishes each as a std_msgs/Bool on bumper/<zone> (relative,
so it is scoped by the entity’s namespace). A stack that wants a vendor’s message assembles it from
these in its own adapter node – a bumper switch is a bool on every robot, and the vendor’s
envelope around it is the stack’s business, not the simulator’s.
Read it through the blackboard, not the endpoint, inside a control loop.
ctx.blackboard.get(f"bumper:{address}") returns a callable giving the current
BumperReading, the same convention contact_monitor and contact_location use.
Not latched, on purpose: a bumper releases when the robot backs off, and a stack that needs “it
bumped at some point” reads contact_monitor. Where a contact falls into no declared zone
(behind a robot with a front bumper only), nothing is pressed – exactly as a shell that is not
there reports nothing, and as contact_monitor still reports the collision.
ceiling
Flags: parallel_safe
Scene plugin: open a world’s roof by deleting its ceiling geometry at build time.
Config:
ceiling:
keep: true # true = keep the ceiling (no-op, the default, so adding this plugin never
# surprise-deletes geometry). false = remove it.
above_z: 2.5 # a geom is "ceiling" iff its whole world-space AABB is above this height (m)
Set keep: false in the world YAML (or via --set / an extends override / a campaign
factor) to open the roof.
The key is keep rather than enabled because this plugin is SUBTRACTIVE: it does its work by
removing geometry, so it is the entry running that opens the roof. enabled: is the reserved
sibling meaning “run this component at all”, so ceiling: {enabled: false} leaves the ceiling
standing – the opposite of what someone writing it wants. It is refused by name rather than obeyed.
Removal happens in build (pre-compile); the engine’s dedup_assets pass then drops the
textures the removed geoms leave unreferenced.
clearance_monitor
Flags: parallel_safe
Observation plugin: how close an entity came to something it should not touch.
Config:
clearance_monitor:
body: "" # base body override; default: the entity's registered base
ignore: [floor] # geom NAMES that never count (default: ['floor'])
ignore_prefixes: [] # geom name prefixes that never count
distmax: 5.0 # [m] cutoff: beyond this the distance is not computed
compute_rate_hz: 200.0 # how often the distance is MEASURED
rate_hz: 30.0 # how often the endpoint is PUBLISHED
Endpoint clearance (out) reads a ClearanceReport:
(current, minimum, at_time, geom, saturated) – minimum is the closest approach
since the last reset and geom names what it was to, so a near-miss is attributable
rather than merely flagged.
compute_rate_hz is separate from rate_hz because they answer different questions.
Publishing is cheap; measuring is a distance query per (watched geom, candidate geom) pair,
which on a nav world costs about a quarter of the step budget if done every physics step –
against a budget the simulator may already be over. Poses reach a consumer at ~30 Hz, so
measuring at 200 Hz resolves ~1.5 mm at walking pace: far finer than anything downstream can
use, at a fraction of the cost. Raise it if a fast pass matters more than throughput; the
trade is stated here rather than hidden in a default.
distmax is the other performance knob and it is a real one: the cost is a distance query
per (watched geom, candidate geom) pair, and the cutoff lets MuJoCo reject far pairs
cheaply. Beyond it the report reads current == distmax with saturated true, which
says “at least this far” rather than offering a number that looks measured.
contact_impulse
Observation plugin: how hard a watched entity was hit, integrated over the run.
Config:
contact_impulse:
# The entity watched is the one this entry is NESTED UNDER -- there is no key for it, and
# declaring it at the top of a document is refused (`requires_owner`).
body: "" # base body override; default: the entity's registered base body
namespace: "" # transport scope for the endpoint
ignore: [floor] # geom NAMES that never count (default: ['floor'])
ignore_prefixes: [] # geom name prefixes that never count (e.g. ['ground'])
reset_on_spawn: true # spawning the watched entity restarts the integral
rate_hz: 30.0 # endpoint publish rate -- of the RUNNING TOTAL, not of the integrand
Endpoint contact_impulse (out) reads a ContactImpulseReport:
(impulse_ns, peak_normal_n, contact_time_s, normal_n, count, peak_time, peak_geom_a,
peak_geom_b). The three totals run from the last reset; normal_n and count are the current
step’s, so the report says what it is integrating as well as what it has integrated. peak_time
is the sim time of the largest single-step load (-1.0 if nothing was touched) and the two geom
names are that step’s strongest single contact, so a severity figure is attributable rather than
merely large.
contact_time_s is the time a qualifying contact existed, which is contact_monitor’s
notion of touching and is longer than the time force was transmitted: MuJoCo goes on listing a pair
while the two geoms still overlap on the way apart, and those steps carry a zero normal force. The
impulse is unaffected – a zero integrand adds nothing – and the alternative, a duration that
switched off before the monitor’s in_contact did, would be the second rule this plugin exists
not to have. The ROS 2 backend hint publishes impulse_ns as a std_msgs/Float64 on
contact_impulse; a consumer that wants the rest reads the report through the blackboard handle
contact_impulse:<address>.
Publishing is rate-limited and the integral is not. rate_hz decides how often the running
total leaves the plugin, and no value of it can lose a contact: the total a slow endpoint publishes
is the same total a fast one publishes, just later. There is deliberately no compute_rate_hz
knob of the kind clearance_monitor and contact_location offer – decimating this
computation would drop the samples the integral is made of, and the number would then be a function
of the knob.
It never ends a trial, the line clearance_monitor and energy_monitor draw as well. What
counts as too hard is the experiment’s threshold, stated in the experiment; a scenario reads the
endpoint and decides for itself.
What a reset does. on_reset – ResetSimulation with SCOPE_STATE, and what runs
between the trials one process serves – zeroes all three totals. Without it the second cell of a
campaign starts with the first cell’s collisions on its bill. reset_on_spawn (default true)
does the same when the watched entity gains presence, because an entity that was not in the world a
moment ago carries no history and because contact_monitor restarts there too – one of the two
carrying a contact the other had forgotten is exactly the disagreement this plugin is shaped to
avoid. A SetEntityState is a pose and a twist, and resets nothing.
Untouched, the report reads impulse_ns = 0.0, peak_normal_n = 0.0, contact_time_s = 0.0
and peak_time = -1.0 – a measured zero, which is what “nothing was hit” is.
contact_location
Flags: parallel_safe
Observation plugin: report where a watched entity is being touched, every step.
Config:
contact_location:
# The entity watched is the one this entry is NESTED UNDER -- there is no key for it, and
# declaring it at the top of a document is refused (`requires_owner`).
body: "" # base body override; default: the entity's registered base body
namespace: "" # transport scope for the endpoint
ignore: [floor] # geom NAMES that never count (default: ['floor'])
ignore_prefixes: [] # geom name prefixes that never count (e.g. ['ground'])
min_force: 1.0 # N; contacts below this normal force are ignored (numerical grazing)
merge_radius: 0.02 # m; contacts closer than this count as one sensing area
frame: base # 'base' -> report in the watched body's frame; 'world' -> world frame
compute_rate_hz: 0.0 # how often the reading is COMPUTED; 0 = every physics step
rate_hz: 30.0 # how often the endpoint is PUBLISHED
Endpoint contact_location (out) reads a ContactLocation:
(in_contact, kind, x, y, z, extent, count, time). kind is "none", "point" or
"line"; x/y/z is the region’s centre and extent the distance between its two furthest
members (0.0 for a point), so a consumer gets both the location and how spread out it is. The ROS 2
backend hint publishes the centre as a geometry_msgs/PointStamped on contact_location.
Read it through the blackboard, not the endpoint, inside a control loop. The endpoint is
rate-limited for logging; ctx.blackboard.get(f"contact_location:{address}") returns a callable
giving the current reading, the same convention contact_monitor and force_torque use.
Cost. The per-step work is a vectorised pass over data.contact – a mask lookup per contact and
an xor – and a contact force query only for the handful that survive it. That matters because a
world’s contacts are overwhelmingly pairs the robot is not in (props resting on the floor, a
crowd’s feet), and touching each of them from Python costs more than the physics step that produced
them. compute_rate_hz decimates the whole computation for the case where even that is too much;
it defaults to 0, meaning every step, because unlike a latching monitor this plugin cannot recover a
contact it did not look at – one shorter than the interval is simply missed.
Frames. frame: base (the default) rotates the position into the watched body’s own frame, which
is what a controller reasoning about “contact on my left” wants and what makes a reading independent
of where the robot happens to be standing. frame: world leaves it in world coordinates.
contact_monitor
Flags: parallel_safe
Observation plugin: report when an entity touches something it should not.
Config:
contact_monitor:
# The entity watched is the one this entry is NESTED UNDER -- there is no key for it, and
# declaring it at the top of a document is refused (`requires_owner`).
body: "" # base body override; default: the entity's registered base body
namespace: "" # transport scope for the endpoint
ignore: [floor] # geom NAMES that never count as a collision (default: ['floor'])
ignore_prefixes: [] # geom name prefixes that never count (e.g. ['ground'])
min_force: 1.0 # N; contacts below this normal force are ignored (numerical grazing)
latch: true # once true, stay true until on_reset (a trial is failed, not un-failed)
reset_on_spawn: true # spawning the watched entity restarts the report (see below)
rate_hz: 30.0 # endpoint publish rate
Endpoint contact (out) reads a ContactReport:
(in_contact, first_time, count, geom_a, geom_b) – first_time is the simulation time of the
first qualifying contact since reset (-1.0 if none), and geom_a/geom_b name the geoms of
that first contact, so a failure is attributable rather than just flagged. The ROS 2 backend hint
publishes in_contact as a std_msgs/Bool on collision (relative, so it is scoped by the
entity’s namespace: two namespaced robots get /a/collision and /b/collision); a bridge that
wants the detail reads the fields directly.
The watched set is the entity’s kinematic subtree: for a mobile base that is the chassis plus its wheels, so a wheel clipping a box counts exactly as much as the bumper does.
When the trial spawns the watched entity. reset_on_spawn (default true) restarts the
report when the watched entity GAINS PRESENCE. An entity that has just been spawned has no
history: it was not in the world a moment ago, so nothing it touched before it went absent is a
fact about it now, and with latch that would otherwise be a permanent one.
Only presence does this. A SetEntityState does not, because the service is specified as “an
instant change in its pose and/or twist” and nothing more – a simulator that also cleared an
observer there would be answering a question the caller did not ask. The standard’s verb for
discarding accumulated state is ResetSimulation with SCOPE_STATE, which reaches this
plugin’s on_reset() like any other.
That distinction decides how a trial should place a robot it does not want observed at the pose
the world compiled it at: spawn it into position rather than teleport it there. An absent
entity’s geoms carry no contype/conaffinity, so it registers no contact at all while
absent, and presence and pose are applied in ONE transaction – so there is no step in which it is
perceivable where the world happened to put it. A trial that teleports a present robot instead is
observed at the compiled pose, and an obstacle placed there by a campaign that never knew about it
is a collision before the trial has moved.
Set reset_on_spawn false where a re-spawned entity should carry its history forward.
contact_pair_override
Scene plugin: override the contact between ONE pair of things.
dummy
Flags: parallel_safe
A trivial plugin used to validate the framework end-to-end without any assets.
Config:
dummy:
size: 0.1 # half-extent (m) of the free-floating box this plugin adds; must be > 0
energy_monitor
Observation plugin: what a robot’s actuators cost it, integrated over the run.
Config:
energy_monitor:
# The entity is the one this entry is NESTED UNDER (`requires_owner`): a battery belongs to a
# robot, and which actuators count is decided by which ones move it.
actuators: [] # names to meter (default: every actuator driving this entity's bodies)
efficiency: 1.0 # mechanical -> electrical; 0 < e <= 1
idle_w: 0.0 # W drawn regardless of motion (compute, sensors)
resistive_w_per_nm2: 0.0 # winding loss k in k*tau^2; a number, or {actuator_name: k}
regenerative: false # credit negative mechanical power back
capacity_wh: 0.0 # 0 = no battery modelled: energy is still reported, charge is not
voltage: 0.0 # V, nominal; 0 = unknown, and the current is then not reported
rate_hz: 5.0 # endpoint publish rate
Endpoint battery (out) reads an EnergyReport and carries a sensor_msgs/BatteryState
hint on battery_state – the message a real platform publishes, so a stack that already watches a
battery needs no change. An EnergyReader is published on the blackboard under
energy:<address> for an in-process consumer, and the report carries the raw joules as well as the
derived state of charge, because the metric a paper quotes is usually the integral, not the fraction.
For the same reason it carries torque_integral_nms, the integral of the summed absolute actuator
forces: where a platform’s electrical constants are not published, that effort integral is the metric
a paper falls back on, and accumulated here it is the physics-rate quantity rather than a sum over
whatever rate /joint_states happened to be published at.
The integral is accumulated on the physics thread, every step, not on read: a rate-limited or
subscriber-gated sample would silently integrate a different signal depending on who was listening.
It is integrated against elapsed sim time rather than a fixed dt so a replay over recorded
samples (see roqsim.recording) accumulates the same way, at its own spacing.
heightfield
Flags: provides_world
World plugin: outdoor ground – a height field, from a DEM or generated.
Config:
heightfield:
source: generated # 'generated', or a path to .npy / .png / .tif (relative to the world)
size: [20.0, 20.0] # ground extent in metres (x, y)
height: 1.5 # metres from the lowest sample to the highest
base: 1.0 # metres of solid below the lowest sample -- a wall, not a shell
resolution: 96 # samples per side, when generated (a DEM keeps its own)
seed: 0 # generated terrain is reproducible from this
roughness: 0.55 # 0..1: how much each finer octave contributes
octaves: 4 # how many scales of detail
friction: [1.0, 0.005, 0.0001] # ground friction, as MuJoCo's three coefficients
rgba: [0.42, 0.38, 0.30, 1.0]
light: true # add a ceiling-height light (the world definition it replaces had one)
Why it provides the world. sim.world builds a floor; a terrain that let one be built would
put a plane through its own hills. Declaring provides_world is how the floorplan plugin
already says the same thing, and the engine then skips the world definition and warns if one was also
asked for.
Contact against a height field is against its triangles, not a smoothed surface, so the sample
spacing is the resolution of every wheel and foot interaction: 96 samples over 20 m is a 21 cm grid,
which a 10 cm wheel rides as facets. Raise resolution for a small rough patch rather than for a
large smooth one – the cost is quadratic and it buys nothing where the ground is flat.
joint_state_publisher
Flags: parallel_safe
Observation plugin: every joint of an entity as one joint_states message.
Config:
joint_state_publisher:
# The entity read is the one this entry is NESTED UNDER; at the top of a document it is
# refused (`requires_owner`).
namespace: "" # transport scope for the endpoint
joints: [] # joint NAMES to publish, the model's own before any spawn prefix;
# default: every hinge and slide joint of the entity's subtree
rate_hz: 50.0 # endpoint publish rate
Endpoint joint_states (out) reads (names, positions, velocities, efforts); the ROS 2
backend hint publishes it as sensor_msgs/JointState on joint_states (relative, so it is
scoped by the entity’s namespace). Names are published without the spawn prefix, as every other
producer here names joints, so a description published alongside matches them.
Only hinge and slide joints are published: a JointState carries one scalar per joint, which a
ball or free joint’s quaternion is not. A named joint that is not in the model, or not on this
entity, raises – a state nobody publishes is a consumer reading zeros as a fact.
model_override
Flags: parallel_safe
Fault plugin: change named MuJoCo model values while a run is in progress, on an external trigger.
Config:
model_override:
overrides: # one or more; each names a field, a selection and a target
- field: geom_friction # must be on the allowlist (see `field_catalog`)
select: [pad_left, pad_right] # names in the field's own namespace (geom/body/actuator/joint)
bodies: [] # ...or every geom of these bodies' subtrees (geom fields only)
entity: "" # ...or an entity's body subtree (geom fields only)
to: 0.0 # scalar (broadcast) or the field's full row
active: false # initial state; false = nominal, i.e. the plugin is inert
namespace: "" # transport scope; defaults to this instance's `name:`, so two
# faults in one world do not both serve `/override`
rate_hz: 10.0 # publish rate of the two out endpoints
Endpoint override (in) is a service, std_srvs/SetBool: apply or restore, replying
success plus a message carrying the verdict – so a scenario’s service_call() can fail the
trial when a fault did not land, instead of a warning nobody reads. Endpoints override_state
(Bool of active) and override_verified (String of verified) publish continuously,
because a service call leaves no trace in a rosbag and mjModel is in neither the bag nor the state
recording – without them an injected fault is invisible to every downstream analysis.
All three are scoped by the instance’s name:, so the world above serves
/grip_fault/override, /grip_fault/override_state and /grip_fault/override_verified.
In-process, ctx.blackboard carries a ModelOverrideHandle under
model_override:<name>, which is how a ROS-free stepped run (an .osc action, a test) fires it –
and the plugin imports no ROS at all, so a world using it runs in a plain venv with no middleware
installed. The service is what the same fault looks like when a bridge is present.
payload
Component plugin: a carried payload, as added mass on a robot’s body.
Config:
payload:
mass: 2.5 # kg, REQUIRED -- added to the body's own mass
body: tray # body to load (default: the entity's root body)
The payload is a point mass at the body’s centre of mass: mass adds, and the inertia a point
mass contributes about its own centre is zero. An offset payload is a different physical object –
it shifts the centre of mass and adds a parallel-axis inertia term, changing the attitude dynamics
rather than the load alone – and representing it means adding a child body before compile. An
offset key is therefore refused rather than approximated.
The mass is applied in configure, after compile: the entity registry is what maps robot: to
a prefixed MJCF body name, and it is populated by the spawn plugins during configure. Writing
model.body_mass and re-deriving the cached constants with mj_setConst is the same mechanism
roqsim.plugins.model_override uses for body_mass.
The mass is stated in the world and therefore recorded with it, so a run’s provenance carries the payload even though the compiled MJCF’s own inertial does not.
spawn_model
Scene plugin: place a static model (a prop) into the world from the world YAML, at a fixed pose.
Config:
spawn_model:
model: industrial_table # bundled model name, filename, or absolute path
prefix: "" # MJCF name prefix (use distinct prefixes for >1 of the model)
pose: # the mount pose, as SpawnEntity states one (see below)
position: {x: 0.0, y: 0.0, z: 0.0}
orientation: {roll: 0.0, pitch: 0.0, yaw: 0.0}
scale: 1.0 # uniform geometric scale factor (see below)
motion: physics # who owns the pose: physics (default; a movable body),
# static (welded scenery), driven (a plugin writes it)
mocap: false # make it a mocap body: moved by a plugin, not by physics
present: true # false: compiled in, but absent until it is spawned
mass: 0.5 # override the root body's total geom mass (kg)
friction: [1.2, 0.005, 0.0001] # override the root body's geom friction (or a single sliding val)
publish_tf: false # publish the root body's world pose as TF (see below)
tf_rate: 30.0 # publish_tf: dynamic -- stream rate (Hz)
name: is the entry’s reserved SIBLING, not one of the keys above: it labels the entry and names
the entity this spawn registers (default: the plugin ref). Written inside the config block it is
silently inert, and anything addressing the prop by the name you chose then resolves to nothing.
mass and friction exist so the two properties that decide whether a grasp holds are world-YAML
keys, and therefore ordinary campaign factors – a sweep over payload or surface friction needs no new
variation plugin, just ParameterVariationList against these. mass rescales the root body’s geom
masses in proportion, keeping the mass distribution of a multi-geom prop; friction accepts a single
sliding coefficient or the full [sliding, torsional, rolling] triple. Both are refused when the prop
has nothing to scale, rather than silently doing nothing.
A prop is in one of three states, and they are mutually exclusive: welded scenery (the default),
a free body physics moves, or a mocap body some plugin drives. free and mocap name the
two non-default ones.
motion: driven makes the prop’s root body a MuJoCo mocap body: it has no degrees of freedom, so
it costs the solver nothing and nothing can push it, but it is still collision geometry a lidar sees
and a robot bumps into. Its pose is written every step by whoever owns it – a navigator
component nested under this entry, say – rather than integrated. That is what a controlled obstacle
is: it goes where the experiment says, and the robot under test cannot shove it off course. Like
free, it is re-seated at its spawn pose on on_reset (through mocap_pos/mocap_quat
rather than a joint), so a repetition never inherits where the last one left it.
motion: physics adds a <freejoint/> to the prop’s root body, making it a body physics moves –
a box a robot can pick up. It also registers the joint as the entity’s
base_joint, which is what lets simulation_interfaces’ SetEntityState teleport or re-seat it
(the service rejects any entity without one), and what on_reset uses to put it back at its spawn
pose between episodes instead of leaving it wherever the last run dropped it.
The prop must end up with mass. MuJoCo derives it from geom volume x density (default 1000), so an
ordinary prop is fine as-is; a geom carrying density="0" – the convention for visual-only
decoration, used throughout the robot models here – is not, and MuJoCo rejects a massless moving body
at compile time rather than simulating it.
Pair it with publish_tf: dynamic: nothing else publishes a free body’s pose.
scale resizes the prop at spawn time, so one asset serves every size a scene needs instead of the
library carrying a folder per size (a 2.9 m wall screen and a 1.2 m one are the same model). It is
applied to the loaded child spec before attach, and covers the whole geometry – mesh scales, and the
positions/sizes of bodies, geoms, sites, lights and joints – so a prop built from primitives or from
several offset parts scales as one piece rather than coming apart. Two spawns of one model at
different scales stay distinct assets: the dedup key in roqsim.assets includes mesh.scale.
It is deliberately a single uniform factor. Non-uniform scaling is ill-defined for a sphere or a
capsule and silently shears any child body that is rotated relative to its parent, so a prop that must
be stretched on one axis needs a purpose-built plugin instead (as door does for its leaf).
scale is geometry only – it does not touch mass or inertia, which is why it suits the static
scenery this plugin places (there is no free joint) and not a dynamic body.
pose: is the mount pose, in the shape SpawnEntity.srv gives its initial_pose. Its
orientation may be a quaternion or Euler angles, so a prop turned about +Z stays short:
- spawn_model: {model: industrial_table, pose: {position: {x: -0.13, y: 0.6},
orientation: {yaw: 1.5708}}}
It is the only way to state one, for the reason roqsim.pose gives: a document declaring where
a thing sits and a SpawnEntity call placing it are the same pose, so they are written the same
way. Omitting it mounts the prop at the origin, unrotated; an omitted position.z is the floor,
which is what a prop’s frame means by z (a robot’s is its model’s resting height, since a wheeled
base has one and a prop does not). scale, mass and friction are unaffected – they are
properties of the prop, not of where it is.
present: false compiles the prop in and starts it absent: nothing sees or touches it, and the
control plane does not list it, until SpawnEntity brings it in at the pose that call states. This
is how a world provides the spares for an obstacle that must appear mid-trial – roqsim never
recompiles, so everything a trial may bring in is declared up front (see roqsim.presence). The
declared value is restored on on_reset, so a spare spawned in one episode is a spare again in the
next; without that, repetitions after the first would begin with an obstacle already in the room.
It is not a way to leave a prop out: enabled: false does that, and does it properly, by never
building the body at all. An absent prop still costs its geometry in the compiled model – that is
what makes it spawnable.
Unlike the spawn_* plugins for robots/sensors this does not pull in a model manifest – a prop
is inert geometry with no intrinsic controller or sensors. Place several by listing the plugin
multiple times with distinct prefix (and name).
publish_tf puts the spawned root body’s world pose on TF so a viewer binds the scene node by name
(child_frame_id == the exported body name) and a TF-tree consumer (rviz, an rso_web_backend federation)
gets the frame. It has no effect on the baked web scene, which already seats the body at its spawn pose.
false(default): a static prop already seated by the baked scene needs no TF.
dynamic(ortrue): stream the live world pose on the relativetftopic attf_rate. For a free body (a<freejoint/>prop the robot moves) – nothing else publishes its pose, so a name-binding viewer would otherwise freeze it at the spawn pose. The ros2_bridge’sgtconfig maps the relative topic to/gt/tf; a multi-robot gateway federates it under the robot’s scope.
static: publish the world pose once on the latched/tf_static(a welded prop’s frame for the TF tree). The pose is model-fixed, so onemj_forwardat configure resolves it.
upright_monitor
Flags: parallel_safe
Observation plugin: did a body a trial drives in the plane stop being in the plane?
Config:
upright_monitor:
# The entity watched is the one this entry is NESTED UNDER -- there is no key for it, and
# declaring it at the top of a document is refused (`requires_owner`).
body: "" # base body override; default: the entity's registered base body
max_tilt_deg: 30.0 # degrees the body's own +z may lean from world +z
max_rise_m: 0.10 # metres its height may depart from where it settled, either way
settle_s: 0.5 # sim time to let the body come to rest before judging it, and the
# moment its reference height is taken
namespace: "" # transport scope for the endpoint
latch: true # once fallen, stay fallen until on_reset (a trial is failed, not
# un-failed -- the same rule contact_monitor follows)
rate_hz: 30.0 # endpoint publish rate
Endpoint upright (out) reads an UprightReport. The ROS 2 backend hint publishes
upright as a std_msgs/Bool on upright (relative, so two namespaced entities get
/a/upright and /b/upright); a consumer wanting the detail reads the fields.
The reference height is where the body SETTLED, not where it was spawned, which is what
settle_s buys. A body spawned twenty centimetres above the floor drops onto it, and measuring
against the spawn would call that drop a departure – flagging every world whose author did not
place a body to the millimetre, which is a monitor people turn off. Nothing is judged and no
reference is taken until settle_s; it is measured per episode, so a repetition compares
against its own start.
The cost is that a body already broken at t=0 is not reported for half a second. That is the right
trade: the verdict latches, so it is reported a moment later rather than not at all, whereas a
false positive on a correct world is reported forever. Set settle_s: 0 where a trial starts in
contact and the first instant matters.
Both thresholds are departures, not limits. max_rise_m is symmetric: a body sinking through
the floor has left the plane exactly as much as one taking off, and a run where the ground gave way
is no more usable than one where the pedestrian flew. max_tilt_deg is the angle between the
body’s own +z and the world’s, so it says nothing about yaw – a body turning on the spot is doing
what a planar trial expects.
Not a failure criterion for a robot that is meant to tip. A quadruped mid-gait, an aerial vehicle banking, an arm’s wrist – all of these leave the plane on purpose. This watches an entity because somebody nested it under one; nothing infers that an entity should be upright.
roqsim_aerial
multirotor_motors
Actuation plugin: normalized per-rotor commands -> rotor thrusts and their reaction torques.
Config:
multirotor_motors:
robot: drone # entity name registered by spawn_robot
namespace: "" # transport scope (default: inherited from spawn_robot)
body: x500 # root body the reaction torque acts on (default: entity's root)
rotors: [rotor0_thrust, rotor1_thrust, rotor2_thrust, rotor3_thrust]
spin: [1, 1, -1, -1] # +1 = CCW seen from above, -1 = CW; PX4 quad-X order
max_thrust: null # N per rotor; default: read from each actuator's ctrlrange
moment_constant: 0.05 # m, yaw torque per newton of thrust (PX4 CA_ROTOR*_KM)
time_constant: 0.02 # s, first-order motor + ESC lag
``max_thrust`` defaults to the model’s own ``ctrlrange``, not to a constant here. The MJCF is the authority on its actuator limits; a hardcoded default would let a model and its actuation plugin disagree about what “1.0” means, and the disagreement would show up as a thrust-to-weight ratio nobody chose.
``moment_constant`` is k_m/k_f, the torque a rotor drags per newton of thrust it makes, in
metres. The default 0.05 is not a generic propeller estimate: it is PX4’s published value for this
airframe, CA_ROTOR*_KM in 4001_gz_x500. This number must agree between the airframe and
the flight stack’s mixer. PX4’s control allocator inverts its own KM to decide how much
differential thrust a commanded yaw moment needs; if the simulator drags a different amount per
newton, every yaw command is scaled wrong – sluggish or oscillatory yaw that reads as a badly tuned
rate loop rather than as two halves of the system disagreeing about a propeller. It is config here
rather than a gear in the MJCF because it is a motor/propeller property, so a re-propped airframe
changes it without touching the frame.
The spin sign convention, which is the easy thing to get backwards. A rotor spinning CCW (+1)
pushes air down and, by reaction, drags the airframe CW – i.e. in -z. So the yaw torque a rotor
applies to the body is -spin_i * k_m * T_i about body z, and the contracted pattern
(+1, +1, -1, -1) sums to zero at equal thrust: two rotors of each handedness, which is the whole
reason a quad has two of each. Yaw is commanded by spinning up the pair of one handedness and down
the pair of the other, which is also why yaw authority is the weakest axis on a multirotor.
The reaction torque is applied in the WORLD frame. data.xfrc_applied is a cartesian
force/torque about the body CoM expressed in world coordinates, so the body-z torque is rotated by
the body’s rotation before it is written. Writing a body-frame torque straight in is correct only
while the drone is level – and it is wrong precisely when the drone is tilted, which is when yaw
control is being exercised.
Air matters. density/viscosity default to 0 in MuJoCo, so a world that does not set
them flies this drone through a vacuum: full rotor authority, no aerodynamic damping, and a
disturbance that never settles. The plugin warns rather than silently flying in vacuum.
px4_sitl
Bridge plugin: PX4 SITL flies this airframe, over PX4’s simulator-agnostic MAVLink HIL API.
Config:
px4_sitl:
port: 4560 # PX4's documented simulator port; PX4 connects OUT to us
bind: "127.0.0.1" # loopback: PX4 SITL runs beside the simulator
lockstep: true
body: x500 # the flown body (default: the entity root)
imu_rate: 250.0 # Hz -- HIL_SENSOR cadence
mag_field: {north: 0.21, east: 0.0, down: 0.43} # gauss, world NED
baro: {sea_level_pressure: 1013.25, temperature: 20.0} # hPa, degC
sensor_noise: {accel: 0.02, gyro: 0.002, mag: 0.002, baro: 0.05} # 1-sigma
connect_timeout: 60.0 # s to wait for PX4 before failing
ground_truth: true # send HIL_STATE_QUATERNION
PX4 is the client, we are the server. Confirmed against PX4’s own SITL startup path, which
runs simulator_mavlink start -c <port> (or -h/-t with PX4_SIM_HOSTNAME /
PX4_SIM_HOST_ADDR): the module dials out to the simulator. The port is 4560 + instance, so
the default here is right for a single vehicle and a second PX4 instance wants port: 4561.
250 Hz is not a guess. The same startup path sets IMU_INTEG_RATE to 250, which is the rate
PX4 integrates HIL_SENSOR at; the default here matches it. That path also sets
SENS_GPS0_DELAY/SENS_GPS1_DELAY to 10 ms, i.e. EKF2 is told to expect a fix that is 10 ms
old – so timestamping HIL_GPS with the current sim time, as this plugin does, is already
consistent with PX4’s assumption and no artificial delay belongs here.
Thrust numbers live in the model, never here. HIL_ACTUATOR_CONTROLS is normalized 0..1 and
goes straight through MotorsHandle.set_normalized; what a 1.0 is worth in newtons is the MJCF’s
ctrlrange, which multirotor_motors reads and exposes as max_thrust. A number duplicated
here would be one that could disagree with the airframe being flown.
``imu_rate`` must divide sensibly into the sim rate. Sensors are sent on whole ticks, so the
achieved rate is 1 / (ceil(1 / (imu_rate * dt)) * dt): at the usual dt = 0.002 s an
imu_rate of 250 Hz lands exactly (every 2nd tick), 300 Hz silently becomes 250. The plugin logs
the rate it will actually achieve rather than the one that was asked for.
Zero-noise sensors are not the neutral choice. It is tempting to send the physics engine’s exact values and call the result “the ideal case”, but EKF2 is tuned against noisy inputs: its process and measurement covariances assume a certain amount of jitter, and a perfect IMU is an out-of-distribution input for it – innovation gates behave differently, covariances collapse, and the estimator’s behaviour stops being the one that flies on hardware. The defaults here are small but non-zero for that reason; set them to zero deliberately and knowing that the run no longer says anything about the estimator.
The magnetic field default [0.21, 0.0, 0.43] G (north, east, down) is a representative
mid-latitude northern-hemisphere IGRF value (~0.48 G total, ~64 degrees inclination, declination
taken as zero). It is a stand-in for the datum’s actual field, not a computed one: this plugin
does not carry an IGRF model, so an experiment whose result depends on declination must set the
field for its datum explicitly.
Frames. MuJoCo is ENU (world) / FLU (body); MAVLink HIL is NED (world) / FRD (body). Every
conversion goes through enu_to_ned(), flu_to_frd() and quat_enu_flu_to_ned_frd(),
and nowhere else. This is not fussiness: a sign error here produces a drone that flies confidently
in the wrong direction, arms and takes off exactly as it should, and is the single most common way
this integration is got wrong. One helper pair means one place to check.
Threading follows architecture.rst section 7 exactly. The socket is opened in configure
(which is where that section puts socket setup) and served by one background thread, and that
thread never touches ctx.data, the motors handle, or any plugin state the physics thread
reads. It parses frames and enqueues the result with ctx.post(...); the engine drains the queue
at the start of the next pre_step, on the physics thread, in FIFO order. So the actuator update
lands at a defined point in the tick rather than whenever the OS scheduled the reader.
Lockstep is architecture.rst section 10 – the designed synchronous mode, of which this plugin
is the first real user. It registers a consumer gate (ctx.register_gate), pending until the
expected input arrives via ctx.post, exactly as that section specifies. The engine does not yet
wait on gates (“today register_gate/gates exist and are reset each reset(), but nothing
waits on them”), so the wait itself is implemented here, in post_step, against that gate –
with the timeout and the deadlock diagnostic section 10 requires, naming the gate that never fired.
It is not a second concurrency scheme: the command queue is still the substrate, and this only adds
the wait. Blocking inside ``post_step`` is listed as an anti-pattern “except deliberately in sync
mode” – this is that exception, taken deliberately, and it is the reason the block is confined to
one clearly named place. When the engine grows its own gate wait, this method becomes a
gate.satisfy and nothing else changes.
Lockstep arms only after PX4’s first answer, and that is not a convenience. PX4 SITL runs on a
lockstep_scheduler whose clock is set from the timestamps in the HIL_SENSOR messages we
send: nothing in PX4 makes progress until sim time advances, including its own startup script and
therefore every module that could ever publish actuator_outputs – the topic
HIL_ACTUATOR_CONTROLS is emitted from. Blocking on the very first batch is consequently a true
deadlock, not a slow start: the bridge waits for controls that require a boot that requires the
sensor stream the bridge has just stopped. Verified against PX4 v1.18.0-beta2, which sits at
ERROR [simulator_mavlink] poll timeout 0, 25 forever. PX4’s own gazebo-classic bridge carries
exactly this gate (received_first_actuator_). So the first ticks free-run; from the first
HIL_ACTUATOR_CONTROLS onward every tick is locked, and the gate stays armed across a reset
because PX4 did not reboot.
What is locked to what: on each tick that carries a sensor batch, the plugin sends HIL_SENSOR
(plus HIL_GPS at the receiver’s own rate) and then blocks until PX4’s HIL_ACTUATOR_CONTROLS
for that batch has been received and queued; PX4 in turn blocks until the next HIL_SENSOR.
Neither side runs ahead, so the number of physics steps between two actuator updates is fixed by
construction. With ``lockstep: false`` the run is NOT reproducible: how many steps elapse
between two actuator updates then depends on host load and OS scheduling, so two runs of the
identical world with the identical seed diverge, and repetitions stop being samples of anything.
Non-lockstep exists only for interactive flying, where a stall in one process should not freeze the
other. (The sensor noise itself is reproducible either way – it draws from ctx.rng_for, keyed
on (seed, episode, sim_time) per section 9, like every other stochastic plugin here. It is the
coupling that lockstep makes deterministic.)
MAVLink encoding is pymavlink’s. Imported lazily in configure() so that loading a world
containing this plugin (to render it, to export it) does not require the dependency, and so the
error names the package. Hand-rolling a serialiser for a dialect that is generated from XML and
versioned upstream would be a maintenance liability disguised as a saved dependency.
quadrotor_controller
Controller plugin: quadrotor position + attitude control over collective thrust and body moments.
Config – a component of the entry that spawns the drone, since ownership is where the entry sits rather than a config key:
quadrotor_controller:
namespace: "" # transport scope (default: inherited from spawn_robot)
body: cf2 # the drone's root body (default: the entity's root)
thrust_actuator: body_thrust # collective thrust, in newtons
moment_actuators: [x_moment, y_moment, z_moment]
target: [0.0, 0.0, 1.0] # position setpoint (x, y, z), world frame
yaw: 0.0 # heading setpoint (rad)
max_tilt: 0.5 # rad, cap on commanded tilt from vertical
max_accel: 4.0 # m/s^2, cap on the commanded horizontal acceleration
max_vel: 1.5 # m/s, cap on the velocity a position error may ask for
kp_pos: [3.0, 3.0, 12.0] # position gains (x, y, z)
kd_pos: [2.4, 2.4, 6.0] # velocity gains
kp_att: [0.0096, 0.0096, 0.0038] # attitude gains (roll, pitch, yaw), N*m per unit error
kd_att: [0.00086, 0.00086, 0.00051] # body-rate damping, N*m per rad/s
The moment actuators carry a negative gear, so a positive ctrl produces a negative body
moment. The sign is read from the model at configure time rather than hardcoded – it is upstream’s
convention, and a future airframe need not share it.
The attitude gains are in newton-metres, not normalised units, because the controller emits a
moment and the model’s actuator gear converts it to ctrl. They are sized from the airframe: for a
body inertia I and a target attitude bandwidth wn with damping zeta, kp_att ~ I*wn^2 and
kd_att ~ 2*zeta*I*wn. The defaults are I = 2.4e-5 kg*m^2 at wn = 20 rad/s, zeta = 0.9. This is
also why the Crazyflie’s moment gear is tuned rather than upstream’s: at the arbitrary 1e-5 N*m
upstream ships, full deflection buys 0.42 rad/s^2 and no attitude loop can track a position
controller’s tilt command – the drone hovers perfectly and flies away the moment it is asked to
translate. See the port log.
Air matters. density/viscosity default to 0 in MuJoCo, so a world that does not set
the world’s density/viscosity flies the drone through a vacuum – no drag, and a lateral step never settles.
The plugin logs a warning rather than silently flying in vacuum.
wind_field
Flags: parallel_safe
World plugin: time-varying wind – steady flow, a discrete gust, and Dryden turbulence.
Config:
wind_field:
steady: [2.0, 0.0, 0.0] # m/s, world frame -- the mean flow
gust: # optional 1-cosine discrete gust (MIL-F-8785C shape)
magnitude: 4.0 # m/s at the peak
axis: [1.0, 0.0, 0.0] # direction (normalised; defaults to `steady`, else +x)
onset: 5.0 # s, when it starts
duration: 1.0 # s, rise-and-fall length
turbulence: # optional continuous Dryden turbulence
intensity: 0.6 # sigma, m/s (per horizontal axis)
length_scale: 5.0 # L, m -- larger = slower, more correlated gusting
vertical: 0.5 # sigma scale on the w axis (Dryden's low-altitude form)
Wind is a signal, not a value. model_override refuses every opt.* global at runtime,
because a fault-injection write would make the value a run recorded differ from the value that ran.
The wind here is fully determined by the parameters above plus the run’s seed, both recorded with
the world: re-run the same world with the same seed and the same wind happens, tick for tick.
One owner per knob: declaring both sim.wind and this plugin is refused rather than merged,
because the compiled model would say one thing, the first tick another, and the run’s provenance
would record the value that was immediately overwritten. State the mean flow in steady:.
Randomness comes from ``ctx.rng_for``, like every other stochastic plugin, so turbulence is a
pure function of (sim.seed, episode, sim_time) and this plugin has no seed key of its own.
Because episode is part of the key, repetitions of one configuration see different turbulence:
repetitions are samples of the weather, not copies of it, which is what makes averaging over them
mean anything.
Wind is inert in a vacuum. MuJoCo applies wind as a relative velocity into the density and viscosity drag terms, so with both at 0 this plugin has no effect whatever – a silent, plausible-looking failure, so it warns.
roqsim_assets
box
Scene plugin: a parametric rectangular box obstacle placed from the world YAML.
Config:
box:
prefix: "" # MJCF name prefix (use distinct prefixes for >1 box)
pose: # world placement (REQUIRED), as SpawnEntity states one; omit z to
position: {x: 0.0, y: 0.0} # sit the box ON the floor, give z to place its CENTRE
orientation: {yaw: 0.0} # a full rotation, so a box may be tipped onto an edge
size: [0.4, 0.4, 0.8] # full extents (not half-extents), metres (REQUIRED)
color: [r,g,b,a] # default a light warehouse grey; alpha optional
collide: true # false -> visual only (raycast still sees it; nothing bumps into it)
friction: 1.0 # sliding friction, or the full [sliding, torsional, rolling] triple
motion: physics # who owns the pose: physics (default; movable and TELEPORTABLE),
# static (welded scenery), driven (a plugin writes it)
motion: driven # a plugin writes the pose: collidable, immovable, and NOT in a
# navigator's planner grid
size is deliberately full extents, not MuJoCo half-extents: a world file describes a 0.4 m
box, and halving it in your head is exactly the kind of silent factor-of-two a scene should not ask
of its author.
By default the box is welded scenery – static, with no free joint – like every other plugin in this
package. motion: physics gives it a free joint, which buys two things: physics can move it, and
simulation_interfaces’ SetEntityState can teleport it (that service rejects any entity
without a free base_joint).
Teleporting is how an obstacle appears mid-trial. roqsim never recompiles the model at runtime, so
there is no spawning: a box that must show up on cue is compiled in at build time, kept out of the
way, and moved into place by the scenario when the moment comes. Between episodes on_reset puts
it back at its declared pose, so a trial never inherits the previous trial’s obstacle position.
A teleport states the box’s centre, and no part of it may be inside the floor. The z this file’s
pose: lets you omit is a convenience of the world, not of the service: SetEntityState states
every field, so a scenario placing a floor-standing box asks for half its height and gets what it
asks for. A free box seated inside the floor is not parked – the solver answers the penetration by
launching it metres upward before it settles, in view of the run. Keep it out of the way by making
it ABSENT (DeleteEntity, which leaves its pose alone) or by moving it sideways, never downward.
motion: driven is the third state, and it is what makes this plugin’s opening paragraph achievable
without a scenario at all. A mocap box has no degrees of freedom, so nothing can push it, and it is
excluded from a navigator’s planner grid by the same rule that excludes walkers and driven props –
that grid holds only what cannot move. So a mover plans straight through it and has to discover it
with its forward probe, which is exactly the “obstacle the robot is not supposed to know about” this
plugin exists for. Welded scenery cannot do that job: it lands in the grid and gets routed around.
boxes
Scene plugin: many parametric boxes from one config value.
Config:
boxes:
instances: [] # list of box configs, each accepting every key `box` does (required)
ceiling_panels
Scene plugin: a parametric field of flat acoustic ceiling panels under a soffit.
Config:
ceiling_panels:
prefix: "" # MJCF name prefix (distinct prefixes for >1 field)
area: [x0, y0, x1, y1] # world rectangle to cover (REQUIRED)
z: 3.5 # soffit height, m -- panels hang below this
drop: 0.04 # gap between soffit and panel top, m
panel: [1.8, 0.6] # panel size [x, y] before `yaw`, m
pitch: [2.6, 1.6] # grid spacing [x, y], m (>= panel, or the panels would overlap)
thickness: 0.04 # panel thickness, m
yaw: 0.0 # rotation of the whole field about its centre, rad
color: [r,g,b,a] # panel colour (default near-white); alpha optional
emission: 0.35 # 0..1 self-illumination -- a panel faces away from every lamp in the room
# (they hang at the same height), so without it white panels render as
# dark grey slabs. This is what makes them read as the white they are.
Panels are visual only (no contacts): they are out of reach of anything driving on the floor, and a
convex collider per panel would be pure cost. The lidar raycaster still sees them – mj_multiRay
ignores contype – so an upward-looking sensor reads the panelled ceiling, as it should.
conveyor
Scene + controller plugin: a velocity-driven belt conveyor.
Config:
conveyor:
namespace: "" # optional transport scope for the speed endpoint (/<ns>/speed)
prefix: "" # MJCF name prefix (distinct prefixes for >1 belt)
model: conveyor # bundled model name / path
pos: [0.0, 0.0, 0.0] # world placement of the belt model
rpy: [0.0, 0.0, 0.0] # belt orientation as roll/pitch/yaw (rad)
length: 2.442 # optional full belt length (X, m); default keeps the base model
width: 0.58 # optional full belt width (Y, m); default keeps the base model
speed: 0.1 # initial belt speed (m/s); negative reverses
friction: 0.6 # optional belt<->package sliding friction override
roller_radius: 0.0275
belt_wrap: 0.025 # +/- position wrap (m); keep <= half the slab overhang
package_pose: [1.0, 0.6, 0.996, 1, 0, 0, 0] # free-body reset pose (x y z qw qx qy qz),
# in the BELT's frame -- pos/rpy are applied to it
cylinder
Scene plugin: a parametric cylindrical obstacle placed from the world YAML.
Config:
cylinder:
prefix: "" # MJCF name prefix (use distinct prefixes for >1 cylinder)
pose: # world placement (REQUIRED), as SpawnEntity states one; omit z to
position: {x: 0.0, y: 0.0} # stand it ON the floor, give z to place its CENTRE
# [x, y, z] places its CENTRE at z (REQUIRED)
radius: 0.075 # metres (REQUIRED)
height: 0.5 # full height, metres (REQUIRED)
color: [r,g,b,a] # default a light warehouse grey; alpha optional
collide: true # false -> visual only (raycast still sees it; nothing bumps into it)
friction: 1.0 # sliding friction, or the full [sliding, torsional, rolling] triple
motion: physics # who owns the pose: physics (default; movable and TELEPORTABLE),
# static (welded scenery), driven (a plugin writes it)
mass: null # total mass, kg. Unset -> MuJoCo's default density (1000 kg/m^3), which
# for a hollow container is several times too heavy
height is the full height and radius is a true radius, matching how a world file talks
about a post. MuJoCo’s cylinder geom wants [radius, half-height]; that conversion happens here so
it never has to happen in your head – the same reason box takes full extents.
mass sets the geom’s total mass and lets MuJoCo derive the inertia from the shape, so it stays a
solid cylinder’s inertia at the stated mass. It is a separate key from the geometry because the two
are independent for anything the size of a drinks can: the diameter is set by what the fingers can
span, the mass by what the object is made of and whether it is full.
cylinders
A population of CylinderPlugin instances.
door
Scene + controller plugin: a hinged (swing) door with a ROS-controllable opening angle.
Config:
door:
prefix: door_1_ # MJCF name prefix (distinct per door)
pos: [x, y, 0] # opening CENTRE, [x, y] or [x, y, z] world placement
rpy: [0, 0, yaw] # orientation; yaw aligns the closed leaf along its wall
width: 0.9 # opening / leaf width (m)
height: 2.0 # leaf height (m)
thickness: 0.04 # leaf thickness (m); box leaf only
leaf: true # false -> a cased opening: the casing is welded, no leaf is hung
model: door # optional leaf mesh model (door | door_glass | pkg:name); omit -> box
color: [r, g, b, a] # repaint the leaf (omit -> the model's own colours); alpha optional
frame: true # weld a static jamb/lintel casing around the opening (frame_model)
frame_model: door_frame # the static frame model to weld (visual-only)
frame_color: [r,g,b,a] # repaint the casing (default: follow `color`)
mount_offset: 0.06 # hang the leaf this far proud of the wall (m), on the swing side, so it
# clears the reveal and can open past 90 deg
hinge_side: left # 'left' | 'right' -- which edge of the opening is fixed (the hinge)
swing: 1 # +1 / -1 -- which way the leaf opens about +Z
max_angle: 120 # fully-open hinge angle (deg); a leaf can swing well past 90 in free
# space -- raise/lower per door for what its surroundings allow
open: 0.0 # initial openness fraction 0..1 ('how open'); held until commanded
controllable: true # expose the ROS endpoints/action; false = passive, held at 'open'
namespace: "" # transport scope -> /<ns>/cmd , /<ns>/state , /<ns>/door
kp: 40.0 # position-actuator stiffness
kv: 8.0 # position-actuator damping (velocity gain)
The leaf-mesh convention (model:) mirrors the rest of the asset library but with one addition: a
door model’s origin is its hinge (fixed) vertical edge at floor level, the leaf extending along
+X and up +Z, so the plugin can hang it on the hinge with no per-model offset. A box leaf (no
model) is built to the same convention procedurally.
Frame. Unless frame: false, a static jamb/lintel casing (the frame_model, default
door_frame) is welded around the opening while the leaf hinges inside it. The frame is centred on
the opening and rescaled to width/height like the leaf; it is visual-only (the wall already collides),
so it never narrows the doorway. A missing frame model is a warning, not an error (bare opening).
Leafless openings. leaf: false welds the casing but hangs no leaf – a cased opening (German
Türblatt-less door): no hinge joint, no actuator, no ROS surface, nothing to command. Use it where a
wall opening should read as a doorway rather than a hole, while staying a door in the floorplan so the
room loops it belongs to are unchanged. It is still registered as a door entity (on the casing body),
so anything enumerating the building’s doors still finds it. leaf: false with frame: false would
place nothing at all and is refused.
Colour. color repaints the leaf and (unless frame_color overrides it) the casing, so a world can
match its doors to the rest of its trim without a recoloured copy of the model. Only the leaf’s
colliding geometry is repainted: in this library a leaf’s decoration is non-colliding by convention
(the chrome handle, contype/conaffinity 0), so it keeps its own finish instead of turning into
the door colour. That also means a glazed leaf should be left alone – painting door_glass’s pane
opaque would defeat it – and only its frame_color set.
duct
Scene plugin: a parametric round ventilation duct run under a ceiling.
Config:
duct:
prefix: "" # MJCF name prefix (distinct prefixes for >1 run)
start: [x, y] # run start, world (REQUIRED)
end: [x, y] # run end, world (REQUIRED)
z: 3.2 # height of the run's AXIS, m
radius: 0.14 # main tube radius, m
branches: [] # distances along the run, m, where a drop + diffuser hangs
branch_length: 0.40 # how far a drop hangs below the main tube, m
branch_radius: 0.08 # drop tube radius, m
diffuser_radius: 0.16 # disc at the bottom of a drop, m (0 = no disc)
color: [r,g,b,a] # galvanised steel by default; alpha optional
Visual only (no contacts), for the same reason the panels are: nothing driving on the floor reaches a duct, and the raycaster sees it regardless.
moving_box
Scene plugin: a moving rectangular box obstacle, driven kinematically from the world YAML.
Config:
moving_box:
prefix: "cube1_" # MJCF name prefix (use distinct prefixes for >1 mover)
size: [0.3, 0.3, 0.3] # full extents, metres (REQUIRED)
pose: # start; omit z to sit it ON the floor, state z to set its CENTRE
position: {x: 0.0, y: 0.0}
orientation: {yaw: 0.0} # constant -- the box travels without turning
speed: 0.1 # m/s along the route (REQUIRED, > 0)
color: [r, g, b, a] # default a light warehouse grey; alpha optional
collide: true # false -> nothing bumps into it (a raycast still sees it)
friction: 1.0 # sliding friction, or the full [sliding, torsional, rolling] triple
# -- mode A: a fixed route -----------------------------------------------------------------
waypoints: [[2.0, 1.0], [2.0, -3.0]] # world metres; the box starts at `pose`
loop: true # true -> cycle the route forever; false -> stop at the last point
ping_pong: false # true -> reverse at the end instead of jumping back to the start
# -- mode B: a seeded random walk -----------------------------------------------------------
random_walk:
seed: 1 # REQUIRED — an unseeded random obstacle is not an experiment
clearance: 0.5 # m of free space required ahead; below it, a new heading is picked
bounds: [x0, y0, x1, y1] # optional axis-aligned box the centre must stay inside
turn_deg: [60, 300] # heading change sampled uniformly from this range, in degrees
pose is where the box is at on_reset, every episode: a trial never inherits the previous
trial’s obstacle position, and neither does the RNG (it is re-seeded), so repetition N of a cell sees
the same obstacle motion however many trials ran before it.
palm_tree
Scene plugin: a parametric artificial palm-like tree – trunk, arching fronds, fruit bunches.
Config:
palm_tree:
prefix: "" # MJCF name prefix (distinct prefixes for >1 tree)
pos: [0.0, 0.0, 0.0] # [x, y] or [x, y, z] world placement of the trunk base
rpy: [0.0, 0.0, 0.0] # orientation as roll/pitch/yaw (rad)
trunk_height: 1.60 # m, top of the pole above the floor
trunk_radius: 0.030 # m
pot_height: 0.16 # m (0 for a pole with no pot)
pot_radius: 0.085 # m
crown_height: 0.95 # m, where the fronds spring from the trunk
fronds: 8 # blades in the crown
frond_length: 0.45 # m, tip to trunk (split over the two segments)
frond_width: 0.10 # m
frond_pitch_deg: 35 # inner segment's rise above horizontal
frond_droop_deg: 45 # extra downward turn of the outer segment
frond_tier: 0.10 # m, height offset between alternating fronds
bunches: # fruit bunches; pos is relative to the trunk base
- pos: [-0.13, 0.02, 0.88]
radius: 0.075
fruits: 9
The trunk, the fronds and the fruit all collide – every one of them is something an arm can hit, and the fronds in particular are the obstacle the experiment is about. The pot collides too (it is a solid object at floor level). Nothing here is decoration, so nothing here is contact-free.
prop_trajectory
Scene + controller plugin: carry a prop along a prescribed planar path at a fixed speed.
Config:
prop_trajectory:
prefix: "" # MJCF name prefix (distinct prefixes for >1 stage)
path: trajectories/t1.csv # 2-column "x,y" CSV, resolved relative to the world YAML
units: mm # mm | m -- the CSV's units (default mm)
speed: 0.03 # m/s along the path (arc length), constant
origin: [2.0, 1.0, 0.6] # world pose of CSV point (0,0); z is the plate's TOP surface
plate: [0.07, 0.07, 0.006] # carrier plate half-extents (x, y, z)
friction: 1.0 # plate friction (a carried object must not slide off)
loop: false # restart at the path start when the end is reached
start_index: 0 # begin at this CSV row (phase offset within one path)
travel: [0.15, 0.15] # +/- soft limits on each axis (m); the path is clamped into them
The plate is driven, not simulated: its joints have no actuator and are velocity-forced, so the stage is infinitely stiff and its motion is exactly the commanded path regardless of the load it carries. That is the right model for a stepper-driven gantry and the wrong one for a compliant conveyor.
Props carried by the plate need their own free joint – spawn them with
spawn_model: {..., motion: physics} and place them just above the plate’s top surface.
shelf
Scene plugin: a parametric chipboard shelf built from primitive boxes at build time.
Config:
shelf:
prefix: "" # MJCF name prefix (distinct prefixes for >1 shelf)
pos: [0.0, 0.0, 0.0] # [x, y] or [x, y, z] world placement
rpy: [0.0, 0.0, 0.0] # orientation as roll/pitch/yaw (rad)
layers: 5 # number of boards (int >= 2; default 5, matching the mesh model)
width: 1.51 # board size along Y, m (default 1.51)
depth: 0.80 # board size along X, m (default 0.80; half-depth => 0.40)
height: 2.00 # overall shelf height, m (default 2.00)
thickness: 0.02 # board thickness, m (default 0.02)
leg: 0.04 # square cross-section of a corner upright, m (default 0.04)
Defaults reproduce the baked free_chipboard_shelf (5 boards, ~0.80 x 1.51 x 2.0 m) so it is a
drop-in, parameterised replacement.
strip_light
Scene plugin: a parametric linear ceiling luminaire – the LED batten, optionally lit.
Config:
strip_light:
prefix: "" # MJCF name prefix (distinct prefixes for >1 batten)
pos: [x, y, z] # fixture centre, world (z = the underside of the ceiling it hangs on)
yaw: 0.0 # direction of the run, rad
length: 2.4 # along yaw, m
width: 0.09 # across it, m
height: 0.06 # how deep the fixture hangs, m
color: [r,g,b,a] # fixture colour (default white)
emission: 0.6 # 0..1 self-illumination, so the batten reads as lit, not just white
emit: false # also add a real light below the fixture
diffuse: [0.25, 0.25, 0.25] # its colour/intensity (only with emit)
cutoff: 80.0 # its spot half-angle in degrees (only with emit)
Keep emit for the few battens that should actually light the scene: MuJoCo caps a model at 100
lights, and every extra one costs render time for a room that is already lit.
window
Scene plugin: a parametric fixed window – a glazed pane in a slim frame, built from boxes.
Config:
window:
prefix: "" # MJCF name prefix (distinct prefixes for >1 window)
pos: [0.0, 0.0, 0.0] # opening CENTRE, [x, y] or [x, y, z] world placement
rpy: [0.0, 0.0, 0.0] # orientation; yaw aligns the pane with its wall
width: 0.94 # opening width along the wall, m (default 0.94)
height: 2.06 # overall height from the floor, m (default 2.06 -- door_frame's outer height)
depth: 0.10 # thickness through the wall, m (default 0.10 -- the default wall thickness)
frame: 0.05 # frame border width, m (default 0.05); glass fills the rest
glass: 0.012 # glass thickness, m (default 0.012)
color: [r,g,b,a] # frame colour (default a neutral grey); alpha optional
glass_color: [r,g,b,a] # glass colour, alpha < 1 to see through it
Both parts collide: the wall around the opening stops a robot, and the glass must too, or a robot would drive through the window.
workbench
Scene plugin: a parametric ESD assembly workbench with an electric lift column.
Config:
workbench:
prefix: "" # MJCF name prefix (distinct prefixes for >1 bench)
pos: [0.0, 0.0, 0.0] # [x, y] or [x, y, z] world placement
rpy: [0.0, 0.0, 0.0] # orientation as roll/pitch/yaw (rad)
height: 0.695 # worktop height, m -- the lift column's stroke, 0.695 .. 0.995
width: 2.00 # worktop size along X, m (default 2.00)
depth: 0.70 # worktop size along Y, m (default 0.70)
cabinet: left # drawer cabinet side: 'left' | 'right' | 'none'
superstructure: true # uprights + tool panel + monitor arm + overhead light frame
The structure collides – worktop, frame, columns, cabinet, uprights, tool panel and the overhead beams are all things a robot or an arm can hit. The trim does not: adjustable feet pads, drawer fronts and handles, the power strip, the monitor arm and the light bar are visual, so a bench in a navigation scene does not cost contacts for decoration.
roqsim_humanoid
agibot_g2_controller
Controller plugin: whole-upper-body joint-position hold for the AgiBot G2.
Config – a component of the entry that spawns the robot, since ownership is where the entry sits rather than a config key:
agibot_g2_controller:
namespace: "" # transport scope (default: inherited from spawn_robot)
rest: {idx21_arm_l_joint1: 1.6, ...} # {joint: angle} spawn+hold stance (default: manifest)
test_target: {idx31_gripper_l_inner_joint1: -0.6} # optional {joint: pos} held every tick
g1_locomotion
Controller plugin: RL walking policy + PD torque loop for the Unitree G1 (12-DoF legs).
Config – a component of the entry that spawns the robot, since ownership is where the entry sits rather than a config key:
g1_locomotion:
namespace: "" # transport scope (default: inherited from spawn_robot's namespace)
policy: g1_stand # use a spec-driven policy instead of the bundled walk one: names a
# directory under policy/ holding <name>.spec.yaml + its checkpoint
# (roqsim.policy.PolicySpec). The spec carries the observation layout,
# gains and trained envelope, so adding a policy edits no Python.
# Omitted -> the bundled walk policy, unchanged.
policy_path: <bundled> # override the TorchScript policy (default: policy/motion.pt)
config_path: <bundled> # override the deploy config (default: policy/g1.yaml)
gait_period: 0.8 # gait-phase period (s) feeding the sin/cos phase obs
max_linear_vel: 1.0 # |vx| clamp (m/s)
max_lateral_vel: 0.5 # |vy| clamp (m/s)
max_angular_vel: 1.0 # |yaw_rate| clamp (rad/s)
test_cmd: [0.4, 0.0, 0.0] # optional [vx, vy, w] applied every tick (standalone demo)
station_keeping: true # hold position when commanded to stop (see below)
station_gain: [2.5, 2.5, 2.0] # P gains on [x, y, yaw] error -> body-frame twist
station_deadband: [0.02, 0.05] # [m, rad] inside which no correction is applied
station_keeping closes a position loop so a zero command means stay here rather than walk at
zero velocity. It is off by default (the policy’s raw behaviour is what a locomotion study wants to
measure), but anything standing still needs it: the policy takes only a velocity command and has no
notion of where it is, so a stationary G1 drifts at roughly 0.06–0.09 m/s – 0.6–0.9 m over ten
seconds, measured on both this model and unitree_g1. That is enough to walk a robot away from the
table it was reaching for, and it also means a navigating robot creeps away after arriving at its goal.
The hold target arms itself whenever the external command returns to (near) zero, capturing the pose
the robot is standing at, and releases the moment a non-zero command arrives – so nav2 drives normally
and only the standstill is corrected. The correction is a P term on the world-frame error rotated into
the body frame, clamped by the same max_* limits as any other command, with a deadband so the robot
is not permanently taking small steps to chase millimetres.
Measured on unitree_g1_dex1, 10 s at rest – settled offset from the armed pose, and how far the
base still wanders in a subsequent 2 s (the number a manipulation task actually cares about):
station_gain |
settled offset |
further motion / 2 s |
|---|---|---|
off |
0.905 m, rising |
– |
[1.0, 1.0, 1.5] |
0.116 m |
0.0045 m |
[2.5, 2.5, 2.0] |
0.046 m |
0.0049 m |
[5.0, 5.0, 3.0] |
0.026 m |
0.0031 m |
Residual wander is ~5 mm regardless, so the gain only trades settled offset against how hard the policy is pushed; the default is the middle row. A P term leaves a steady-state offset because the policy needs a finite velocity command to step at all – plan in the robot’s own base frame (as MoveIt should here anyway) and a constant offset costs nothing.
This is what the hardware does too: the real G1’s sport mode has a stand state that holds pose rather than integrating a zero velocity.
oli_locomotion
Controller plugin: ONNX whole-body walk policy + PD torque loop for the LimX Oli (HU_D04_01).
Config – a component of the entry that spawns the robot, since ownership is where the entry sits rather than a config key:
oli_locomotion:
namespace: "" # transport scope (default: inherited from spawn_robot)
policy_path: <bundled> # override the ONNX policy (default: policy/oli/policy.onnx)
config_path: <bundled> # override the deploy config (default: policy/oli/walk_param.yaml)
max_linear_vel: 0.5 # |vx| clamp (m/s) -- vendor max_vx
max_lateral_vel: 0.3 # |vy| clamp (m/s) -- vendor max_vy
max_angular_vel: 0.5 # |yaw_rate| clamp (rad/s) -- vendor max_vz
test_cmd: [0.3, 0.0, 0.0] # optional [vx, vy, w] applied every tick (standalone demo)
roqsim_manipulation
arm_controller
Controller plugin: joint-position hold for a manipulator + joint-state publishing.
Config – a component of the entry that spawns the arm, since ownership is where the entry sits rather than a config key:
arm_controller:
joints: [shoulder_pan_joint, ...] # optional: the joints this controller owns. Omitted, the
# plugin claims every joint actuator sharing the entity's prefix,
# which is right for a standalone arm and wrong for an arm that
# shares its entity with other actuated parts -- a humanoid's legs,
# a mobile manipulator's wheels. There the scan claims those too
# and this plugin then fights their owner, writing position targets
# into what may be torque actuators. Naming the joints also scopes
# `joint_states` to this arm, so several controllers can share one
# topic without each restating the others' joints.
gripper_actuator: left_gripper # required WITH `joints:` for a gripper, and the ONLY way to
# declare one that is a plain joint actuator (the X-Series arms
# drive their jaws from a `left_finger` slide, which the scan
# below would otherwise claim as a seventh arm joint). Resolved by
# ACTUATOR NAME, so it need not be a tendon -- and a tendon one is
# not inferable anyway once an entity carries two (left/right).
joint_prefix: "" # prepended to every joint name this controller REPORTS in
# `joint_states` and accepts in a trajectory. Empty (the default)
# reports the model's own names. Set it -- conventionally to the
# arm's MJCF prefix -- when two arms must appear in ONE robot
# description: a URDF is a flat namespace, so two `shoulder_pan_joint`
# cannot coexist there, and MoveIt matches states and trajectory
# points to the description by name.
namespace: ur10e # transport scope (default: inherited from spawn_arm's namespace)
topics: {joint_states: /joint_states} # optional: hardwire the joint_states topic to an
# absolute name, overriding namespace (see Plugin.topic_override)
controller_name: arm_controller # action at <controller_name>/follow_joint_trajectory
goal_tolerance: 0.5 # rad the joints may end from the trajectory's last waypoint before
# the action reports GOAL_TOLERANCE_VIOLATED instead of success.
# A scalar applies to every joint; {joint: rad} sets them apart;
# 0 disables the check. Loose on purpose -- it exists to catch an
# arm that never arrived (blocked, saturated, planned through the
# furniture), not to grade a servo's steady-state error.
goal_time_tolerance: 1.0 # s the joints get, after the last waypoint, to reach that
stream_commands: false # also expose <controller_name>/joint_trajectory as a high-rate topic
# input (mirrors ros2_control's JointTrajectoryController): the path
# moveit_servo streams position targets to. Off by default.
velocity_commands: false # also accept JOINT VELOCITIES at <controller_name>/joint_velocity
# (see "Velocity commands" below). Off by default.
velocity_timeout_s: 0.5 # watchdog: a velocity command decays to zero if not refreshed within
# this window, so a dropped stream cannot leave the arm drifting.
gripper_ctrl: 255.0 # ctrl held on any non-joint (tendon) actuator, e.g. the gripper
rest: {joint1: 0.0, ...} # {joint: angle} spawn+hold stance. Seeds BOTH the reset qpos (so the
# arm spawns in the pose) and the held target (so it stays there).
# Needed whenever the arm is carried by `spawn_robot`, which sets
# only the base pose and no joint stance -- see below.
test_target: [...] # optional joint vector held every tick (standalone demo)
To pose the arm by hand with the viewer’s control sliders instead, run roqsim --manual-control
(a run-level switch; see roqsim.context.SimContext.manual_control).
Velocity commands (velocity_commands: true). Reactive whole-body controllers – resolved-rate
or QP redundancy resolution, e.g. Haviland et al.’s holistic mobile manipulation – emit joint
velocities, not positions. This plugin’s actuators are
position servos, so a velocity command is integrated into the held target at the physics rate:
target += qd * dt, clamped to each joint’s range. That is what a real velocity-mode driver does on
top of a position-controlled joint, and it keeps the servo’s gravity-compensated hold – a MuJoCo
<velocity> actuator would sag under gravity whenever the command is zero.
Two consequences worth knowing before using it for a metric:
The achieved profile is shaped by the servo, not only by the command. Integrating and then tracking with a stiff PD adds the actuator’s own dynamics, so end-effector acceleration is not purely the controller’s. Where acceleration is the measured quantity, verify tracking error and report the servo gains as part of the setup.
A stream that stops must stop the arm.
velocity_timeout_szeroes a stale command; without a watchdog an interrupted stream integrates the last velocity forever.
ArmHandle.set_velocities(names, velocities) is the in-process entry point; the transport endpoint is
<controller_name>/joint_velocity.
The ``rest`` stance. spawn_arm supplies a per-model home that this plugin seeds its targets from. spawn_robot
does not: a robot spawn sets the base pose only, so an arm carried by a mobile base falls back to
qpos0 – all joints zero. For the Panda that is not a neutral default but an actively bad pose (its
link5 and hand collision geoms overlap by 0.030 m there), so a mobile manipulator must declare
rest in its manifest. It seeds the reset qpos and the held target, by joint name, which is
attach-safe where a model <keyframe> is not (spawn_robot strips keyframes – they cannot merge
into a composed world). Mirrors agibot_g2_controller’s rest.
If the arm has a non-joint (tendon) actuator – a parallel gripper – it also becomes commandable: the
plugin declares a control_msgs/GripperCommand action endpoint at
<gripper_controller_name>/gripper_cmd and publishes a () -> (position, velocity) reader on the
blackboard under gripper:<arm> (the bridge’s GripperCommand handler watches it to report
reached/stalled). The commanded position (the gripper joint angle, e.g. 0=open .. 0.8=closed for a
Robotiq 2F-85) is mapped linearly onto the tendon actuator’s ctrlrange.
Grip force. Beside the position the plugin publishes a GripperEffort under
gripper_effort:<arm>, the key the endpoint’s effort_key hint names. It takes GripperCommand’s
max_effort as ros2_control’s gripper action controller does: a clamp on the gripper joint’s
effort, in that joint’s own unit – newtons for a slide jaw, newton-metres for a knuckle, the unit
/joint_states reports – which is what that controller’s effort adapter applies
(gripper_controllers/hardware_interface_adapter.hpp). A goal is never refused for its effort: a
request at or above the model’s own limit saturates there, as a drive does, and max_effort <= 0
and every reset restore the model’s own force range. The clamp reaches the actuator through the
transmission, so it needs a constant moment – a joint transmission or a fixed tendon, which every
shipped gripper has. Any other keeps its range and executes the position alone, as a
position-interface controller does. Gripper config:
gripper_controller_name: gripper_controller # action at <name>/gripper_cmd
gripper_joint: right_driver_joint # joint whose angle is the reported gripper position
gripper_open: 0.0 # position value that maps to the open end of the actuator ctrlrange
gripper_close: 0.8 # position value that maps to the closed end
cartesian_admittance
Controller plugin: Cartesian end-effector control for an arm, with or without force feedback.
Config – a component of the entry that spawns the arm, whose ArmHandle it drives, since
ownership is where the entry sits rather than a config key:
cartesian_admittance:
controller_type: cartesian_compliance_controller # which of the three above; see `law`
controller_name: "" # ROS name its topics sit under; defaults to controller_type
initial_state: active # active | inactive -- `inactive` is ros2_control's `spawner --inactive`
site: tool_site # site whose pose is controlled (prefixed with the arm's prefix)
ft: ft # blackboard key suffix of the force_torque sensor (`ft:<key>`);
# required by the force and compliance types, unused by motion
rate_hz: 100.0 # control rate; the loop runs at this, not at the physics rate
target_wrench: [0, 0, -10, 0, 0, 0] # w_d, what the TOOL applies, so -10 on z presses DOWN
mass: [1, 1, 1, 0.6, 0.6, 0.6] # M, diagonal
damping: [80, 80, 80, 160, 160, 160] # D, diagonal
stiffness: [0, 0, 0, 0, 0, 0] # C, diagonal; a zero axis is pure force control
axes: [1, 1, 1, 1, 1, 1] # per-axis enable mask
kp: [2, 2, 2, 2, 2, 2] # motion type only: proportional gain on the pose error
max_linear_vel: 0.1 # m/s, clamp on the commanded twist MAGNITUDE
max_angular_vel: 1.0 # rad/s
ik_damping: 0.01 # damped-least-squares lambda
law: admittance | position is the older spelling and still works, deriving a controller_type:
position is the motion controller, and admittance is the force controller, or the compliance
controller where a non-zero stiffness is configured. Prefer controller_type – a controller
that changes its law on command is not something any real robot offers.
Endpoints, named as FZI’s cartesian_controllers name them, so a node written against this runs
unchanged against that stack: <controller>/target_wrench (in, geometry_msgs/WrenchStamped),
<controller>/target_frame (in, geometry_msgs/PoseStamped) and <controller>/current_pose
(out). A commanded value overrides its configured default; until one arrives the config stands, so a
world that publishes nothing behaves exactly as configured.
Also publishes a CartesianHandle on the blackboard under cartesian:<arm> for an in-process
task plugin, with the same reach as the endpoints.
Frames. The commanded twist is applied in the WORLD frame, and the measured wrench is used as
given. Configure force_torque’s frame: to match how the task defines its working axis; a
wrench reported in the sensor frame and integrated as if it were world-frame produces a controller
that drifts sideways under load and looks like a friction problem.
spawn_arm
Scene plugin: attach a manipulator MJCF into the world – to the ground, or onto a mobile base.
Config:
spawn_arm:
model: ur10e # bundled model name, filename, or absolute path
namespace: ur10e # optional transport scope; the arm's endpoints inherit it (default: "")
prefix: "ur10e_" # MJCF name prefix (use distinct prefixes for >1 arm)
base_body: base # arm root body (ur10e -> 'base', panda -> 'link0')
pos: [-0.41, 0.0, 0.76]
rpy: [0.0, 0.0, 3.14159] # mount orientation as roll/pitch/yaw (rad)
home: [...] # joint home pose (defaults per model); applied on reset
actuators: # OPTIONAL: what law this arm's joints run under, and their gains.
control: impedance # position | velocity | effort | impedance; the model's own if unset
stiffness: 2.0 # N*m/rad -- see roqsim.actuators for the gain of each control
damping: 0.02 # N*m*s/rad
each: # per-actuator, on top of the shared keys above
wrist_3: {control: position, p: 2000, d: 500}
gravity_compensation: # OPTIONAL: whether the arm's bodies carry their own weight. Default:
# true under position/velocity/impedance (real drives hold a pose),
# false under effort (supplying the term is the controller's job).
pedestal: false # add a static support box under the base (floor -> mount height); only
# has an effect when pos[2] > 0. Leave it off when the arm mounts on a
# table/desk that is already there (the usual case).
pedestal_half_width: 0.1 # pedestal: half-width (m) of that box's square footprint
rail: # OPTIONAL: carry the arm on a linear axis (gantry / ceiling track)
axis: [1, 0, 0] # travel direction, in the MOUNT frame (after `rpy`)
range: [-1.5, 1.5] # travel limits (m) about `pos`
home: 0.0 # carriage position at spawn/reset (m)
joint: rail_joint # MJCF joint name, under the arm's prefix (default: 'rail_joint')
kp: 20000 # position-servo gain of the carriage drive
damping: 200 # carriage joint damping
mount: # OPTIONAL: weld the arm onto another entity's body instead of the world
robot: robot # entity name of a spawn_robot in this world
body: base_link # that robot's body to weld to (default: base_link)
end_effector: # OPTIONAL: weld a gripper onto the arm's tool flange
model: robotiq_2f85 # a gripper model (robotiq_2f85, schunk_pg70)
site: attachment_site # arm site to weld it to (default: attachment_site)
prefix: "" # MJCF name prefix for the gripper's own names
pos: [0, 0, 0.011] # offset in the SITE's frame (e.g. the UR->Robotiq adapter's 11 mm)
rpy: [0, 0, 0] # extra rotation in the site's frame (rad)
replaces: [ee_plate] # bodies of the ARM model this tool supersedes, deleted before the
# attach (the ur10e ships a conveyor pushing plate 60 mm past its
# flange, which a gripper would be welded straight into)
home/base_body fall back to per-model defaults so a bare {model: ur10e} works.
Riding a linear axis (``rail:``)
A gantry, a ceiling track or a seventh-axis floor rail is a prismatic joint carrying the arm base,
which is the one thing mount: cannot express: mount welds the arm to a body that already
exists, while a rail has to introduce the moving body itself. With rail: the plugin inserts a
carriage between the mount frame and the arm, gives it a slide joint along axis and a position
servo, and attaches the arm to the carriage.
The point of it is kinematic redundancy: a 6-DOF arm on a rail is a 7-DOF system, so a task pose has a one-parameter family of solutions and a planner can trade base travel against arm posture. That is the class of system this option exists for, and the reason it belongs here rather than in a per-cell MJCF.
Two ordering facts that other code depends on, so they are guaranteed rather than incidental:
The rail joint is declared before the arm’s joints, and its actuator before the arm’s. Both
prefixed_jointsandprefixed_actuatorsreturn model order, soarm_controllerpublishes and commands[rail, <arm joints...>]– matching a URDF that puts the prismatic joint at the root of the chain, which is how MoveIt will see the same robot.``home`` stays the ARM’s joint vector; the carriage’s initial position is
rail.home. Folding the rail intohomewould silently invalidate every per-model default (a 6-value ur10ehomewould land on[rail, j1..j5]and leavewrist_3unset).
The carriage is a real body with mass, so it needs geometry; the plugin draws a small box for it and a
thin beam spanning the travel. Both are visual only (contype/conaffinity = 0). A ceiling
track that collides would trap the arm against its own support from the first step, and the collision
model a motion planner actually reasons about comes from the URDF/planning scene, not from these two
geoms – so making them solid would add a contact the planner cannot see. Model the real structure as
scene geometry if the cell needs it.
Mounting on a mobile base (``mount:``)
This is what turns a base + an arm into a mobile manipulator without a per-combination MJCF: the
arm is attached to the base’s body rather than to worldbody, so it rides the base’s free joint.
Both models stay untouched and any base pairs with any arm from the world YAML.
Two things it requires, both enforced in validate_config/build rather than left to fail
obscurely later:
The base must be declared before the arm in the world’s plugin list.
buildruns in declaration order, so the body the arm welds to has to exist already.The arm needs a non-empty ``prefix``.
arm_controllerand_apply_homeselect an arm’s joints by prefix scan, and with an empty prefix that scan also claims the base’s wheel joints – writing arm position targets into wheel actuators that another plugin owns. (An arm that names itsjoints:explicitly is safe either way, but the prefix is the cheap general guard.)
name: is the entry’s reserved SIBLING, not one of the keys above: it labels the entry and names
the entity this spawn registers (default: the plugin ref, i.e. spawn_arm). Written inside the
config block it is silently inert – the arm is then called spawn_arm, and anything addressing
it by the name you chose (arm_controller’s arm:, a sensor’s robot:) resolves to nothing.
End effectors (``end_effector:``)
A gripper is attached into the arm’s own spec before the arm is attached to the world, so the
arm’s prefix covers it. That is deliberate: arm_controller discovers a gripper as an actuator with
a non-joint (tendon) transmission sharing the arm’s prefix, which is exactly how the pre-assembled
gen3 works – so a bare arm plus a gripper reaches the same state as a factory-assembled one, and
the controller needed no change to gain interchangeable hands. The gripper’s own
<model>.manifest.yaml supplies the gripper half of arm_controller’s config
(gripper_joint/gripper_open/gripper_close), merged by SpawnArmPlugin.expand().
The attach uses MuJoCo’s site attachment, so the site’s orientation defines the tool frame and
pos/rpy are offsets within it – matching how a real tool adapter is specified.
roqsim_mobile
ackermann_drive
Controller plugin: Ackermann (car-like) steering + wheel-encoder odometry.
Config:
ackermann_drive:
wheel_radius: 0.05
wheelbase: 0.32 # front axle to rear axle -- what turns a curvature into an angle
track: 0.24 # driven axle width, for the drive split (and the default below)
steer_track: 0.24 # steering-axis (kingpin) separation, for the steer split
max_linear_vel: 2.0
max_steer_angle: 0.5 # rad; the rack's mechanical limit, and the turning circle with it
steer_rate: 4.0 # rad/s slew on the steering angle (0 = instant)
accel_limit: 2.0 # m/s^2 on the commanded speed (0 = instant)
steer_actuators: [left_steer_motor, right_steer_motor] # POSITION servos, left then right
steer_joints: [left_steer_joint, right_steer_joint]
drive_actuators: [rear_left_motor, rear_right_motor] # VELOCITY servos, left then right
drive_joints: [rear_left_joint, rear_right_joint]
base_body: base_link
odom_child_frame: base_link # link the odometry TF points at (see below)
stamped_cmd_vel: false # true when the stack publishes TwistStamped (see below)
test_cmd: [1.0, 0.4] # optional [v, w] applied every tick (standalone demo)
stamped_cmd_vel selects geometry_msgs/TwistStamped instead of geometry_msgs/Twist
for the velocity command. Which of the two a stack publishes is a property of that stack, not of
the kinematics: Nav2 switches with its own enable_stamped_cmd_vel (the TurtleBot 4’s shipped
configuration sets it), and ROS 2 is moving towards the stamped form. A subscription is one type,
so a mismatch is not a degradation but silence – the robot receives no command at all, and the
only symptom is a controller reporting that it cannot make progress.
odom_child_frame names the link the odom -> transform points at, and it must be the ROOT of
whatever URDF robot_state_publisher is running beside the simulator: a description rooted at
base_footprint already gives base_link a parent, and a second parent from here leaves that
frame with two, which tf2 cannot resolve.
Endpoints are the ones every base here publishes, so a stack does not know which geometry it is
driving until it tries to turn in place: cmd_vel in (geometry_msgs/Twist), odom out with
TF, and joint_states out carrying the steer joints as well as the driven ones – a car’s
steering angle is state a stack watches, and leaving it out is how a URDF’s front wheels stay
straight in RViz while the robot corners.
Odometry is what the encoders say, as in diff_drive: the driven wheels’ measured speed for
v, and the measured steering angle for the yaw rate through the same bicycle relation
(w = v * tan(delta) / L). Reading back the commanded angle instead would report a car that
corners perfectly while the rack is still slewing.
It is therefore dead reckoning and it drifts, which is deliberate. On a straight run the test
vehicle’s odometry lands within a few percent; cornering, the tyres slip and the bicycle relation
under-reports the turn – measured, a car that came round 1.1 rad believes it came round 0.8. No
scrub factor is offered to hide it: unlike a skid-steer’s, whose scrub is systematic enough for
diff_drive’s slip_factor to correct, a tyre’s slip angle varies with speed and load, so a
single constant would be a fudge that makes the odometry look better than the sensor it stands for.
roqsim_sensors.plugins.ground_truth_pose is what a grader compares against, and the gap
between the two is what a localisation experiment is about.
diff_drive
Controller plugin: differential-drive kinematics + wheel-encoder odometry.
Config – a component of the entry that spawns the base, since ownership is where the entry sits rather than a config key:
diff_drive:
namespace: "" # transport scope (default: inherited from spawn_robot's namespace)
wheel_radius: 0.03575
wheel_separation: 0.233
max_linear_vel: 0.31
max_angular_vel: 1.90
wheel_accel_limit: 0.9 # m/s^2 per wheel; ramps commands (Create 3 default/max 900 mm/s^2)
left_actuator: left_wheel_motor
right_actuator: right_wheel_motor
left_joint: left_wheel_joint
right_joint: right_wheel_joint
odom_child_frame: base_link # frame the odometry TF points at (see below)
odom_rate_hz: 50.0 # publish rate of odom (and its TF) and joint_states
stamped_cmd_vel: false # true when the stack publishes TwistStamped (see below)
cmd_vel_timeout: 0.0 # s; > 0 stops the base when no command arrives for this long
publish_joint_states: true # false when a joint_state_publisher covers the whole robot
test_cmd: [0.15, 0.4] # optional [v, w] applied every tick (standalone demo)
odom_noise: # optional odometry error (see below); omitted = exact odometry
linear_stddev: 0.0 # m/s, white noise on the reported linear velocity
angular_stddev: 0.0 # rad/s, white noise on the reported yaw rate
linear_scale: 1.0 # multiplicative bias (e.g. a wheel radius off by 1 %)
angular_scale: 1.0 # multiplicative bias on the yaw rate (e.g. an effective track error)
Skid-steer (>1 wheel per side, e.g. Husky A200): give the per-side actuator/joint lists instead of
the singular keys; every left wheel gets the same command, every right wheel the same, and odometry
averages each side’s wheel velocities. slip_factor compensates the lateral scrub of a skid-steer
(see below):
diff_drive:
wheel_radius: 0.17775
wheel_separation: 0.5708 # track width
slip_factor: 4.0 # ICR slip compensation (1.0 = ideal diff-drive)
left_actuators: [front_left_wheel_motor, rear_left_wheel_motor]
right_actuators: [front_right_wheel_motor, rear_right_wheel_motor]
left_joints: [front_left_wheel_joint, rear_left_wheel_joint]
right_joints: [front_right_wheel_joint, rear_right_wheel_joint]
stamped_cmd_vel selects geometry_msgs/TwistStamped instead of geometry_msgs/Twist
for the velocity command. Which of the two a stack publishes is a property of that stack, not of
the kinematics: Nav2 switches with its own enable_stamped_cmd_vel (the TurtleBot 4’s shipped
configuration sets it), and ROS 2 is moving towards the stamped form. A subscription is one type,
so a mismatch would be not a degradation but silence – no command arrives and nothing logs it –
which is why the ROS bridge fails the run when a peer of another type sits on one of its topics,
naming the topic, both types and both sides.
cmd_vel_timeout is the watchdog every real base driver has: a command is good for this long
and then the base stops, so a stack that dies mid-run leaves a stationary robot rather than one
driving at its last velocity into a wall. ros2_control’s diff_drive_controller ships it at
0.5 s and the Create 3’s firmware behaves the same. It is off by default here, because an in-process
driver that sets a twist once and steps expects it to hold; a world that runs a real stack sets it
to that stack’s value. The stop goes through the same acceleration ramp as any command.
publish_joint_states switches this plugin’s own joint_states (the wheel joints) off, for a
robot whose joint_state_publisher publishes every joint in one message – a consumer that needs
a suspension travel in the same message as the wheels must not get two messages on one topic that
each carry half.
odom_child_frame names the link the odom -> transform points at, and it must be the ROOT
of whatever URDF robot_state_publisher is running: a robot_state_publisher rooted at
base_footprint (every published TurtleBot 3 description) already gives base_link a parent,
and a second parent from here leaves the frame with two, which tf2 cannot resolve. Robots whose
description is rooted at base_link (TurtleBot 4) keep the default.
slip_factor exists because a 4-wheel skid-steer turns by scrubbing its wheels sideways: with
MuJoCo point contacts the base yaws at only ~15% of the ideal differential-drive prediction, which
would leave a planner unable to rotate. The factor inflates the yaw term of the wheel command and is
divided back out of odometry, so commanded yaw is achieved and odometry stays consistent with the
base’s real motion. It is a per-robot calibration against the model’s contact/friction setup –
re-measure it (achieved vs commanded yaw rate) if wheel friction, mass, or the timestep change.
odom_noise makes the odometry wrong the way real wheel odometry is wrong, and leaves the robot’s
motion alone. The error is applied to the velocities read off the wheels, before they are integrated,
so the reported pose drifts rather than jitters: a linear_scale of 1.01 is a wheel radius stated
1 % too small and overstates every metre by a centimetre, and the *_stddev terms are zero-mean
white noise on each reading, drawn per physics step. A white velocity error integrates to a random
walk, so the pose spread grows with the square root of the distance driven and shrinks with the
timestep; state the stddev together with the world’s sim.timestep. Everything downstream that
consumes odometry sees it – the odom endpoint, its TF, the RobotHandle – and nothing that
reports the truth does: the body’s pose, sim_poses and a ground-truth pose plugin are exact.
Draws come from ctx.rng_for (docs/architecture.rst §9.1), so a noisy run needs a seed and
reproduces from it; with the block omitted nothing is drawn and the odometry is exact, as before.
floorplan
Flags: provides_world
Scene plugin: a floorplan as the world – ground plane, light and walls, from a mesh or from wall segments.
Config:
floorplan:
# --- one of these two sources ---
mesh: <path> # floorplan mesh (.stl); absolute, or relative to the process cwd
# ...or the layout instead, in the floorplan JSON's own vocabulary:
floorplan: rooms.json # what `roqsim scenes dxf-to-floorplan` and the sketch window write
lines: # ...or the segments inline
- {id: 0, x0_m: 0.0, y0_m: 0.0, x1_m: 6.0, y1_m: 0.0}
doors: [{line_id: 0, t: 0.5, width_m: 0.9}] # t is 0..1 along that wall
height: 2.5 # segments only: ceiling height (m)
thickness: 0.12 # segments only: wall thickness (m)
opening_height: 2.0 # segments only: door height; the wall above one becomes a lintel
# --- the rest applies to both ---
mesh_scale: 1.0 # float or [x, y, z]
mesh_pos: [0, 0, 0] # placement offset of the mesh in the world frame
floor: # ground-plane appearance + physics (all keys optional; default = light gray)
rgb1: [0.85, 0.85, 0.85] # builtin-checker colour A (0..1 RGB)
rgb2: [0.78, 0.78, 0.79] # builtin-checker colour B
reflectance: 0.2 # 0..1; if omitted, a texture's manifest value (else 0.2) is used
texture: null # PNG image; overrides rgb1/rgb2 when set. A package-qualified
# name ('roqsim_assets:Concrete030') or a PNG path
# (absolute / cwd-relative). MuJoCo loads PNG only.
rgba: null # optional multiplicative tint on the texture/checker (like
# Poly Haven's base colour). RGB >1 brightens (not clamped
# to 1); e.g. [2.2, 2.2, 2.2, 1] = much brighter.
physical_size: 1.8 # metres one tile spans (real-world scale); scalar or [x, y].
# If omitted, a texture's manifest value (else 1.0) is used.
friction: [2.0, 0.005, 0.0001] # geom contact friction [sliding, torsional, rolling]
wall: # floorplan-mesh appearance (same keys as 'floor' minus friction; default = gray)
rgb1: [0.8, 0.8, 0.82] # solid colour when rgb1 == rgb2 (the default)
rgb2: [0.8, 0.8, 0.82]
reflectance: 0.0
texture: null # PNG image (see 'floor.texture'), applied to the wall mesh.
rgba: null # optional tint (see 'floor.rgba')
physical_size: 2.4 # metres one tile spans. Honoured for both a UV-less .stl (via
# texuniform) and a UV'd .obj (its UVs are scaled to match).
light: # a single overhead light at the floorplan centre + a global ambient
height: 2.5 # metres above the floor for the light
diffuse: [0.35, 0.35, 0.35] # light colour/intensity (flat across the cone)
cutoff: 90.0 # spot half-angle (deg); 90 = hemisphere, no visible cone edge
fill: [0.3, 0.3, 0.3] # uniform global ambient (not a light); [0, 0, 0] disables it
Textures are resolved via roqsim.textures.resolve_texture(): a package-qualified
<package>:<name> (e.g. roqsim_assets:Concrete030, from the shared roqsim_assets)
or a PNG path – no cross-package name search. A texture folder may carry a manifest.yaml (next to
the PNG) with surface properties – reflectance and physical_size – used when the world does
not set the matching <floor|wall> key explicitly. When no manifest exists, the defaults are used.
omni_drive
Controller plugin: holonomic (omnidirectional) base kinematics + odometry.
Config – a component of the entry that spawns the base, since ownership is where the entry sits rather than a config key:
omni_drive:
namespace: "" # transport scope (default: inherited from spawn_robot)
base_joint: base_free # the base's free joint
vx_actuator: base_vx # planar drive actuators (see "Planar drive" below)
vy_actuator: base_vy
wz_actuator: base_wz
max_linear_vel: 1.0 # m/s, applies to vx
max_lateral_vel: 1.0 # m/s, applies to vy (defaults to max_linear_vel)
max_combined_linear_vel: 0.7 # m/s, cap on hypot(vx, vy); 0 disables
max_angular_vel: 2.09 # rad/s
accel_limit: 1.0 # m/s^2, ramps vx and vy
angular_accel_limit: 2.09 # rad/s^2, ramps wz
# Mecanum wheels: OBSERVATIONAL only (see "Wheels" below). Omit to skip wheel handling.
wheel_radius: 0.0762
wheel_separation: 0.44715 # lateral, left <-> right
axis_separation: 0.488 # longitudinal, front <-> rear
wheels: [front_left, front_right, rear_left, rear_right] # joint name stems
wheel_actuators: [...] # same order; defaults to <stem>_motor
# SWERVE bases only: give the steer joints and their POSITION actuators, same order as
# `wheels`. Their presence is what selects swerve inverse kinematics over mecanum.
steer_joints: [...]
steer_actuators: [...]
odom_child_frame: base_footprint # link the odometry TF points at (see below)
stamped_cmd_vel: false # true when the stack publishes TwistStamped (see below)
test_cmd: [0.2, 0.1, 0.0] # optional [vx, vy, wz] applied every tick (standalone demo)
stamped_cmd_vel selects geometry_msgs/TwistStamped instead of geometry_msgs/Twist
for the velocity command. Which of the two a stack publishes is a property of that stack, not of
the kinematics: Nav2 switches with its own enable_stamped_cmd_vel (the TurtleBot 4’s shipped
configuration sets it), and ROS 2 is moving towards the stamped form. A subscription is one type,
so a mismatch would be not a degradation but silence – no command arrives and nothing logs it –
which is why the ROS bridge fails the run when a peer of another type sits on one of its topics,
naming the topic, both types and both sides.
odom_child_frame names the link the odom -> transform points at, and it must be the ROOT of
whatever URDF robot_state_publisher is running beside the simulator: a description rooted at
base_footprint already gives base_link a parent, and a second parent from here leaves that
frame with two, which tf2 cannot resolve.
Planar drive. A real omnidirectional base translates sideways because each mecanum wheel’s passive rollers let the
contact patch slide along one diagonal. Those rollers are not modelled (~9 per wheel would mean
~36 extra bodies and as many contact pairs, and MuJoCo’s cylinder-on-plane contact frame is not
roller-aligned, so anisotropic friction is not a reliable substitute). Instead the commanded twist is
applied to the base’s free joint through three velocity actuators, and the wheels are near-frictionless
load carriers – which is what an omni wheel is, in the directions that matter. This is the approach
PAL themselves sketched: base_x / base_y / base_tau velocity actuators on the floating
base, commented out in omni_base_description/mujoco/mj_tags.xacro.
The drive stays inside MuJoCo’s force path rather than writing qvel directly, so contacts still
win: a wall stops the base, and it cannot be shoved through thin geometry.
Frames. A free joint’s translational DOFs are expressed in the world frame and its rotational
DOFs in the body frame (both verified against MuJoCo 3.11), and gear inherits that. So the
body-frame vx/vy command is rotated by the base yaw before it is written to ctrl, while
wz needs no rotation. Getting this wrong yields a base that drives correctly only while its
heading is zero – which a straight-line test would not catch.
Swerve. A steerable-wheel base is holonomic like a mecanum one, so the planar drive above is
unchanged: what differs is only how the wheels are told to follow. Given steer_joints and
steer_actuators, each corner’s contact velocity is computed from the body twist
(v_k = v + w x r_k), the steer actuator is commanded to atan2 of it and the roll actuator to
its magnitude over the wheel radius. The steer target is resolved to whichever of the two equivalent
headings (theta, theta+pi) is nearer the joint’s current angle, negating the roll rate for the flipped
one – without that a command crossing straight-ahead makes every wheel slew half a turn, which looks
like a violent glitch and is purely an artefact of the branch cut.
Like the mecanum case this is observational: the wheels are not the motive force. The difference is that a swerve base’s steering is visible and physically meaningful, so getting it right matters for anything reading joint_states or watching the robot – but a paper measuring the steer joints themselves (their rate limits, or the reorientation delay at a direction change) needs real steer actuation, which this is not.
Wheels. The wheel velocity servos are driven from the mecanum inverse kinematics purely so that the visual
and joint_states are right (the wheels turn, and turn differently when strafing). They are not
the motive force: at the model’s wheel friction they transmit almost nothing. Each wheel’s roll sign
is derived from its joint axis in the base frame at configure time rather than hardcoded, because
which way “positive” spins depends on how the source URDF mirrored that wheel.
Odometry. Integrated from the base’s achieved twist, so a base held against a wall reports no progress. Note the consequence: with no wheel slip in the model, wheel-encoder odometry and ground truth coincide by construction. This port therefore cannot be used to study odometry drift – a skid-steer’s characteristic error source is absent here by design, not by accident.
spawn_robot
Scene plugin: attach a robot MJCF into the world and own its base spawn pose.
Config:
spawn_robot:
model: turtlebot4 # bundled model name, filename, or absolute path
namespace: "" # optional transport scope; the robot's endpoints inherit it
prefix: "" # MJCF name prefix (use distinct prefixes for >1 robot)
pose: # the spawn pose, as SpawnEntity states one (see below)
position: {x: 0.0, y: 0.0}
orientation: {yaw: 0.0}
base_joint: base_free # free joint used to place the base
actuators: # OPTIONAL: what law this robot's actuators run under, and their gains.
control: velocity # position | velocity | effort | impedance; the model's own if unset
d: 40 # N*m*s/rad -- see roqsim.actuators for the gain of each control
each: # per-actuator, on top of the shared keys above
front_left_wheel_motor: {d: 25}
present: true # false: compiled in, but absent until it is spawned
frames: # OPTIONAL: fixed links beyond the manifest's own (see below)
- {name: cover_link, parent: body_link, pos: [0, 0, 0.05], rpy: [0, 0, 0]}
Vendor frames. A top-level frames: block in the model’s manifest – plus any in this config,
after it – names the fixed links the vendor description chains and the MJCF flattens
(roqsim.frames). Each becomes a site <prefix><name> on its parent’s body at build, so a
mounted device can hang from it (spawn_sensor’s parent_frame), and is published at configure
as a static transform parent -> name read from the compiled model, in the robot’s namespace.
Where a chain starts at a body other than the robot’s root, root -> body is published with it,
so the chain joins the robot’s tree; that body must be welded to the root.
name: is the entry’s reserved SIBLING, not one of the keys above: it labels the entry and names
the entity this spawn registers (default: the plugin ref). Components nested under the entry attach
to that entity by position, and are addressed <name>.<label>.
The plugin registers an Entity(kind='robot') whose meta carries prefix, model, and
initial_pose so controller/sensor/bridge plugins can resolve the right (prefixed) names.
pose: is the spawn pose, in the shape SpawnEntity.srv gives its initial_pose – a
geometry_msgs/PoseStamped. Its orientation may be a quaternion or Euler angles, so the common
case stays short:
- spawn_robot: {model: turtlebot4, pose: {position: {x: 1.5, y: 2.0},
orientation: {yaw: 0.785}}}
It is the ONLY way to state one, which is the point: a world declaring where a robot starts and a
SpawnEntity call placing it mid-trial are the same pose, so they are written the same way and
a producer needs no conversion that depends on which door the pose came in at. It also makes the
pose a value a campaign can write per configuration – one destination, not two keys to split
across – so a swept start pose is spawned rather than applied after the fact.
Omitting it spawns the robot at the origin, at the model’s own resting height. See
roqsim.pose for the shape and the three ways a document may say more than the message can
(a world-frame header, an omitted z meaning that resting height rather than zero, and the
Euler spelling).
The rotation is a full quaternion, because the service’s is: a robot can be spawned tilted. Nothing here refuses that – the base has a free joint and MuJoCo holds whatever orientation it is given – so a rotation that is not a heading is taken at its word.
present: false compiles the robot in and starts it absent – nothing sees or touches it, and
the control plane does not list it – until SpawnEntity brings it in at the pose that call
states (see roqsim.presence). The declared value is restored on on_reset, so what one
trial spawned does not carry into the next.
Absence hides the robot’s BODY, not its software: its controller and sensor plugins keep running,
so an absent robot still publishes and still responds to a twist – and being out of the contact
set, a twist drives it through walls. For a start pose decided per run, move the robot with
SetEntityState instead; absence is for a machine that is not meant to be in the trial yet.
roqsim_nav
navigator
Controller plugin: move an entity along a route, inside the simulator.
Config – a component of the entry that provides the entity it moves, since ownership is where the entry sits rather than a config key:
navigator:
output: auto # auto | drive | mocap | ... | module:Class | file.py:Class
speed: 0.5 # m/s the route is followed at (REQUIRED, > 0)
goals: # the route, world metres
- [4.0, 3.0] # [x, y] or [x, y, yaw]
- [0.0, 3.0]
dwell: 0.0 # seconds to stand still on reaching a goal: one number, `[lo, hi]`
# for a random pause, or a list of either -- one per route point,
# the mover's own start included, so it lines up with `goals`
# preceded by where the mover began
route_mode: plan # plan -> A* between the points; exact -> the points ARE the path
tracker: waypoint # waypoint -> steer at the goal, advance within `arrival_radius`
# pure_pursuit -> steer at a carrot `lookahead` along the route and
# advance on crossing a goal; bounds cross-track error by the
# lookahead instead of by the arrival radius, which is what a
# non-holonomic base asked to follow a given path needs
lookahead: 0.6 # m; pure_pursuit only
autostart: true # false -> hold at the first point until started
loop: false # cycle the route forever rather than stopping at the last point
arrival_radius: 0.25
# -- what it does about what the plan did not contain -----------------------------------
# Three independent capabilities, not a ladder. See AVOIDANCE_KEYS for why.
avoidance:
stop: true # look ahead and hold until the way is clear
steer: none # none | give_way | orca | module:Class -- which model gives way
reroute: false # remember what stopped it and plan around it (needs `stop`)
params: {} # per-agent keys the chosen model accepts, checked at load
# the probe's own tuning, in the same block
lookahead: 1.2 # m of clear corridor needed, measured from the mover's FRONT
width: 0.6 # m of corridor swept: the body, plus the clearance it should keep
rays: 5 # how finely that width is sampled
height: 0 # m above the floor to scan; 0 -> just above obstacle_height's floor
clear_time: 0.5 # s the way must stay open before setting off again
yield_time: 3.0 # s a blockage reads as traffic before recovery may engage
forget_after: 5.0 # s a remembered blockage keeps steering the planner (reroute only)
blockage_radius: 0 # m of the disc a blockage marks; 0 -> half the corridor width
ignore: [] # entities this mover never stops for
# -- output: drive ---------------------------------------------------------------------
kinematics: auto # auto | unicycle | holonomic | ackermann (auto asks the output)
heading_gain: 2.0 # rad/s of yaw command per rad of heading error
max_angular_vel: 1.5 # rad/s cap BEFORE the drive plugin's own clip
turn_in_place: 0.8 # rad of heading error above which a unicycle base pivots instead
min_speed: 0.15 # m/s an ackermann base is never commanded below (it cannot pivot)
face: travel # holonomic only: travel | hold
# -- output: mocap / walker ------------------------------------------------------------
yaw_rate: 3.0 # rad/s the body is re-faced at (0 = snap)
# -- planning --------------------------------------------------------------------------
obstacle_height: [0.05, 0.6] # z band a geom must span to be a wall FOR THIS MOVER
resolution: 0.05 # m per planner grid cell
planner: {inflation_radius: 0.35, waypoint_radius: 0.3}
recovery: {enabled: true, stuck_time: 1.5, backup_time: 0.5, max_recovery: 4}
update_hz: 20.0 # nav pipeline rate; physics steps far faster
obstacle_height is per mover on purpose: a 0.4 m pallet is not stopped by a ceiling beam that
blocks a walker, so “what counts as a wall” is a property of the thing navigating, not of the world.
Movers that agree on it share one rasterized grid (see roqsim_nav.grid).
roqsim_quadruped
spot_locomotion
Controller plugin: RL flat-terrain walking policy for the Boston Dynamics Spot (12-DoF quadruped).
Config – a component of the entry that spawns the robot, since ownership is where the entry sits rather than a config key:
spot_locomotion:
namespace: "" # transport scope (default: inherited from spawn_robot's namespace)
policy_path: <fetched> # TorchScript policy (default: $SPOT_POLICY_PATH or policy/spot_policy.pt)
config_path: <bundled> # deploy config (default: policy/spot.yaml)
max_linear_vel: 1.5 # |vx| clamp (m/s)
max_lateral_vel: 0.8 # |vy| clamp (m/s)
max_angular_vel: 1.5 # |yaw_rate| clamp (rad/s)
test_cmd: [0.5, 0.0, 0.0] # optional [vx, vy, w] applied every tick (standalone demo)
roqsim_sensors
fiducial_marker
Scene plugin: add an ArUco / AprilTag fiducial marker to the world or onto a robot body.
Config:
fiducial_marker:
family: apriltag_36h11 # apriltag_36h11 | apriltag_25h9 | aruco_4x4_50 | aruco_5x5_100 | ...
id: 0 # marker id within the family
size: 0.05 # side of the BLACK marker square (m) -- the length a detector estimates
quiet_zone: 0.15 # white margin around the tag, as a fraction of `size` (default 0.15)
emission: 0.0 # material emission; RAISE ONLY FOR VISIBILITY, NOT FOR DETECTION (see below)
thickness: 0.002 # box half-thickness behind the marker face (m)
vflip: false # flip the texture rows / cols if the render comes out mirrored
hflip: false # (a mirrored tag will NOT decode)
# --- placement: EXACTLY ONE of the following two forms ---
# (a) free-standing in the world:
pose: [x, y, z] # world position of the marker centre
quat: [w, x, y, z] # world orientation (or `rpy: [r, p, y]`); default: marker faces +Z (up)
# (b) welded to a robot/arm body:
attach_to: wrist_3_link # body name (without prefix)
prefix: "ur10e_" # target robot/arm MJCF prefix (matches spawn_robot/spawn_arm `prefix`);
# inherited automatically when this plugin ships in a model manifest
rel_pose: [x, y, z] # marker position in that body's frame (default [0, 0, 0])
rel_quat: [w, x, y, z] # marker orientation in that body's frame (or `rel_rpy`); default identity
The texture, material and geom this builds are named after the entry’s label (its name:
sibling, else fiducial_marker), so a world carrying several markers gives each entry a label.
force_limit
Observation plugin: stop when a measured wrench exceeds what the task allows.
Config – a component of the entity whose wrench is watched:
force_limit:
ft: ft # blackboard key suffix of the force_torque sensor (`ft:<key>`)
max_force: 40.0 # N; magnitude of the measured force, 0 disables
max_torque: 0.0 # Nm; magnitude of the measured torque, 0 disables
settle_s: 0.0 # ignore the first seconds, while a reset transient decays
latch: true # once tripped, stay tripped until reset (a trial is failed, not un-failed)
stop_run: true # ask the driver to end the run, as a real stop ends the motion
release_controllers: true # deactivate the controllers driving the arm, the way a stop does
reports_as: protective_stop # the word this robot's own interface uses
rate_hz: 30.0
Endpoint force_limit (out) reads a LimitReport:
(tripped, reason, at_time, force, torque) – at_time is the simulation time of the first
trip (-1.0 if none) and force/torque are the magnitudes that caused it, so a failure is
attributable rather than merely flagged.
force_torque
Flags: parallel_safe
Sensor plugin: a six-axis force/torque sensor at a site.
Config – a component of the entry that spawns the arm whose prefix and namespace it inherits, since ownership is where the entry sits rather than a config key:
force_torque:
site: fts_site # REQUIRED: MJCF site to measure at (prefixed with the arm's prefix)
frame: base # sensor | base | world -- the frame the wrench is REPORTED in
invert: true # negate the reading (report the force the ENVIRONMENT applies to the
# tool, the sign convention a real FT sensor and its users assume;
# MuJoCo's site sensor reports the opposite). Whichever is chosen,
# the blackboard reader says which it is in `measures`, so a
# consumer never has to assume.
tare_at_s: null # sim time (s) at which to capture the zero offset, once per
# episode; null (default) never tares. The `tare` service and
# `WrenchReader.tare()` are the better doors -- see "Taring"
# below, and note the offset is only valid at the pose it was
# captured at.
noise_force_stddev: 0.0 # N, additive Gaussian white noise on the three force channels
noise_torque_stddev: 0.0 # Nm, likewise on the three torque channels
rate_hz: 100.0 # endpoint publish rate
namespace: "" # transport scope (default: inherited from the entity)
topics: {wrench: /ft} # optional absolute-topic hardwire
Endpoint tare (in) is that zero button as a service; it takes no argument and its reply is
what lets a scenario fail rather than measure against an offset it only assumed was applied.
Endpoint wrench (out) reads (force[3], torque[3]) and carries a
geometry_msgs/WrenchStamped backend hint. A WrenchReader is published on the blackboard
under ft:<entry label> for in-process consumers — the admittance controller is one — exposing
read() and the resolved frame.
Frames. sensor returns MuJoCo’s raw site-frame reading. base rotates it into the owning
entity’s base body frame, and world into the world frame. The choice is not cosmetic for
metrics that split the wrench into an insertion axis and the plane orthogonal to it: |F_z| and
||F_x, F_y|| are frame-dependent, and a tool that tilts reports a different split in its own
frame than in the world’s.
Taring: zeroing the tool’s own load. The sensor reads everything below the cut, which for a loaded flange is mostly the tool’s own weight – so a contact task measuring a 5 N push starts from 20 N of tool.
Zeroing is a command, the way it is on real hardware: an FT driver exposes a service taking no
argument (zero_ftsensor) and this exposes the same thing three ways onto one implementation –
the tare endpoint (std_srvs/Trigger over ROS), WrenchReader.tare() for an in-process
controller, and tare_at_s for a world that wants it done once at a stated time without anything
to press the button. Prefer one of the first two: a time is a number that has to stay in step with
a scenario’s own timing, and if the approach runs long it fires mid-motion and zeroes against a
contact.
All three re-arm on on_reset: an offset carried into the next episode is a measurement of the
previous one, and repetitions of a trial would not be repetitions.
A tare is not gravity compensation, and the difference is a trap. The offset is captured in the raw sensor frame and subtracted there, exactly as a real FT sensor’s zero button works – so it is valid at the pose the tool was in when it was captured. Rotate the tool ninety degrees and the weight reappears, up to twice the tool’s load in the worst case, because the tool’s weight is fixed in the WORLD frame while the sensor frame turns with it. Compensating at every pose needs the tool’s mass and centre of mass estimated, which is a calibration, not a tare; roqsim does not do it, and a tare that quietly claimed to would leave a contact controller chasing a bias that grows with the tool’s tilt. Tare at the pose you are about to make contact in, or tare per approach.
The offset is the reading BEFORE noise is added, so what is left after taring is the noise alone
rather than the noise plus one sample’s worth of it – a real tare averages many samples, and this
is that average exactly. Capture happens on the first read at or after tare_at_s, so a sensor
nobody reads is never tared and one read at 100 Hz tares within a step of the time asked for.
Noise is per-sensor config, deliberately. There is no generic error-model framework in roqsim (see
docs/architecture.rst §9); a sensor that wants noise declares its own, as the lidar’s
range_stddev does. The default is zero: a noise model that appears without being asked for is a
silent change to every metric derived from the signal.
The draws come from roqsim.context.SimContext.rng_for() – the run’s seed, not a per-sensor one –
for the same reason the lidars use it, plus one specific to a wrench: it is a pure function of
(seed, sim_time, sensor), so two readers in the same step see the same wrench. This sensor has
two by construction (the wrench endpoint and the blackboard WrenchReader an in-process
controller polls), and with a stateful generator each read would have advanced the stream – the
controller and the recorded signal would disagree about the force at one instant, which is
indistinguishable from a controller bug. It also makes the noise reproducible from a recorded state
without replaying the run, and repeats identically after on_reset because sim_time restarts.
gnss
Sensor plugin: a GNSS receiver – local ENU position converted to WGS84, with drift.
Config:
gnss:
datum: {lat: 47.397742, lon: 8.545594, alt: 488.0} # REQUIRED -- world origin on Earth
body: x500 # the body whose position is measured (default: the entity root)
rate: 10.0 # Hz -- real GNSS is slow, and EKF2's behaviour depends on that
horizontal_noise: 0.5 # m, 1-sigma
vertical_noise: 1.0 # m, 1-sigma
velocity_noise: 0.1 # m/s, 1-sigma per axis
bias_time: 60.0 # s, correlation time of the slowly-drifting position bias
satellites: 12
eph: 0.5 # m, reported horizontal accuracy (default: horizontal_noise)
epv: 1.0 # m, reported vertical accuracy (default: vertical_noise)
fix_type: 3 # 3 = 3D fix
denied: false # true -> no fix at all, without removing the plugin
The projection is deliberately the cheap one. dlat = north / R, dlon = east / (R cos
lat) with R = 6378137.0 m (the WGS84 semi-major axis) – an equirectangular tangent-plane
approximation. Its error grows with the square of the offset from the datum, and over the
kilometre-scale extents a MuJoCo world can actually hold it stays well under a metre, i.e. below
this receiver’s own noise floor. It is not a general geodesy routine: it ignores the ellipsoid’s
flattening, uses geodetic altitude as if it were MSL, and would be wrong at continental range. Any
experiment that needs true geodesy needs pyproj and a real projection, not a bigger constant here.
Noise is a drifting bias plus white noise, not white noise alone. A pure-white GNSS is the
input that makes an EKF look best: averaging kills it, so position error shrinks with the filter’s
window and the estimator appears to have solved a problem it has not. Real GNSS error is dominated
by slowly-correlated terms (ionosphere, ephemeris, multipath geometry), and that is what a
navigation experiment is about – how the estimator behaves when its position reference wanders
metres over a minute. The bias here is an Ornstein-Uhlenbeck process with correlation time
bias_time; the white term rides on top.
Randomness comes from ``ctx.rng_for``, like every other stochastic plugin, so the fix is a pure
function of (sim.seed, episode, sim_time) and this plugin has no seed key of its own.
The fix is held between updates. At rate Hz the value changes 10 times a second and is
constant in between, because that is what the sensor does – a consumer that interpolated it would
be inventing measurements the receiver never made, and would hide exactly the latency an estimator
has to cope with.
What this does not model: multipath and its geometry-dependent structure, ionospheric and
tropospheric delay, constellation geometry (so satellites and the reported eph/epv are
declared constants, not computed DOP), RTK/carrier-phase modes, receiver clock error, and
acquisition time – the fix is available on the first tick.
ground_truth_pose
Flags: parallel_safe
Sensor plugin: ground-truth pose of a body or a site as a TF frame.
Config:
ground_truth_pose:
# The pose read is the entity this entry is NESTED UNDER; ownership is position, not a
# config key, and it is required -- at the top of a document this entry is refused.
body: "" # base body override; default: the entity's registered base body
site: "" # a SITE of the entity instead of a body: the pose of a sensor
# mount, an emitter, an optical-flow sensor; `body` is then unused
relative_to: world # `world`: the true world pose (the default). `base`: the pose
# in the entity's base-body frame -- what a simulator publishes
# for a link of a model, and what a stack that composes link
# poses with the model's own pose expects
frame_id: map # parent frame of the transform (`base`: default: the base body)
child_frame: "" # default: "<model>_base_link_gt" for a body (Gazebo-compatible),
# the site's own name for a site
rate_hz: 30.0 # TF publish rate
lazy: false # true: publish only while something subscribes (Endpoint.lazy) --
# for a frame only a robot's own stack reads, never for /tf
topics: { pose: /tf } # optional absolute-topic hardwire (default relative "tf" -> /tf)
The transform is published on the relative tf topic, so it lands on the plain /tf (matching
Gazebo). Configure the ros2_bridge gt: {prefix} block to divert it to /gt/tf instead, or
give an instance its own topics: {pose: ...} when a stack reads ground truth from a topic of its
own rather than from the TF tree.
imu
Flags: parallel_safe
Sensor plugin: a strap-down IMU – angular rate, proper acceleration, and attitude at a site.
Config:
imu:
# The entity is the one this entry is NESTED UNDER; declaring it at the top of a document is
# refused (`requires_owner`) -- an IMU measures a body's motion, so it belongs to something.
body: "" # body the IMU is bolted to; default: the entity's registered base body
site: "" # measure at an EXISTING site instead (then `body`/`pos`/`rpy` are unused)
pos: [0.0, 0.0, 0.0] # mount offset in the body frame (m)
rpy: [0.0, 0.0, 0.0] # mount orientation, fixed-axis XYZ (rad); or `quat: [w, x, y, z]`
frame_id: imu_link # the frame the reading is stamped in (default: '<label>_link')
topic: imu/data # the endpoint's RELATIVE topic, so a device can match its driver's
# layout (the D435i's IMU is `camera/imu`); `topics: {imu: /abs}`
# still hardwires an absolute one and wins over this
rate_hz: 100.0 # endpoint publish rate
orientation: true # publish attitude; false -> orientation_covariance[0] = -1
accel_stddev: 0.0 # m/s^2, additive Gaussian white noise, per axis
gyro_stddev: 0.0 # rad/s, likewise
accel_bias: [0, 0, 0] # m/s^2, systematic -- a real accelerometer's is not zero-mean
gyro_bias: [0, 0, 0] # rad/s, likewise (a rate bias is what makes integrated yaw drift)
orientation_stddev: 0.0 # rad, small-angle noise about each axis
yaw_stddev: 0.0 # rad, EXTRA noise about the vertical axis only (see above)
fault: {gyro_stddev: 0.4} # optional: the values it takes while degraded (set_sensor_override)
Endpoint imu (out) reads an ImuReading and carries a sensor_msgs/Imu backend hint on
imu/data – the topic robot_localization and a standalone IMU driver both use – plus the
static body -> frame_id transform. An ImuReader is published on the blackboard under
imu:<address> for in-process consumers.
Nothing is computed while nothing is listening. The reading is assembled in the endpoint’s
read() rather than in a post_step, and the endpoint is lazy, so a bridge does not even
read it while the transport reports no subscriber (BridgeBase._skip_unsubscribed). That matters
here because an IMU is the fastest sensor on the robot – at 200 Hz, several of them, each drawing
noise from a fresh counter-based generator, is real work to do for nobody – and because a device
whose manifest carries an IMU by default (the D435i does) must cost a world that ignores it nothing.
Two properties make lazy safe for this endpoint, and both are why it is opt-in per endpoint:
the publish has no side effect beyond the message (the mount transform is latched once at bind
time, not derived per read), and an in-process consumer goes through the blackboard reader, which is
not gated by anyone’s subscriptions. Where no transport can report subscribers at all, the
convention is “assume yes”, so the endpoint stays live.
Covariances are the declared noise, squared, and nothing else. The reported variance is
stddev**2 per channel (bias is systematic and deliberately not folded in: a covariance is what a
filter uses to weight the random part, and inflating it to cover a bias tells the filter the bias
will average out). A perfect sensor therefore reports zero variance, which is truthful and which some
filters refuse; a world that wants a filter-friendly floor states the stddev it wants assumed, in the
world, where the run’s provenance records it.
Noise draws come from ctx.rng_for, keyed imu:<address>, like the lidar’s and the FT
sensor’s: reproducible from the run’s seed, identical for two readers in the same step (the endpoint
and the blackboard reader are two by construction), and repeatable across a reset.
lidar
Flags: parallel_safe
Sensor plugin: 2D lidar via batched ray-casting (roqsim.raycast.cast(), no GL).
Config (in addition to lidar_common’s namespace/site/frame_id/
rate_hz/exclude_body/dropout_percent/emit_static_tf/tf_parent/lazy):
lidar:
rays: 360 # samples; the first at angle_min, the last exactly at angle_max
angle_min: 0.0
angle_max: 6.265732015 # default: angle_min + 2*pi*(rays-1)/rays, a full turn
# with no bearing published twice
range_min: 0.164 # published as the header's range_min
max_range: 20.0 # published as the header's range_max
detection_min: 0.164 # nearest distance measured; a nearer hit is too close
# (default: range_min)
detection_max: 20.0 # farthest distance measured; a farther hit is no return
# (default: max_range)
too_close: -inf # published for a too-close hit: -inf, +inf, nan, raw
# (the true distance) or a number
no_return: +inf # published where nothing is measured: +inf, nan or a number
range_stddev: 0.0 # Gaussian range sigma (m)
range_stddev_relative: 0.0 # sigma as a fraction of the distance, at and beyond
# range_stddev_relative_from (0 = constant sigma)
range_stddev_relative_from: 0.0 # m; nearer than this the sigma is range_stddev
range_resolution: 0.0 # quantisation step of a published distance (m); 0 = continuous
What a scan publishes follows ROS REP 117 unless the device says otherwise. A hit nearer than
detection_min is published as too_close (-inf by default) and a ray with nothing within
detection_max as no_return (+inf). A too-close hit is never raised to range_min and
never published as though it were measured. A device model whose real driver publishes something
else declares it, e.g. urg_node publishes a Hokuyo’s too-close error code as 0.004 and its
no-return code as 65.533. A consumer uses a range only where range_min <= r <= range_max and
the value is finite, as REP 117 prescribes.
The header and the physics are separate keys because they differ on real hardware: a driver
publishes a hard-coded range_min/range_max (neo_sick_s300 publishes 0.01/29.0 for a
scanner that measures 0.05 to 30 m), while whether a return exists is the device’s physical limit.
range_min/max_range are what the header says; detection_min/detection_max decide
which rays are too close, measured, or no return. Each defaults to its header counterpart.
Layout. rays samples run from angle_min to angle_max inclusive, angle_increment =
(angle_max - angle_min) / (rays - 1), which is how every 2D scanner driver lays out a
LaserScan. A full turn is written the way its driver writes it: angle_max one increment short
of angle_min + 2*pi where no bearing repeats (the LDS-01’s hls_lfcd_lds_driver), or
-pi .. pi where the first and last ray share a bearing (the RPLIDAR C1’s sllidar_ros2).
Noise is drawn on the true distance of every hit and published on measured and raw
too-close returns; a constant too_close/no_return carries none. A noisy distance is floored at
0 and then quantised to range_resolution. It is not re-classified: a measured return that noise
carries past a header limit is published as the device would publish it, outside
[range_min, range_max].
frame_id defaults to site; set it when the robot’s real description names the frame
differently (the TurtleBot 4’s URDF calls it rplidar_link).
exclude_body defaults to nothing: a scanner skips only its own housing, which a device model names
(exclude_body: mount), and any other robot geometry in the scan plane is a real return.
The static mount TF (emit_static_tf, on by default) is tf_parent -> frame_id, measured from
that body. tf_parent defaults to the root body of the entity carrying the lidar (a robot’s base),
else the resolved exclude_body, else world for a lidar nothing carries. A device model mounted
with spawn_sensor sets emit_static_tf: false: the mount publishes the chain.
livox_mid360
Flags: parallel_safe
Sensor plugin: Livox Mid-360 3D lidar via batched ray-casting (roqsim.raycast.cast(), no GL).
Config (in addition to lidar_common’s shared keys):
livox_mid360:
site: lidar # site the rays are cast from
frame_id: livox_frame # defaults to `site`; Livox's own driver publishes `livox_frame`
horizontal_rays: 360 # azimuth samples across the 360deg FoV (wraps, so none at 2pi)
vertical_rays: 56 # elevation samples across the vertical FoV (inclusive endpoints)
h_fov_min: 0.0
h_fov_max: 6.283185307 # 2*pi (full 360deg)
v_fov_min: -0.122173048 # -7 deg
v_fov_max: 0.907571211 # +52 deg
range_min: 0.1 # blind zone: a nearer hit is too close
max_range: 40.0
too_close: drop # a hit nearer than range_min: drop (not in the cloud) or origin
no_return: drop # nothing within max_range: drop or origin
What a cloud carries follows the device. A measured return is a point at its distance along the
ray. A too-close hit and a ray with no return are left out (drop) unless the device’s driver
publishes them: origin puts a point at (0, 0, 0) of the sensor frame for each such ray, which is
what the Livox Mid-360 and livox_ros_driver2 publish (see mid360.manifest.yaml). A point
cloud is not a fixed-length array, so neither keeps a slot as a LaserScan ray does; with both set
to origin the cloud has one point per ray, in ray order.
oakd_camera
Sensor plugin: OAK-D Pro RGB-D camera via mujoco.Renderer (GL, offscreen).
Config (in addition to camera_common.CameraPlugin’s, and depth_camera.DepthCameraPlugin’s
clip_near/clip_far/depth_encoding):
oakd_camera:
camera: oakd_rgb
clip_near: 0.3 # m; depth outside [clip_near, clip_far] reads as "no return" (inf)
clip_far: 100.0 # m
On depth_encoding: 16UC1: this camera’s default 100 m clip_far is further than uint16
millimetres reach, so opting in means also lowering the range to the depth the world actually needs –
which the plugin says at load time rather than saturating at 65.5 m.
object_detector
Flags: parallel_safe
Sensor plugin: named objects detected relative to a robot, from ground truth plus an error model.
Config – a component of the entry that spawns the robot the detections are reported from, since ownership is where the entry sits rather than a config key:
object_detector:
frame: base_footprint # body whose frame the poses are expressed in
rate_hz: 10.0
objects: # body name -> what a detector would call it
- {body: graspable_carton, class_id: parcel, size: [0.040, 0.024, 0.090]}
position_stddev: 0.0 # m, Gaussian, per axis
orientation_stddev: 0.0 # rad, small-angle, applied about each axis
position_bias: [0.0, 0.0, 0.0] # m, systematic -- calibration error is not zero-mean
dropout_percent: 0.0 # this percentage of detections go missing
max_range: 0.0 # 0 = unlimited; else report only within this range
confidence: 1.0 # what the hypothesis score reports
The consumer contract, which a real detector must satisfy to drop in:
topic
detections, typevision_msgs/msg/Detection3DArrayheader.frame_idis a frame reachable through the robot’s own kinematics – nevermapobjects identified by
results[0].hypothesis.class_idlatest-wins; a detection missing from a message means “not detected this cycle”
range_sensor
Flags: parallel_safe
Sensor plugin: a ray-grid range sensor – the ToF / IR proximity / ultrasonic / cliff model.
Config (in addition to lidar’s site/frame_id/rate_hz/range_min/max_range/
detection_min/detection_max/too_close/no_return/noise/emit_static_tf/
tf_parent keys):
range_sensor:
site: cliff_front_left # rays are cast along the site's +x axis
h_rays: 1 # rays across the horizontal field of view (>= 1)
v_rays: 1 # rays down the vertical field of view (>= 1)
h_fov: 0.0873 # horizontal field of view, rad (5 deg); 0 with h_rays 1
v_fov: 0.0 # vertical field of view, rad; 0 with v_rays 1
range_min: 0.0001 # published as the header's range_min
max_range: 0.15 # published as the header's range_max
rate_hz: 62.0
A lidar’s rays/angle_min/angle_max are refused here: the layout is the grid’s, and
a world that wants a fan declares a lidar. The endpoint’s role name is range (so the topic
is renamed with topics: {range: ...}), leaving scan to the robot’s scanner.
Pointing it. The site’s +x is the boresight, as for every ray sensor here; a cliff sensor
is a site pitched towards the floor, a proximity sensor a site facing out through the shell. With
the boresight on the floor at a known standoff, a floor return reads the standoff and a hole reads
no_return (+inf by REP 117), which is exactly the comparison a cliff detector makes.
realsense_d415
Sensor plugin: Intel RealSense D415 colour stream, via mujoco.Renderer (GL, offscreen).
realsense_d435
Sensor plugin: Intel RealSense D435(i) colour + depth + point cloud, via mujoco.Renderer.
Config (also inherits camera_common.CameraPlugin’s own fields, undocumented here):
realsense_d435:
depth: true # publish the depth image (default: false)
points: true # publish a PointCloud2 -- implies depth (default: false)
clip_near: 0.28 # m; the D435's minimum-Z. Outside [clip_near, clip_far] reads "no return"
clip_far: 3.0 # m; the datasheet's usable range at default settings
depth_frame_id: camera_depth_optical_frame
depth_encoding: 32FC1 # or 16UC1 -- millimetres, 0 for invalid, as realsense-ros publishes it
References:
https://github.com/IntelRealSense/realsense-ros – topic layout, frame names,
CameraInfoshape.https://www.intelrealsense.com/depth-camera-d435i/ – D435i data sheet. Colour FOV 69.4 x 42.5 deg; DEPTH FOV 87 x 58 deg; min-Z ~0.28 m at 848x480; usable range ~0.3-3 m.
Note on FOV. One MuJoCo camera has one fovy, while a real D435 images colour and depth through
different optics. A model that ships a d435_color camera therefore has to pick: the bundled
d435 model uses the colour FOV, and the OpenMANIPULATOR-X’s eye-in-hand camera uses the depth
FOV (58 deg), because the depth path is the one its experiment consumes. Override fovy in plugin
config to choose per world.
realsense_d455
Sensor plugin: Intel RealSense D455 colour + depth + point cloud, via mujoco.Renderer.
segmentation_camera
Sensor plugin: per-pixel class and instance labels, and the 2D boxes that fall out of them.
Config (in addition to camera_common.CameraPlugin’s):
segmentation_camera:
camera: camera # the MJCF <camera> to render through
classes: # REQUIRED: the experiment's vocabulary, in priority order
- {class_id: 1, name: parcel, bodies: ["graspable_*"]}
- {class_id: 2, name: person, entities: [walker_1]} # whole kinematic subtree
- {class_id: 3, name: shelf_board, geoms: ["board_*"]} # PARTS of one body
instances: false # also publish the instance-id image (16UC1)
detections: true # publish 2D boxes derived from the mask
min_pixels: 16 # an instance with fewer visible pixels is not reported
color: false # the colour stream, off by default here (see below)
rate_hz: 10.0
frame_id: camera_optical_frame
topics: {} # hardwire absolute topics, e.g. {labels: /seg/class_image}
Endpoints. labels (sensor_msgs/Image, mono8) is the class image: each pixel is the
class_id of the class owning the geom that pixel hit, 0 where nothing labelled was hit.
instances (16UC1, off by default) is the instance-id image. detections
(vision_msgs/Detection2DArray) carries one entry per visible instance, with the class in
results[0].hypothesis.class_id and the instance in Detection2D.id. camera_info comes from
the base and describes all of them, since they are one render through one camera.
Instance ids are body ids, so they are stable across frames, across a reset, and across two runs of the same world – a tracker’s association is then a fact about the world rather than about the order this plugin happened to see things in. They are consequently not contiguous, which is why the detections name them rather than leaving a consumer to infer them from the image.
Class ids are the world’s, and 0 is reserved for “nothing labelled here”: a background class with an id of its own would be indistinguishable from an unlabelled geom, and a mask metric computed over it would score the wall. Classes are matched in declaration order and the first match wins, so a world can put a specific body ahead of the glob that would otherwise swallow it.
The colour stream is off by default here (PUBLISHES_COLOR = False). A label camera normally
shares its MJCF <camera> with the RGB sensor that already publishes those pixels, and a second
colour topic off the same optics is a duplicate that costs a serialisation. color: true turns it
on for the case that wants it: a pixel-aligned image/label pair off one camera, which is what a
training set is.
An absent entity is absent here too, and the pass says so rather than inheriting it.
DeleteEntity hides geoms by moving them to roqsim.presence.ABSENT_GEOM_GROUP and zeroing
their alpha (roqsim.presence). Measured, MuJoCo’s default view options already exclude that
group, so a deleted obstacle contributes no label pixels either way – but “a deleted obstacle is not
in the ground truth” is a contract this sensor owes an experiment, not something to leave resting on
a default in another project that no test of ours would notice changing. So the pass is rendered with
the group explicitly masked, and test_segmentation_camera.py pins the outcome. Alpha is the other
half of hiding and is no help here: it is a colour, and an id pass need not respect it.
sensor_coverage_probe
Scene plugin: report the sensor coverage of a world, enabled/disabled from the world YAML.
Config:
sensor_coverage_probe:
sensors: auto # 'auto' = every MuJoCo camera in the world; or an explicit list of
# {type, pos, rpy, config} placements (for lidars, or hypotheticals)
camera_far: 10.0 # detection range assumed for 'auto' cameras (metres; not physics)
target: {k: 1, frac: 0.95}
sample:
volume: true
objects: true
resolution: 0.25
heights: [0.3, 1.0, 1.7]
per_object: 64
out: coverage # output directory (report.json + render); relative to the CWD
render: 3d # 3d | 2d | both | none
palette: coverage # colour encoding: 'coverage' (red 0->green many) | 'density'
# (light 0->dark many, so overlapping-sensor regions read darker)
Rendering needs a GL context, which import roqsim already selected for this machine (set
MUJOCO_GL only to override it). sensors: auto discovers
cameras robustly (their FOV is read straight from the MJCF); include lidars/Livox via the explicit
list form or evaluate them with the CLI.
seyond_robin_w1g
Flags: parallel_safe
Sensor plugin: Seyond Robin W1G forward-facing solid-state 3D lidar via batched ray-casting.
spawn_sensor
Scene plugin: attach a standalone sensor MJCF (mesh + camera/site) into the world at a mount pose.
Config:
- spawn_sensor:
model: d435 # bundled model name, filename, or absolute path
namespace: "" # optional transport scope; the capture plugin's endpoints inherit it
prefix: "" # MJCF name prefix (use distinct prefixes for >1 mount of the model)
pos: [0.0, 0.0, 0.0]
rpy: [0.0, 0.0, 0.0] # mount orientation as roll/pitch/yaw (rad)
motion: static # who owns the mount's pose: static (default; welded, nothing moves
# it), driven (a plugin or scenario places it), physics (the solver)
attach_to: wrist_3_link # OPTIONAL: weld the mount to this body of an ALREADY-SPAWNED robot
# or arm instead of to the world, so it rides what carries it.
# `pos`/`rpy` are then relative to that body. Same spelling as
# `fiducial_marker`'s, and mutually exclusive with `motion:`.
attach_prefix: "ur10e_" # the carrier's MJCF prefix, prepended to `attach_to`/`parent_frame`
parent_frame: cover_link # OPTIONAL, instead of `attach_to`: a body OR a declared frame of
# the carrier to hang from; `pos`/`rpy` are the vendor joint origin
frame_id: laser # the device's scan frame name, filled into its manifest; default: the
# vendor name its manifest's `frame_id:` declares (see below)
show_fov: false # reveal / synthesise the sensor's FOV visualisation (see below)
fov_alpha: 0.25 # per-cone translucency when show_fov is true (0..1); ~0.25 maximises the
# darkness step between single- and multi-sensor overlap
fov_range: <far> # far plane of a synthesised camera frustum (m); default: model manifest
fov_near: <near> # near plane (m); >0 truncates the cone; default: model manifest 'fov:'
fov_rays: [32, 24] # ray grid [horizontal, vertical] used for the occlusion clip
intrinsics: # this UNIT's measured lens, rendered as well as published
{fx: 1330.23, fy: 1329.37, cx: 974.25, cy: 538.99, width: 1920, height: 1080}
name: camera_1 # the entry's label -- a sibling of the ref -- names this mount's
# entity, which a capture plugin's `robot:` then points at
Mounting on something that moves: eye-in-hand and friends. attach_to welds the mount to a
named body of a robot or arm spawned EARLIER in the document, so the sensor rides the flange, the
mast or the chassis and needs no pose of its own to be maintained. pos/rpy are then read
relative to that body, and attach_prefix carries the carrier’s MJCF prefix.
This is what puts a sensor on an arm that does not ship one. An arm whose MODEL carries a camera needs nothing here – its manifest offers the capture plugin and a world switches it on – but that is a property of three models, not of arms, and the alternative for the rest is editing an MJCF. A model edited for one trial travels badly and is invisible to anyone reading the world, whereas a mount declared here is part of the world that states it.
attach_to and motion: are mutually exclusive, and the refusal says why: a mount that rides
a body has its pose from that body, so there is nothing for a motion: answer to own. Placing
such a sensor means moving what carries it.
A device mounted by its carrier. Nested under a robot or arm – in the world’s components:,
or in the robot’s own manifest – a spawn_sensor is that carrier’s device and inherits its
identity, so a robot manifest states a vendor mount and nothing else:
components:
- spawn_sensor: {model: rplidar_a1, parent_frame: shell_link,
pos: [-0.04, 0, 0.098715], rpy: [0, 0, 1.5708], frame_id: rplidar_link}
name: rplidar
attach_prefixdefaults to the carrier’s prefix, andprefixto<attach_prefix><name>_, so two identical scanners on one base never collide and the device’s own components resolve its own bodies (exclude_body: mountis this device’s housing and nothing else).parent_frameis where it hangs: a body of the carrier, or a frame the carrier declares (spawn_robot’sframes:), resolved underattach_prefix. It is required when nested (attach_tostill works and names a body), and the two are mutually exclusive.namespacedefaults to the carrier entity’s, at configure; an explicit one wins, and a capture plugin’stopics:still overrides a topic outright.A nested mount is welded:
motionother thanstaticis refused.
Frames and placeholders. A device manifest may declare a frames: chain relative to its own
bodies (roqsim.frames), first entry hanging from the mount and the scan frame named
{frame_id}, plus the vendor’s default name for that frame as frame_id::
frame_id: laser
components:
- lidar: {site: scan, frame_id: "{frame_id}", exclude_body: mount, emit_static_tf: false}
frames:
- {name: "{frame_id}", parent: mount, pos: [0, 0, 0.03], rpy: [3.14159, 0, 0]}
Two placeholders are filled into every string of the manifest’s component configs and frames::
{frame_id} (this mount’s frame_id) and {parent_frame} (its parent_frame, else
attach_to, else world). Any other is refused. A mount that sets no frame_id takes the
manifest’s frame_id: (roqsim.manifest.manifest_frame_id()); an explicit one wins. A device
whose vendor names no default declares none, and a mount of it that uses {frame_id} without
setting one is refused, as is a second mount on one carrier with the same frame_id. Each frame
becomes a site of the
device; at configure the mount publishes, from the compiled model, parent_frame -> first frame
(only for a welded mount, whose pose that is) and each further frame from its declared parent,
or from the first frame when its parent is a body of the device. Names are bare and scoped by the
mount’s namespace.
Moving a mount after the world is built. motion: is the same three-answer key
spawn_model uses for a prop, and it is what a trial needs to place a sensor at run time – a
viewpoint the campaign varies, a camera a scenario repositions between phases.
motion: static is the default: the mount is welded into the model. A welded body has neither
a mocap slot nor a joint, so nothing can place it at all, and a set_entity_state naming it is
refused rather than silently ignored – which is the answer a trial can act on, where a placement
that quietly did nothing is not.
motion: driven makes the mount a mocap body: no degrees of freedom, so it holds whatever pose
it is given, nothing that touches it shoves it off, and it does not fall. That is what a sensor on
a mast or a ceiling IS, and it is the mode a repositionable sensor wants.
motion: physics adds a free joint, handing the pose to the solver from the next step on. Only
a mount that is meant to fall, be pushed or be carried wants it – ask for it on an overhead
camera and the camera drops to the floor, which is precisely what it means.
model’s <model>.manifest.yaml (e.g. d435.manifest.yaml) ships the matching capture
plugin, injected automatically the same way a robot’s manifest is (see
roqsim.manifest.expand_manifest()); off with default_plugins: false.
A measured lens, per placement. intrinsics: writes fx/fy/cx/cy (pixels, at the
resolution they were measured at) onto the model’s camera as sensorsize + focalpixel +
principalpixel, which is what MuJoCo renders through – so the frame really has an off-centre
principal point and fx != fy, and the capture plugin reads the same numbers back out of the compiled
model (intrinsics_from_model(), path 1). Pixels and
camera_info are then one claim rather than two.
It belongs to the placement and not to the model because a calibration describes one physical unit:
three D435s of one rig measure fx 1330 / 1344 / 1413 with principal points scattered up to 14 px off
centre, so a shared d435.xml has no single lens to carry, and a variant per unit would clone a mesh
in order to hold three numbers. Stating them here leaves one model and gives each mount its own optics.
The resolution is part of the measurement, so width/height are required rather than inferred –
the model camera’s own resolution and the size the capture plugin renders at are both within reach
and neither is necessarily the frame the calibration was made in. The plugin may then render at any
size: the intrinsics scale with it. Distortion is a separate matter – it cannot come out of a projection matrix –
and stays on the capture plugin’s distortion:, which warps the render to match.
FOV visualisation. show_fov: true makes the sensor’s field of view visible. Three paths, tried
in order: (1) if the model has cameras (e.g. the RealSense/Zivid mounts) a translucent view frustum
is synthesised per camera from its fovy/aspect spanning the valid detection band
fov_near..``fov_range``, always clipped against world geometry into a visibility volume that
stops at walls and objects (see Occlusion below); (2) otherwise (a camera-less model), if it ships
FOV geoms – non-colliding, name ending FOV_GEOM_SUFFIX (_fov), hidden at rgba alpha 0 –
they are made translucent (fov_alpha); (3) otherwise, if the model manifest
declares an angular fov: band (h_min/h_max/v_min/v_max – the camera-less lidars,
Mid-360 / Robin W1G) a translucent sector shell is synthesised from those datasheet angles between
radii fov_near..``fov_range``, using the very direction convention the capture plugin casts with (so
the drawn shell matches the rays; a >= 2*pi azimuth span is a full 360deg dome). So every sensor can
show its FOV, whether or not it ships a bundled mesh. show_fov: true on a model with none of a
camera, an _fov geom, or an angular manifest band is a hard error, not a silent no-op.
The valid range is device knowledge: fov_near/fov_range default to the sensor model’s own
fov: {near, far} block in its <model>.manifest.yaml (a world overrides either per placement),
so show_fov: true alone draws the correct band without repeating device specs in every world.
fov_near > 0 sets the near cap of the synthesised visibility volume so the drawn cone starts at the
near plane – the shape then is the valid range band, not a cone implying validity down to distance 0.
(A camera has no physical range, so these are display values.) A model that has a camera (e.g. the
RealSense or Zivid) always synthesises its frustum from that camera, even when it also ships a bundled
_fov envelope (the Zivid does) – the envelope is only revealed for a camera-less model that ships
one. A camera-less lidar (Mid-360, Robin W1G) draws a synthesised angular sector (see below), whose
~0.1 m near cutoff is negligible.
Occlusion (always on for anything synthesised). A synthesised FOV volume is never drawn as an
idealised cone that passes through walls: it is always clipped into a visibility volume that stops at
world geometry. A ray grid is cast from the sensor against the world built so far, each ray clamped at
its first hit, and the drawn mesh spans those hit points (a non-convex userface mesh). This covers
camera frustums (a fov_rays grid from the pinhole) and lidar sectors alike (the sector’s own
azimuth x elevation grid from the scan site). Only a bundled _fov envelope draws un-clipped –
it is a baked mesh and not re-cuttable. The clip is a static build-time snapshot: it raycasts geom
groups 0/1/3 of the partial world, so list scene/floorplan plugins before the sensors (a sensor built
before the walls would see none); dynamic bodies (robots) occlude at their spawn pose; the volume never
updates at runtime. Costs one extra world compile per synthesising sensor at build time.
Overlap reads as darkness. The cones are translucent and MuJoCo alpha-blends them, so where several
sensors’ cones overlap the region accumulates more layers and renders darker – a visual cue for “how
many sensors see here”. fov_alpha defaults to 0.25: the darkness step between single- and
double-coverage is largest near this alpha (a(1-a) is maximal around a=0.3) and vanishes at very low
alpha, which is why a barely-translucent cone makes single and double look identical. Raise it toward
opaque only if you want solid cones; lower it only if the cones obscure the scene – but expect the
overlap cue to weaken. The cones are drawn double-sided so you also see the colour when standing
inside a field of view (MuJoCo back-face culls, so a single-sided shell vanishes from within); this
means a lone cone shows two layers (its near and far walls) and already reads as tinted, so overlap is
now a further darkening rather than the sole cue. This is still qualitative and view-dependent; for a
quantitative, unambiguous per-area count use the coverage density render (sensor_coverage_probe /
roqsim sensors coverage with palette: density).
(Bundled _fov geoms are identified by name, not geom group, on purpose, and synthesised frustums
live in group FOV_GEOM_GROUP (2): the MuJoCo 3.x offscreen renderer drops large geoms in group
4/5 once a scene has several geoms, so an FOV volume must live in a normally-rendered group.)
zivid
Sensor plugin: Zivid 3 XL250 structured-light 3D camera, via mujoco.Renderer (GL, offscreen).
roqsim_walker
walker
Scene + controller plugin: a kinematic pedestrian that patrols a route or is driven to goals.
Config:
walker:
walker: MaleVisitorWalk # blueprint folder under models/people/ (required)
namespace: "" # transport scope for the goal endpoint
outfit: B # clothing variant: a letter, or {pants: C, jacket: A}
skin: true # false -> capsule visuals instead of the character mesh
speed: 1.2 # m/s; past ~1.7 the run clip blends in
pos: [0.0, 0.0] # spawn, used when `waypoints` is empty (goal-driven only)
waypoints: # patrol route; the walker starts at waypoints[0]
- [-2.5, -2.5]
- [ 2.5, -2.5, [3, 6]] # optional per-waypoint dwell: secs, or [lo, hi] random pause
loop: true # cycle the patrol forever
dwell: 0.0 # default dwell applied to every waypoint
arrival_radius: 0.25
avoidance: false # true -> the shared local model gives way for it. A walker
# steers or does nothing; it has never looked ahead, so this
# never makes it stop. Write a `navigator` with
# `avoidance: {stop: true}` for one that should.
robot_body: base_link # body the walker yields to (default: the robot entity's base)
robot_radius: 0.25
goal_endpoint: true # false -> patrol only; declares no goal endpoint, so a bridge needs
# no handler for it (a patrol-only world drops the nav2_msgs dep)
action_name: navigate_through_poses # relative action name of the goal endpoint
orca: {neighbor_dist: 4.0, time_horizon: 3.0, radius: 0.26, max_speed: 1.6}
planner: {inflation_radius: 0.3, waypoint_radius: 0.3}
recovery: {stuck_time: 1.5, backup_time: 0.5, max_recovery: 4}
motion: {walk: /abs/walk.npz} # override a resolved locomotion clip
Several walker plugins may coexist: they share one
WalkerController (so ORCA sees every walker, the robot and any mocap props in one simulation). The
first instance to initialise owns the per-step tick; the rest only contribute their spec.
ROS 2 bridge (roqsim_ros_bridge)¶
The bridge plugins live in the ROS 2 workspace (ros2_ws/), which is built with colcon rather
than pip-installed into the docs venv, so they are listed here by hand; they appear in the generated
catalog above once ROS is sourced and the workspace is on the path.
Plugin |
Purpose and key config |
|---|---|
|
Generic bridge: wires every endpoint declared on |
|
|
Note
A publish rate lands on the physics grid. A gate is tested once per physics step, so the rates
a world can hold are exactly physics_rate / k for integer k. Every rate this bridge gates
on — an endpoint’s own rate_hz, a backend hint, a rates: override, clock_rate_hz, a
merged joint_states — is snapped to the nearest of those when it is bound, and the move is
logged in proportion to its size (silent below 0.1 %, a note below 1 %, a warning naming the nearby
achievable rates above it). Nearest, so a snapped rate may come out slightly FASTER than asked: a
rate meant as a ceiling has to be one the world can hold.
At the common timestep: 0.002 that makes 10 Hz and 25 Hz exact and 30 Hz a 500/17 —
29.41 Hz. Where a result turns on that difference there are two ways to keep the number: ask for a
rate on the grid, or step the world at a whole multiple of the rate you need (30 Hz is exact at
510 Hz, i.e. timestep: 0.0019607843137254902), which is the one to reach for when the rate came
from a paper. Write such a timestep out in full: the step rate is recovered from the float, and a
rounded one is a different grid. Both numbers,
requested and realised, reach a recording’s provenance as endpoint_rates, so a run states what
it published at rather than what it was asked for.
Note
When to set merged_joint_states. A robot with several controllers declares one
joint_states endpoint per controller (arm_controller does), and where those are scoped
apart by namespace nothing publishes the plain topic a robot_state_publisher or MoveIt’s
planning-scene monitor over their combined robot_description subscribes to — so that
description gets no TF and move_group never learns a current state, silently: it still logs
that planning is ready. The bridge closes that with one extra merged publisher per group, carrying
every member endpoint’s names/positions/velocities/efforts in registration order, alongside each
endpoint’s own topic, which is unchanged.
Which endpoints form a group is a fact about the stack — how many robot_descriptions it
runs — that the world cannot answer, so it is declared:
components:
- ros2_bridge: {} # "auto" (default): group by entity
- ros2_bridge: {merged_joint_states: true} # one /joint_states across all entities
- ros2_bridge: {merged_joint_states: false} # never merge
- ros2_bridge: # groups stated outright
merged_joint_states:
- {topic: /cell/joint_states, owners: [ur10e_left, ur10e_right]}
"auto" merges each entity’s controllers into the scope they share (dual/left +
dual/right → /dual/joint_states) and keeps separate robots separate — right without any
declaration, because one entity is one physical robot however the stack is arranged. Two arms that
are two entities but one description (what arm_controller’s joint_prefix exists for) need
true or an explicit group; auto warns when more than one entity publishes joint states here
so that case is not silent. Controllers already sharing one topic get no merged publisher: they
meet on the wire, and both robot_state_publisher and MoveIt accumulate partial joint states.
Note
When to set tf_namespace. By default the bridge publishes TF on the global /tf, which is
right for a single robot owning the tree. But a namespaced Nav2 bringup follows the multi-robot
convention of remapping /tf -> tf, so its TF lives on /<robot>/tf — and tooling that
assumes that convention (notably scenario_execution’s NamespacedTransformListener, which
subscribes <namespace>/tf) then never sees the bridge’s odom -> base_link. The symptom is a
stack that looks healthy while a transform lookup hangs forever, e.g. scenario_execution’s
init_nav2 stuck on “Waiting for transform map -> base_link”.
Set tf_namespace to the robot’s namespace so the bridge joins that tree:
components:
- ros2_bridge: {tf_namespace: a200_0000} # -> /a200_0000/tf, /a200_0000/tf_static
It must match the namespace the consuming stack uses, and it is all or nothing: every publisher
of a link in the chain has to agree on one topic. Note this scopes the TF topics only — frame ids
(map, odom, base_link) are untouched; namespace those with frame_prefix if needed.
Setting the bridge’s namespace does not do this: tf2_ros’s broadcasters hardwire the
absolute /tf, which is exactly why this option exists.
Transport plugins and the scene-only consumers¶
A bridge publishes what the other plugins built; it adds nothing to the model. Such a plugin sets the
class attribute transport_only = True — BridgeBase does, so every transport inherits it,
including one you write — and the tools that want the scene rather than a running simulation drop it
before building: roqsim render, the scene-review window (roqsim_scene_builder), and roqsim export
web/urdf/srdf/moveit.
That is what makes a *_ros world renderable without ROS. Those same tools also skip a plugin
whose ref does not resolve at all (the bridge is registered by a colcon package, so it is absent from a
pip-only environment) and say so on stderr, naming it — geometry is unaffected either way, but the
other way to get there is a misspelt ref.
roqsim sim keeps both by default: a simulation genuinely needs its transport, so an unresolvable
ros2_bridge there stays the loud failure it should be (source ros2_ws/install/setup.bash).
--no-communication is the deliberate exception, for opening a *_ros world in the viewer on a
machine with no middleware installed:
roqsim sim depot_nav2.yaml --no-communication
It is not the render path’s rule with a flag on it. Two differences, both because this one is a simulation:
Only a plugin that can be identified as transport goes — its class declares
transport_only, or its ref is one of the bridgesroqsimnames for--ros. An unresolvable ref that is neither stays in the world and still fails the build, because there it is a misspelt plugin that would have changed the run.The run then communicates with nothing, and says so on every start: nothing published (no
/clock, no TF, no sensor topics, no odometry), nothing received (no/cmd_vel, no goals, no services). An external stack sees a simulator that was never started, and a robot it would have driven stands still. Use it to look at a world, never to run its experiment.
--no-communication and --ros/--tf-namespace/--sim-control are refused together rather
than silently ordered.
Nothing is required to know the flag exists, either. When a world’s only unresolvable plugins are
its bridges, the failure says so and names both ways out; a bare “unknown plugin ros2_bridge”
sends the reader hunting for a typo that is not there.
Model plugin manifests¶
A robot’s controller and sensors are intrinsic to the model, not the world, so they ship with
the model in a <model>.manifest.yaml manifest next to its MJCF. A spawn plugin pulls them in
automatically, so a world just spawns the robot – and the same applies to a device with more than
one sensor in it: the bundled d435 is a D435i, so its manifest carries the imu component with
the inertial module’s own extrinsic, and spawn_sensor: {model: d435} yields both camera/imu
and the colour stream. A world that models the IMU-less D435 sets enabled: false on that
component (which is also how “does this device have an IMU” becomes a campaign factor):
components:
- spawn_robot:
model: turtlebot4
pose: {position: {x: 0, y: 0}} # diff_drive + lidar + oakd_camera come with it
- ros2_bridge: {}
An arm’s manifest can also carry an eye-in-hand sensor: a camera among the arm’s components rides
its flange, exactly as one among a mobile base’s components rides the base – there is no per-family
mount key to choose between. The
open_manipulator_x model ships the MJCF d435_color camera at ROBOTIS’s own RealSense mount pose
but deliberately leaves realsense_d435 OUT of its manifest – the arm provides the mount, the world
decides whether anything renders from it, at what rate, and whether it reprojects to a point cloud.
The same applies to spawn_arm (roqsim_manipulation): {model: ur10e} pulls in that
arm’s arm_controller; and to spawn_sensor (roqsim_sensors): {model: d435} pulls
in its realsense_d435 capture plugin.
An eye-in-hand camera, or any sensor that rides something that moves, is
spawn_sensor: {attach_to: <body>, attach_prefix: <carrier prefix>} – the same spelling
fiducial_marker uses, welding the mount to a body of a robot or arm declared earlier in the
document, with pos/rpy then read relative to that body – which is what a datasheet or a
CAD drawing states, and what a world-frame pose cannot be once the carrier moves. An arm whose own
MODEL ships a camera needs none of this (three do, and their manifests offer the capture plugin);
this is how every other arm gets one, without a per-trial MJCF edit that travels badly and is
invisible to anyone reading the world. It is mutually exclusive with motion:: a mount that
rides a body has its pose from that body, so moving the sensor means moving what carries it.
A device a robot ships with is a spawn_sensor nested among the robot’s components, usually
in the robot’s own manifest. It is mounted at the vendor’s parent_frame (a body, or a link the
robot declares in its manifest’s frames:) with the vendor joint origin as pos/rpy. It
inherits the robot’s prefix (its own is <robot prefix><name>_) and namespace. Its components
are addressed <robot>.<name>.<plugin>, and a robot manifest overrides one by nesting it under
the mount. The mount publishes the device’s frame chain as static TF. Its scan frame is the mount’s frame_id,
else the vendor default the device manifest declares as frame_id:; a device whose vendor names
none needs one on every mount. The spawn_sensor and
spawn_robot entries below have the keys.
A standalone mount takes the same motion: key a prop does, with the same three answers, and it
is what a trial needs to place a sensor at run time – a viewpoint the campaign varies, a camera a
scenario repositions between phases. static is the default and welds the mount into the model,
so nothing can move it and a placement naming it is refused rather than ignored. driven makes
it a mocap body: it holds the pose it is given, nothing that touches it shoves it off, and – the
part a free joint gets wrong – it does not fall. That is what a sensor on a mast or a ceiling is.
physics hands the pose to the solver, which for an overhead camera means the camera drops to
the floor; ask for it only when the mount is meant to fall, be pushed or be carried.
Override a default: declare the same plugin inside that robot’s/arm’s
components:block — your entry wins (e.g. addtest_cmd/test_target, or changelidarrays). Nothing is duplicated. Matching is on the label: the entry’sname:, else its plugin ref, among that owner’s components. There is no entity key to name — an entry belongs to the entity whose block it sits in — which is what makes an override an override: a controller that named its robot instead could sit anywhere, and one declared outside the block would run alongside the manifest default rather than replacing it, leaving two controllers on the same actuators and a config with no visible effect. The override is partial: keys you do not mention keep the model’s manifest values, so adding atest_cmddoes not cost you the model’s wheel geometry or actuator names. Per key, what the world says wins; nested values (e.g.topics:) replace the manifest’s mapping outright rather than being deep-merged. To start from the plugin’s own defaults instead of the model’s, opt out withdefault_plugins: falseand declare the plugin fully.Switch one off with
enabled: false– as a sibling in the document, or from an override:roqsim sim world.yaml --set components.robot.oakd_camera.enabled=false
The component is not deleted: it stays addressable, stays in the run’s record saying it was turned off, and a later override can turn it back on. Disabling an entry disables everything it owns.
Opt out entirely: set
default_plugins: falseon thespawn_*config.Derive one manifest from another with
extends:.unitree_g1_dex1is aunitree_g1plus hands, and says exactly that – rather than repeating the base’s locomotion and lidar blocks as a second copy for someone to keep in step by hand:extends: unitree_g1 # a roqsim.models ref, or a path beside this manifest components: - arm_controller: {...} name: left_arm_controller
What is inherited is components, not geometry: a derived model keeps its own MJCF. The base’s components come first, so a derived entry with the same label is the one that runs. Cycles raise. A manifest may not carry
sim:at all – and neither may anything it extends – because a model is a component of a world, and the run’s seed, pacing and contact overrides belong to the world being run rather than to something included in it.Add a manifest for your own model: drop a
<model>.manifest.yamlbeside the MJCF listing the plugins (same shape as a world’scomponents:); the entity name is filled in for you, and each injected plugin also inherits the spawn’sprefix— so a build-time plugin that welds geometry onto a spawned body (e.g.fiducial_markerwithattach_to: wrist_3_link) resolves the prefixed body name without the world having to know it.ur10e_custom.manifest.yamlships anarm_controller, an eye-in-handrealsense_d415, and afiducial_markeron the wrist.
A model: name is resolved across all installed packages, so a world can spawn a model that
lives in a different package from the spawn plugin. Register a package’s models once:
# pyproject.toml
[project.entry-points."roqsim.models"]
roqsim_assets = "roqsim_assets.models" # module exposing MODELS_DIR
Then {model: conveyor} finds it by bare name (or {model: roqsim_assets:conveyor}
to be explicit). A model that reuses another package’s meshes need not copy them — add an
assets: <provider> key to its <model>.manifest.yaml to borrow that provider’s mesh/texture
dirs (e.g. assets: roqsim_manipulation_assets for a custom arm variant that keeps the stock meshes).
# turtlebot4.manifest.yaml — shipped next to turtlebot4.xml (abridged)
components:
- diff_drive: {max_linear_vel: 0.46, max_angular_vel: 1.9, wheel_accel_limit: 0.9,
cmd_vel_timeout: 0.5, odom_rate_hz: 62.0, publish_joint_states: false}
- joint_state_publisher: {rate_hz: 62} # every joint, wheels and suspension, in one message
- spawn_sensor: # the RPLIDAR A1 device model, at the vendor joint origin
model: rplidar_a1
parent_frame: shell_link
pos: [-0.04, 0.0, 0.098715]
rpy: [0.0, 0.0, 1.5707963267948966]
frame_id: rplidar_link
name: rplidar
- oakd_camera: # renders: needs a GL backend (roqsim selects one on import)
camera: oakd_rgb
topics: {image: oakd/rgb/preview/image_raw, ...} # the TurtleBot 4's names
- bumper: {geoms: [body_collision], zones: {bump_front_center: [-0.314, 0.314], ...}}
- range_sensor: {site: cliff_front_left, max_range: 0.15, lazy: true, ...} # x4 cliff, x7 IR
name: cliff_front_left
- imu: {pos: [0.050613, 0.043673, 0.0844], topic: imu, rate_hz: 62}
- ground_truth_pose: {site: mouse, relative_to: base, lazy: true, ...}
name: gt_mouse
The second half is the Create 3 base’s own sensor surface – bumper zones, cliff and IR proximity
sensors, IMU, and the ground-truth streams its vendor’s simulator adapter reads – declared on the
model because the reference robot carries them, on the topic names that adapter’s shipped parameter
files expect, and lazy so a world that never launches that stack publishes none of it. See
The Create 3 / TurtleBot 4 stack.
Selecting a policy¶
A policy-driven controller can be pointed at a different checkpoint with policy::
- spawn_robot: {model: unitree_g1}
name: robot
components:
- g1_locomotion: {policy: g1_stand, station_keeping: true}
policy: names a directory under the family’s policy/ holding <name>.spec.yaml and its
checkpoint (an absolute or relative path to a spec also works, for an out-of-tree policy). The spec
declares the observation layout, the joints the policy commands, the joints it merely observes, the
control gains, and the envelope it was trained for – so adding a policy is dropping in a directory, not
editing Python. Omit the key and the controller uses its bundled default, unchanged.
Note the envelope is recorded, not enforced: outside it a policy does not fail, it balances worse. Check
it against what a world actually contains (spec.envelope.check_payload(mass)) rather than trusting a
port log to be read.
What a robot carries¶
payload adds a carried mass to one body of an entity – a load is a property of the trial, not
of a robot family, so it is stated in the world and swept like any other factor:
- spawn_robot: {model: turtlebot4}
name: robot
components:
- payload: {mass: 2.5} # kg, on the entity's root body
It is a point mass at the body’s centre of mass: mass adds, and a point mass contributes no
inertia about its own centre. An offset is refused rather than approximated – an offset payload
shifts the centre of mass and adds a parallel-axis term, which is a different body, not a heavier
one. mass: 0 leaves the model untouched, so the unloaded cell of a sweep is identical to a world
that never declared a payload. Load a body other than the root with body: (the entity’s spawn
prefix is applied for you); which robot is loaded is the entry this one sits in, so there is nothing
to point with.
Where thrust is bounded this is the flight envelope rather than a detail: see
roqsim_aerial/README.md, which measures a quadrotor’s hover collapsing at a thrust-to-weight
ratio of 1.
A range sensor that is not a scanner¶
The small range sensors a base carries – IR proximity, ToF, ultrasonic, a downward cliff sensor –
illuminate a narrow cone, and range_sensor models that cone as a small grid of rays from a
site, published as one LaserScan with the rows concatenated:
- spawn_robot: {model: turtlebot4}
name: robot
components:
- range_sensor: {site: cliff_front_left, range_min: 0.0001, max_range: 0.15, rate_hz: 62}
name: cliff_front_left # 1 ray: a cliff sensor
- range_sensor: {site: ir_front, h_rays: 5, v_rays: 5, h_fov: 0.1745, v_fov: 0.1745,
range_min: 0.025, max_range: 0.2, rate_hz: 62}
name: ir_front # 5x5 rays: an IR proximity sensor
It publishes returns, not verdicts. A cliff detector asks whether the nearest return is farther than the floor should be; a proximity sensor turns the nearest return into an intensity. Both are the consumer’s rule, applied to the grid this publishes, so a single sensor plugin serves every such device and nothing in the simulator encodes what a cliff is.
The site’s
+xis the boresight, as for every ray sensor here. A cliff sensor is a site pitched at the floor: with its boresight on the floor at a known standoff, a floor return reads the standoff and a hole reads+inf(REP 117’s no return), which is exactly the comparison a cliff detector makes.The rest is
lidar‘s. Detection limits,too_close/no_return, the noise model, the fault switch and the static mount TF are inherited rather than restated; a fan’srays/angle_*keys are refused, because the layout is the grid’s. The endpoint’s role isrange(renamed withtopics: {range: ...}), leavingscanto the robot’s scanner.
Injecting a physical fault¶
Some trials need something to go wrong at a chosen instant: a gripper that stops holding halfway
through a carry, a wheel that loses traction, a payload that changes under load. model_override
makes that a property of the world rather than something scripted into whatever drives the robot —
name a model field, name the objects, name the target value, and let the scenario switch it on:
- model_override:
overrides:
- field: geom_friction
select: [gripper_right_left_pad1, gripper_right_left_pad2,
gripper_right_right_pad1, gripper_right_right_pad2]
to: 0.0
name: grip_fault
and, for a wheel that keeps its grip only until it does not:
- model_override:
overrides: [{field: geom_friction, select: [wheel_left_tyre, wheel_right_tyre], to: 0.02}]
name: traction_fault
The plugin is inert until fired, so adding it changes nothing about a nominal run.
Firing it is a service call, and the reply is the point. Over ROS 2 the inbound endpoint is a
std_srvs/SetBool, so a scenario does:
service_call(service_name: '/grip_fault/override', service_type: 'std_srvs.srv.SetBool',
data: '{\"data\": true}', response_variable: 'fault')
and can fail the trial when the reply says the fault did not land — a topic publish could only be
followed by hoping. In a ROS-free stepped run the same switch is
ctx.blackboard.require("model_override:grip_fault").set_active(True), which is what an .osc
action calls. Severity is not on the wire: it is the configured to: value, so sweeping how
slippery the pads get is an ordinary experiment factor rather than a runtime message.
Three things to know before writing one:
Which side of a contact you select decides whether anything happens. MuJoCo takes a contact’s friction from the geom with the higher
priority, and at equal priority the element-wise maximum of the two. So a default-priority wheel written to0.0against a floor declaring1.0changes nothing, and overriding a carton held by pads that carrypriority="1"changes nothing either — select the geoms that own the contact, or select both sides. Useroqsim scenes describe <world> --overridable 'gripper_right*'to see the names, their current friction and their priority, rather than guessing.A fault that did nothing says so. One step after the change the plugin compares the applied contact against what it asked for and reports
landed,no_effect(a warning, and a failed service reply) oruntested— the last meaning nothing was touching the selected geoms, which is not a failure. It is published asoverride_verifiedtoo, because a service call leaves no trace in a rosbag andmjModelis in neither the bag nor the state recording.A reset returns the world to the configured state, exactly, from the values read at startup. Without that, repetition 2 of a sweep cell would start already faulted and report a plausible wrong number —
Engine.resetresetsMjDataand never touchesMjModel.
Not every model value can be written at runtime; geom_size and the opt.* globals are refused
by name, with the reason and with what to use instead (for the globals, sim.contact_override,
which is global and applies before compile — a different tool for a different job). The full
allowlist, with what each field does and how it can silently do nothing, is in the plugin’s own
Config:: block above and in roqsim scenes describe’s overridable.fields. Details and the
measurements behind each row: architecture §9.2.
Perception ground truth¶
Two plugins answer two different questions about the same objects, and an experiment usually wants
one of them, not both. object_detector reports an object’s POSE in the robot’s frame – what a
manipulation stack consumes, and what a real pose estimator would output. segmentation_camera
reports which PIXELS an object covers – what an IoU, a mask AP or a training set is computed from:
components:
- spawn_robot: {model: turtlebot4}
name: robot
components:
- segmentation_camera:
camera: oakd_rgb
classes:
- {class_id: 1, name: parcel, bodies: ["graspable_*"]}
- {class_id: 2, name: person, entities: [walker_1]}
- {class_id: 3, name: shelf_board, geoms: ["board_*"]}
instances: true
That publishes a mono8 class image, a 16UC1 instance image and a
vision_msgs/Detection2DArray of tight boxes, all off one render through the named MJCF camera.
Three selectors, because a label does not always follow a body. bodies and entities
label everything a body or a whole kinematic subtree carries – what a parcel or a pedestrian is.
geoms labels named geoms directly, which is what the parts of a procedural prop are: a
shelf compiles to one body carrying its boards and its legs as separate geoms, and a
workbench its top and its frame, so a body-granular vocabulary can only call the whole thing
one class. An experiment measuring whether a mapper separates a surface from its support needs
them named apart. roqsim scenes describe <world> --overridable '*' lists the geom names a
world carries, which is where a prop’s parts appear — the same listing model_override’s
select: is written against, so this is not a second naming scheme to learn. Selectors compose
within a class and across classes, first match wins in declaration order, and a geom takes the
instance of the body it sits in — so two boards of one rack are one instance of shelf_board,
which is what an instance image of a rack should say.
Three properties are worth knowing before a metric is built on it. Boxes measure the visible
extent, because that is the only extent derivable from a mask and the only one a detector could have
produced – an occluded object shrinks and, below min_pixels, is not reported at all. Instance ids
are body ids, so they are stable across frames and runs rather than depending on the order things
were seen in; they are correspondingly not contiguous, which is why the detections name them.
And class id 0 is background: a declared class with id 0 would be indistinguishable from an
unlabelled geom, so it is refused at load.
What a run cost¶
energy_monitor is the third observation plugin, beside the two that watch geometry: it meters the
actuators that move a robot and integrates their mechanical power, so “energy per metre”, “how far on
a charge” and “which planner is cheaper” become numbers a run produces rather than numbers an
analysis fits:
components:
- spawn_robot: {model: turtlebot4}
name: robot
components:
- energy_monitor: {efficiency: 0.72, idle_w: 8.0, capacity_wh: 26.0, voltage: 14.4}
The split between measurement and assumption is explicit, and the defaults assume nothing.
force * velocity per actuator is measured, every step, at the physics rate – reconstructed from
a recording afterwards it would be sampled at the recording’s rate and need a drivetrain model to
turn poses back into effort, which is a fitted constant between the simulator and the result.
efficiency, idle_w, resistive_w_per_nm2 and regenerative are the platform’s own
numbers; unset, the plugin reports mechanical work and nothing else. A state of charge exists only
where a capacity_wh was given – without one the fraction is reported as unknown rather than as
a full battery.
The per-actuator split is what makes the number usable on an arm. Each actuator’s force *
velocity is sorted into driving and driven before the sum, so one joint descending under gravity
cannot pay for another one lifting – netted first, an arm changing pose reports as free. Negative
mechanical power is dropped rather than billed, because a non-regenerative drive dissipates the
load’s energy instead of drawing it from the pack:
components:
- spawn_arm: {model: ur5e}
name: arm
components:
- energy_monitor: {efficiency: 0.85, idle_w: 35.0, resistive_w_per_nm2: 0.012}
The torque metered is the one a real drive supplies: the actuator’s own force plus its share of
the gravity-compensation force. MuJoCo carries a compensated arm’s weight outside the actuator, so
actuator_force reads exactly zero on a joint holding a payload against gravity – and since every
position- and impedance-driven arm is compensated, metering it alone would report an arm that is free
to hold a load up and free to lift one. It is the same quantity arm_controller reports as a
joint’s effort, and for the same reason. Under control: effort, where nothing is compensated
because supplying the gravity term is the controller’s job, the share is zero and nothing changes.
resistive_w_per_nm2 is the term a manipulator needs and a mobile base can usually ignore: the
k in k * tau^2, the winding loss. A motor torque is a motor current, so it is the one term
that survives a standstill – an arm holding a payload against gravity has exactly zero mechanical
power and still dissipates I^2 R, which on a slow trial is often the larger part of the bill. One
number covers a machine whose motors are one class; a mapping of actuator name to coefficient gives a
shoulder and a wrist their own, and an actuator the mapping omits contributes nothing. Beside the
joules the report carries torque_integral_nms, the integral of the summed absolute actuator
forces – the effort metric a paper falls back on where its platform’s electrical constants are not
published, accumulated here at the physics rate rather than at whatever rate /joint_states was
published at.
Which actuators count is derived, not configured: every actuator driving a body of the robot’s kinematic subtree, so a world’s other machines are not on this robot’s bill and a model that gains a joint does not need the world edited. An entity with no actuators is an error, because a meter reading zero forever looks exactly like a robot that costs nothing to drive.
It reports; it does not intervene. A depleted battery latches and is published; the robot keeps
driving. Ending a trial is the experiment’s decision, the same line contact_monitor draws about a
collision – a scenario reads the endpoint and stops the run itself.
Ground that is not flat¶
Without it everything a robot can stand on here is a plane, while four of the ported platforms –
Spot, the Husky, the Jackal, the Warthog – are outdoor machines whose papers are about what happens
when it is not. heightfield is MuJoCo’s own height field wired into a world:
components:
- heightfield: {size: [40, 40], height: 2.5, resolution: 128, seed: 3}
- spawn_robot: {model: husky_a200, pose: {position: {x: 0, y: 0}}}
name: robot
It provides the ground (provides_world), so sim.world is not also built underneath it – a
floor through the hills is what that would mean. Elevation comes from one of three places and is
normalised the same way regardless: generated fractal noise (reproducible from seed, so two cells
of a campaign share their hills), a .npy array, or a greyscale .png/.tif read at its own
bit depth. A GeoTIFF is converted by the tools that own reprojection –
gdal_translate -ot UInt16 -scale dem.tif dem.png – rather than by a simulator pretending to know
about coordinate systems.
The vertical scale is stated in metres (height:), never inferred from the file: an image has no
unit, and a guessed one would put a made-up gradient under every result. It is also the natural
campaign factor – “the same hills, half as steep” is one number.
roqsim sim roqsim_mobile:warthog_terrain_demo is this with a robot on it: 2.5 m of relief over
24 m, which the skid-steer climbs at up to 23 deg of pitch. It also shows the one coupling a terrain
world has to get right – spawn_robot keeps the model’s rest height, measured against flat ground
at z=0, so the spawn belongs at the terrain’s lowest sample or the robot starts inside a hill. The
demo picks a seed whose minimum is the grid centre and pins that in a test, because a seed changed
without moving the spawn looks like a world that simply throws its robot.
Contact is against the field’s triangles, so the sample spacing is the resolution of every wheel and
foot interaction: 128 samples over 40 m is a 31 cm grid, which a 10 cm wheel rides as facets. Raise
resolution for a small rough patch rather than a large smooth one; the cost is quadratic and buys
nothing where the ground is flat.
Documenting a plugin’s config¶
A plugin’s keys are written in a Config:: block in its module docstring. Two readers parse
that block and nothing else: roqsim plugins describe (and the MCP tool over it), and the page
you are reading – every plugin listed above renders from it. A block written in a shape they do
not read is a plugin that publicly takes no configuration:
"""Sensor plugin: 2D lidar via batched ray-casting.
Config::
lidar:
rays: 360
angle_min: 0.0 # trailing text after # is the key's doc, and may
# wrap onto a bare comment line like this one
sample:
resolution: 0.25 # nested keys are published as sample.resolution
"""
Four things the readers rely on:
The block opens with a line beginning
Configand ending::. Qualify it freely (“Config (in addition tolidar_common’s …)::”) and let the qualifier wrap over up to three lines – but the::must arrive, or there is no block.One key per line, as
name: example. The example is documentation, not a parsed value.Nest as the world YAML nests. A key opening a mapping is published under the dotted path a world writes it at, so the block and the YAML have one shape rather than two.
Put it on the MODULE, not the class. A class docstring that merely points at the module (“See the module docstring.”) is ignored in favour of the module’s, but a class that documents different keys than its module will publish its own.
Prefer the declaration below wherever the keys have types, bounds or units worth checking: prose cannot be validated, so it drifts, and this block is read by a caller writing a world.
Declaring a plugin’s config¶
Every plugin validates its own config. A plugin may also declare that config, so the checks and the published description come from one place:
from roqsim.schema import Field
class PayloadPlugin(Plugin):
CONFIG_SCHEMA = {
"mass": Field(float, required=True, minimum=0.0, unit="kg", doc="added to the body's own"),
"body": Field(str, default="", static=True, doc="body to load (default: the root body)"),
}
def validate_config(self, config):
return [...] # whatever only this plugin knows
Declaring it is what enforces it. The types, ranges, required keys and – with STRICT_KEYS
– unknown keys are checked when the world’s plugins are built, beside whatever validate_config
adds; there is no call to remember. A schema the catalog publishes and nothing checks would be
prose with a type annotation.
roqsim plugins describe payload then carries a schema block beside the docstring-parsed
parameters: the same keys with their types, defaults, units and bounds. That is what a caller
generating a world needs and what prose cannot give it – and unlike a comment it cannot drift from
behaviour, because validation runs on it.
It is opt-in: a plugin without a declaration is unchecked by it, and one with a declaration still
owns validate_config. The schema covers what is the same everywhere; a rule only one plugin has
(two lists the same length, a file that must exist, a cut that must be finite) stays where it
belongs rather than growing the shared vocabulary.
STRICT_KEYS = True adds the check nothing else can do – an unknown key is a typo, and
above_Z silently leaving the ceiling standing looks exactly like the plugin not working. It is
opt-in because a component’s config carries keys the world’s author did not write (a manifest’s
prefix, a spawn’s entity); those are known centrally, and a plugin says so once its own list is
complete.
Degrading a sensor mid-run¶
model_override changes the physics. Its counterpart changes what a sensor reports — a lidar
that starts dropping returns halfway down a corridor, a scanner whose noise triples when it fogs up.
That perturbation belongs in the sensor’s own config (§9.1), not in a model field, so it is written
there and needs no plugin of its own:
components:
- spawn_robot: {model: turtlebot4}
name: robot
components:
- spawn_sensor: {} # the manifest's RPLIDAR mount, by its label
name: rplidar
components:
- lidar:
range_stddev: 0.01
dropout_percent: 2.0
fault: {dropout_percent: 60.0, range_stddev: 0.35} # held while active
The sensor is nominal until the fault is switched on, so adding a fault: block changes nothing
about a run that never fires it. A scenario switches it by the sensor’s address:
set_sensor_override(instance: 'robot.rplidar.lidar', active: true)
wait elapsed(8s)
set_sensor_override(instance: 'robot.rplidar.lidar', active: false)
and over ROS 2 the same switch is a std_srvs/SetBool at robot/rplidar/lidar/override, with
robot/rplidar/lidar/override_state and .../override_verified reporting back. The address is the dotted
path of labels with dots as slashes, because a dot is not legal in a ROS name; a bare lidar would
name neither of a robot’s two lidars.
It mirrors model_override in the three ways that matter, rather than re-deciding them:
Severity is configured, not sent. The
fault:values are ordinary config, so sweeping how bad the fault gets iscomponents.robot.rplidar.lidar.fault.dropout_percent— an experiment factor, deterministic per cell and in the run’s provenance. One bit crosses the wire.The world never decides when. No time trigger, no condition trigger; a fault’s timing is the experiment’s independent variable.
A fault that changed nothing is reported as such. Applying a block whose values already equal the nominal reports
no_effect, andset_sensor_override’srequire_landedfails the trial on it — an unfaulted outcome wearing a faulted label is worse than a failed run. A restore has nothing to verify and reportsuntested.
Only keys the sensor reads per frame may be written. Each sensor declares its own allowlist; on
the ray-casting sensors that is range_stddev, range_stddev_relative,
range_stddev_relative_from, range_resolution, dropout_percent, max_range, range_min
and rate_hz, plus detection_min and detection_max on the 2D lidar, and on the imu it is the noise, the biases and orientation – so a trial can
drop the attitude channel or triple the rate noise partway through, which is what an IMU failure
looks like to a localisation filter. Everything else is refused at load, by name, with the reason — rays,
angle_min and angle_max because they change a LaserScan’s length or the bearing its
indices mean, and site/frame_id/exclude_body because they are consumed once at
configure. This is the geom_size rule from the physics channel: a value that writes fine,
takes effect nowhere, and reads back as though it had is worse than one that is refused.
A fault does not survive reset: one process serves several trials, and a fault leaking into the
next would quietly turn a nominal control cell into a degraded one.
Bases: three geometries, one interface¶
diff_drive, omni_drive and ackermann_drive publish the same endpoints – cmd_vel in,
odom and joint_states out – so a stack does not know which it is driving until it asks for
something the geometry cannot do. That is the point of having the third one: a car cannot turn in
place, and cmd_vel with v = 0 and a yaw rate moves it nowhere at all. A planner that emits
that command is a planner that would not move the real vehicle, and approximating a car with a
differential base and a small angular limit hides exactly the failure the experiment is looking for.
What a real base offers its stack. Three keys on diff_drive (and omni_drive) are the
base driver’s behaviour rather than the kinematics’, and a model that states its robot’s interface
states them in its manifest – the TurtleBot 4’s does:
- diff_drive:
cmd_vel_timeout: 0.5 # the watchdog every base driver has; 0 (default) holds a command
odom_rate_hz: 62.0 # the rate odom (with its TF) and joint_states are published at
publish_joint_states: false # when a joint_state_publisher covers the whole robot
cmd_vel_timeout is off by the plugin’s default because an in-process driver sets a twist once
and steps; a stack republishes at a rate and expects a dead publisher to leave a stationary robot,
and the stop goes through the same acceleration ramp as any command. publish_joint_states
exists for the consumer that needs a passive joint – a suspension travel, a caster swivel – in
the same message as the wheels: the core joint_state_publisher publishes every hinge and
slide joint of the entity in one message, the way ros2_control’s joint_state_broadcaster
does, and the base’s own two-joint message is then switched off rather than left to interleave
with it:
- spawn_robot: {model: turtlebot4}
name: robot
components:
- diff_drive: {publish_joint_states: false}
- joint_state_publisher: {rate_hz: 62} # every joint, names without the spawn prefix
The command’s message type is the stack’s choice, not the base’s, so it stays a world key:
stamped_cmd_vel: true where the stack publishes a TwistStamped – a TurtleBot 4’s own nav2
configuration does, and with the Create 3 nodes the base listens where motion_control
republishes (topics: {cmd_vel: diffdrive_controller/cmd_vel}). Getting it wrong is not silent:
the ROS bridge asks the graph once a second and fails the run when a peer of another type sits on
one of its topics, naming the topic, both types and both sides.
ackermann_drive needs the model’s four names – two steered joints and two driven ones, left then
right – plus the wheelbase and the widths its geometry comes from:
components:
- spawn_robot: {model: my_car}
name: robot
components:
- ackermann_drive:
wheelbase: 0.32
track: 0.24
steer_track: 0.20
max_steer_angle: 0.5
steer_actuators: [left_steer_motor, right_steer_motor]
steer_joints: [left_steer_joint, right_steer_joint]
drive_actuators: [rear_left_motor, rear_right_motor]
drive_joints: [rear_left_joint, rear_right_joint]
The two front wheels are steered by different angles and the two rear wheels driven at different speeds, both derived from the same curve – the inner wheel of a turn follows a tighter radius, and a shared value would scrub the tyres. Both splits vanish as the curve straightens.
Which width is which. A real car has three and they are not interchangeable: the separation of
the two steering axes, the separation of the front wheel centres, and the driven axle’s track. The
two splits are measured across different ones, so there are two keys. steer_track is the width
the steer split pivots about – the steering axes, which on most vehicles are inboard of the wheels
since a kingpin sits inside the hub – and track is the driven axle the drive split is measured
across. steer_track defaults to track, which is exact for a design whose steering axes sit at
its wheel centres; anywhere else, leaving it out overstates the steer split at every radius, and the
front wheel centres (neither of the two) overstate it whichever key they are passed as.
It also accepts the message a car-like stack already speaks. ackermann_cmd takes an
ackermann_msgs/AckermannDriveStamped on drive, beside the cmd_vel every base here
publishes:
ros2 topic pub /drive ackermann_msgs/msg/AckermannDriveStamped \
'{drive: {steering_angle: 0.3, speed: 0.6}}'
The message’s steering_angle is defined as the yaw of a virtual wheel located at the center of
the front axle, which is exactly the centre angle this plugin splits into two, so nothing is
converted on the way in. That is what makes it more than an alias for a twist: a twist states a
curvature, w / v, which says nothing at rest – so through cmd_vel a stopped car’s rack can
only hold the angle it has. Through ackermann_cmd a stopped car can turn its wheels, which is
what a real one does while parking, and what a car-like stack sends when lining up before it moves
off. Whichever of the two commands arrived last owns the angle; they are never merged, because a
stated angle and a curvature are two ways of saying the same thing and averaging them obeys neither.
Both interfaces are kept because their consumers differ. Nav2 plans for car-like vehicles perfectly
well – Smac Hybrid-A* and the state-lattice planner both take a minimum turning radius – but its
controller commands in TwistStamped, so a car driven by Nav2 needs cmd_vel. A stack built
around ackermann_msgs needs the other. Neither is a superset of the other.
Its odometry is dead reckoning like the others’, and it drifts on a curve where the tyres slip. That
is left visible rather than corrected by a scrub factor: a skid-steer’s scrub is systematic enough
for diff_drive’s slip_factor, while a tyre’s slip angle varies with speed and load, so a
constant would only make the estimate look better than the sensor it stands for.
Manipulation: an arm on a linear axis¶
A gantry, a ceiling track or a seventh-axis floor rail is a prismatic joint carrying the arm base.
spawn_arm’s rail: expresses it, and it is the one thing mount: cannot: mount welds the
arm to a body that already exists, while a rail has to introduce the moving carriage itself:
components:
- spawn_arm:
model: ur10e
name: ur10e
prefix: "ur10e_"
pos: [0.0, 0.0, 2.6] # where the axis sits
rpy: [3.14159265, 0.0, 0.0] # rolled 180 deg: the arm hangs from the ceiling
rail: {axis: [1, 0, 0], range: [-2.0, 2.0], home: 0.0}
What this buys is kinematic redundancy: a 6-DOF arm on a rail is a 7-DOF system, so a task pose has a one-parameter family of solutions and a planner can trade base travel against arm posture.
Three facts other code depends on:
The rail is joint 0. Its MJCF joint and actuator are declared before the arm’s, so
arm_controllerpublishes and commands[rail_joint, <arm joints...>]— matching a URDF with the prismatic joint at the root of the chain, which is what MoveIt plans against.``home`` stays the arm’s joint vector; the carriage’s start is
rail.home. Folding the rail intohomewould invalidate every per-model default (a 6-valueur10ehome would land on[rail, j1..j5]and leavewrist_3unset).The carriage and track geoms are visual only. A ceiling track that collides traps the arm against its own support from the first step, and the collision model a planner reasons about comes from the URDF/planning scene, not from these geoms. Model real structure as scene geometry.
roqsim export urdf handles such a robot: a jointed root is emitted below a synthetic fixed world
link (--world-link), which is the standard URDF spelling for a rail and keeps the fixed root MoveIt
requires. A root body with a free joint still becomes a fixed root — a floating base belongs in TF
(odom -> base_link), not in the description.
The SRDF is where that base reappears, and roqsim export srdf reads it off the model rather than
assuming it: a base riding a MuJoCo free joint gets a planar virtual_joint (--base-joint
floating for a full 6-DOF one), while a base welded to the world — an arm on a pedestal, or a rail
whose DOF is already a URDF joint — gets none, which is how MoveIt spells a bolted-down robot. Declaring
a virtual joint nothing publishes does not fail loudly; move_group logs Missing virtual_joint and
never assembles a complete robot state, so --base-joint planar on a welded model is refused.
Manipulation: the whole MoveIt configuration¶
move_group needs six files, and the two above are the two a human would call the robot
description. roqsim export moveit writes all six:
roqsim export moveit --world cell.yaml --out cfg/ --tip-site pinch --check
cfg/
<arm>.urdf meshes/ the kinematics, FK-checked against the MJCF
<arm>.srdf groups, named states, a sampled collision matrix
kinematics.yaml a solver over the chain the SRDF names
joint_limits.yaml the kinematic limits that time a geometric path
moveit_controllers.yaml which action a trajectory is executed against
ompl_planning.yaml the planner -- a starting point, meant to be overridden
The invariant is the same one the URDF export exists for, extended to the rest: the configuration
MoveIt plans against is derived from the model the simulator loads, and --check fails the export
when the two disagree by more than a micrometre.
Where the meshes are referenced from is a separate question, and ``–check`` does not answer it.
The default URI is file:// plus the path the export wrote to, which is right where the URDF is read
out of the tree it was generated in and wrong everywhere else: an ament package installs to another
prefix, and a campaign generates the description in one container and plans in another.
--mesh-package and --mesh-prefix name the consumer’s path instead — where the meshes will be
READ — and both exports take them, as alternatives to each other. move_group does not fail on a
mesh it cannot fetch; it logs the failure, keeps the links without their collision geometry and plans
through them. So an export whose URIs land under a temporary directory warns about it, and --check,
which resolves the meshes in the export’s own mesh directory whatever the URIs say, reports that it
measured the geometry rather than the reference.
That matters most for the file that looks least interesting. moveit_controllers.yaml maps MoveIt’s
controller names onto the actions this substrate’s bridge serves and onto the joint list
arm_controller publishes — so it is read from the Endpoint objects the controller declared,
not restated. A name written by hand there can be right on the day and wrong after a world renames a
controller, and the failure is that a trajectory is executed against nothing.
Four more answers come off the model rather than from flags, each because getting it wrong is quiet:
the joint list and its order — the
ArmHandlethe controller published, i.e. the names that reach/joint_states.robot_state_publishermatches by name, so a list that is right about the robot and wrong about the order leaves MoveIt planning from a pose the arm is not in.the home posture —
data.qposafter setup, not the world’shome:key. The controller applies that key itself and areststance may overlay it, while MoveIt’s start-state bounds check runs against the posture the arm is really in.the collapse root — the lowest common ancestor of every body an
equalityconstraint touches. A closed linkage is exactly what URDF cannot express, and MuJoCo says where one is; collapse it and the loop is gone, miss it and the URDF keeps revolute DOFs nothing publishes.``fix_start_state`` — emitted only for an arm that has a continuous joint.
CheckStartStateBoundsnormalizes such a joint onto [-pi, pi], and with this false (its default) it reportsSTART_STATE_INVALIDprecisely because it had to normalize. A start state that drifted a hair past pi is therefore refused rather than wrapped, which surfaces as a phase failing instantly right after a phase that succeeded — at a different phase each run. True writes the normalized state back into the request; a joint genuinely outside its limits is still refused, by a separate bounds check this flag does not relax. A range-limited arm has no such problem and gets no such setting.
The IK answer is not one of them. kinematics.yaml configures MoveIt’s KDL plugin, and that is
the one generated file whose content is not a reading of the model: the plugin solves from the seed
state on its first attempt and from a configuration drawn uniformly inside the joint limits on every
attempt after that, until kinematics_solver_timeout is spent, and takes the first attempt that
converges. The draw is seeded per process from the clock and the number of attempts that fit in the
budget follows the machine, so one pose is answered by a different arm branch from run to run –
the elbow the other way, or a joint turned a full revolution where the limits hold that posture twice
– while everything else the export writes is the same file every time. Each branch is a correct
answer: the tool frame lands where it was asked for, the plan succeeds, and the arm took another
route and stands in another posture. No parameter of the solver constrains it to one branch (its own
are joint weights, max_solver_iterations, epsilon, orientation_vs_position and
position_only_ik), so the export states the fact in the file’s header, names there the joints
whose exported limits hold one posture at more than one value, and warns when it writes it. A trial
whose repeatability rests on a pose therefore solves that pose’s joint vector once, against this
description, commands it in joint space, and reaches further poses by a Cartesian path from the one
the arm is in – which follows the branch it is already in instead of choosing one. Where a query at
run time cannot be avoided, check the joint vector that comes back against the posture expected
before executing it: a plan that succeeds is not evidence that the answer was the intended one.
Pass ``–tip-site``. Without it the arm chain ends at the tool flange, and a goal for the
fingertips has to be written as an offset from there — which multiplies every orientation tolerance by
that lever arm, so 0.15 rad of permitted tilt becomes ±33 mm at the fingers. One cell measured 61 mm of
lateral error against 12.2 mm of jaw clearance: MoveIt had satisfied the goal exactly, and the goal was
about the wrong point. --tip-site pinch emits a frame link at the gripper’s own grasp site (through
a collapsed parent, where such a site usually sits), so a 3 mm position tolerance means 3 mm at the pads.
Two arms that must move at once. --arm left,right describes both as one robot: one URDF with
both chains under a common root, one group per arm, and a group spanning all of them. That last group
is the point of it — a plan for it is a single trajectory through both arms’ joint space, so each
arm’s motion is checked against where the other is at that instant rather than against where it was
before it started. It deliberately gets no IK solver: KDL solves a single serial chain and this
group is several, so a solver there would load and then fail every pose request; reach a pose through
one arm’s own group, and use the combined group for joint-space planning. One flat namespace has to
hold both arms, so their links and joints keep each arm’s MJCF prefix — and since joint names are the
controller’s, not the description’s, each arm’s arm_controller needs joint_prefix: set to that
same prefix. An arm publishing unprefixed names is refused rather than renamed, because those names
are what reaches /joint_states and what a trajectory point carries. Every check above runs per arm:
a second arm whose chain is short by a joint, or whose home disagrees with the simulator, fails as
loudly as the first.
More than one planning pipeline. --pipelines ompl,chomp writes a planning_pipelines.yaml
naming them for move_group and saying which one a request that names none gets; with the default
single pipeline neither that file nor the selector is written, because there is nothing to select. Any
name MoveIt can load is allowed — the list is open, so a pipeline this exporter has never heard of
costs nothing — but each one’s planner package has to be installed where move_group runs, or the
pipeline fails to load at start-up rather than failing the request that uses it.
Only ompl_planning.yaml is written. Its projection_evaluator names joints this model has,
which is what makes it derivable; an optimizer’s cost weights are not — they are the operating point
of a minimisation, which is the experiment’s decision, and a table of them emitted here would be the
exporter making it. Every other pipeline therefore takes MoveIt’s own packaged config (the config
builder falls back to moveit_configs_utils/default_configs/<name>_planning.yaml) until the
experiment puts a file of that name on the config path it reads.
Use this for a comparison across pipelines, where the planners are different plugins with
unrelated parameter files and a trial picks one per request through MotionPlanRequest.pipeline_id;
comparing planners inside OMPL is another entry in ompl_planning.yaml and needs none of it. One
thing to settle before designing such a comparison: CHOMP accepts joint-space goals only. It
rejects a goal with no joint constraints, or with any position or orientation constraint, as
INVALID_GOAL_CONSTRAINTS, so a trial that sets a pose target fails every CHOMP request outright —
which reads like a planner performing badly rather than one that was never given a goal it could take.
Solve the IK and send joint goals, or leave that pipeline out of a pose-goal comparison. The export
warns about it, and planning_pipelines.yaml says so in a comment.
What it does not write is a planning.yaml. The planning frame, the group name and the gripper’s
units belong to whatever node drives the trial, and that is the experiment’s file, not the substrate’s.
Manipulation: the world the arm stands in¶
Those six files describe the robot and nothing else, so the world the simulator loads and the
world the planner reasons about are disjoint: the bench the arm is bolted to, the cabinet it opens
and the wall beside it are invisible to move_group, which plans straight through them, and the
simulator resolves the contact afterwards. There is no error and no warning — the symptom is a plan
that looks fine and an arm that drives into furniture.
--scene writes the other half from the same compiled world, as a seventh file:
roqsim export moveit --world cell.yaml --out cfg/ --tip-site pinch --scene
# cfg/planning_scene.yaml
It is a moveit_msgs/PlanningScene, in YAML a bring-up node fills the message from directly, and
it is a diff: applying it (/apply_planning_scene, or a PlanningScene publisher) adds the
world’s objects and states nothing about the robot. A non-diff scene replaces everything in it, the
robot state included, so applying one would hand the planner a robot at all-zero joints.
One object per static body, at that body’s pose, carrying the primitives it is built from. An
industrial_table is one object called industrial_table holding its top and four legs, so a
trial allows, pads or removes the bench rather than five unrelated shapes. Geoms hanging directly
off the world body — the room’s walls — are each their own object, because one object holding every
wall could not be padded a wall at a time. Poses are written in the frame move_group plans in:
the URDF’s root link, which is the arm’s own base and not the world origin.
What it leaves out is reported by name, in the log and in the file itself, because a missing obstacle is exactly the silent failure the flag exists to end:
Anything with a degree of freedom — a
motion: physicsprop, amotion: drivenobstacle, a pedestrian, another robot’s links. Its pose at export time is not where it will be. Note the default:spawn_modelgives a prop a free joint unless toldmotion: static, so scenery meant for the planner has to say so.Visual-only geometry (
contype/conaffinityboth zero). The simulator does not collide it, so a planner that did would refuse motions the robot can make.Mesh geoms. MoveIt takes a mesh as explicit triangles and MuJoCo collides one as its convex hull, so neither is a shape the two engines agree on — and they disagree most exactly where a hull fills the span a trestle or a shelf exists to leave open. A prop for a planning scene carries primitive collision geoms behind its visual mesh, which is what
roqsim assets collisionmeasures.Plane geoms. A MuJoCo plane is infinite and the robot stands on it, so a half-space in the scene puts the start state in collision and every request is refused before it is planned.
Ellipsoid, height-field and SDF geoms —
shape_msgs/SolidPrimitivehas a box, a sphere, a cylinder and a cone, and none of those is any of these. A capsule is not in this list: it is exactly a cylinder and two spheres, and one object holds all three.
A robot that is not welded down is refused rather than written: its base rides a free joint, so MoveIt plans in a frame TF provides, a prop’s pose is fixed in the world, and the offset between the two is a run-time quantity. Publish that scene from the stack, against the frame TF gives it.
The export also says which objects touch the robot at the posture the simulator starts in.
CheckStartStateCollision refuses a request whose start state is in collision, so an arm bolted to
a bench that is also a collision object plans nothing at all — which reads like a planner that will
not work rather than like a scene saying the arm is inside its own furniture. Allow the pair, pad the
object back by more than the approach clearance, or leave it out; the export names the pair and
leaves the choice where it belongs. MuJoCo reports no contact for it, since both are welded to the
world, so only a distance query finds it.
This is the named-object route, not the only one. A depth sensor feeding MoveIt’s octomap updater
already carries what is in view to the planner as occupied voxels — realsense_d435 with
points: true is that path, and it sees whatever shape a thing has and follows it as it moves.
What voxels cannot be is named: attached to the gripper, allowed against a link, padded, or removed
when the trial picks the part up. The two compose. MoveIt’s sensor filter removes the robot’s own
links and what is attached to them from the incoming cloud, not the world’s collision objects, so a
prop that is both declared and in view is carried twice — conservative, and not wrong.
Manipulation: what a contact task needs¶
Driving one from outside. A Cartesian controller takes its setpoints as topics, named the way
the controllers that do this job on a real arm name theirs – <controller>/target_wrench and
<controller>/target_frame, with <controller>/current_pose coming back. Its identity is
what decides its law, as it is under ros2_control, where what a controller does is settled by which
one is loaded rather than by a mode key:
|
what runs |
|---|---|
|
pose tracking only, blind to contact |
|
the wrench loop, no stiffness and so no equilibrium |
|
both, with per-axis stiffness |
An axis given zero stiffness stays under pure force control while the others track the commanded
frame. That superposition is the point: it is how a task-space motion is layered on a running force
loop on real hardware, which means the thing driving that motion is an ordinary publisher rather
than a second controller. Two controllers cannot claim the same joints, so an experiment that ships
its search or its scan as a “controller” is writing something that cannot run on the arm – ship a
node that publishes target_frame instead.
law: admittance | position is the older spelling and still works, deriving a controller_type.
Zeroing is not optional. The sensor reads everything below the cut, so an arm starts from the weight of its own wrist – and a force controller has no stiffness and therefore no equilibrium anywhere, so an untared tool sinks at that force over the damping for as long as the trial runs. Tare through the service (see “Taring” above) before commanding anything.
A limit that stops the trial is force_limit: a measured wrench magnitude above a threshold
latches, reports, releases whatever was driving the arm and asks the driver to stop. Named for the
capability rather than for one vendor’s word for it, with that word supplied by reports_as. It
is not a controller and does not pretend to be one – on a real arm a stop of this kind comes from
the controller box and is surfaced through the vendor’s status interface, so reporting it as a
controller switch would put a fiction in a results table’s failure-mode column.
contact_monitor (above) treats contact as the failure, and model_override (above) can take a
contact away on command. A contact-rich manipulation task inverts both: contact is the task, and the
measurement is the wrench, not the trajectory. Four plugins make
that chain, and they are listed in a world in this order because each needs the previous one’s
blackboard handle:
- spawn_arm: {model: ur5e, name: ur5e, prefix: "ur5e_"}
- force_torque: {name: ft, arm: ur5e, site: fts_site, frame: world}
- peg_in_hole.py:PegInHolePlugin: {arm: ur5e, clearance: 0.001, hole_pos: [-0.49, -0.13, 0.0]}
- cartesian_admittance: {arm: ur5e, ft: ft, law: admittance, site: tool_site}
- insertion_task.py:InsertionTaskPlugin: {arm: ur5e, ft: ft, law: admittance, target_pos: [...]}
Read the refs, not the order. Three are named — spawn_arm and cartesian_admittance from
roqsim_manipulation, force_torque from roqsim_sensors — and resolve through entry points because
they are substrate: an arm, a sensor, a control law. Two are paths, and ship with the experiment: the
bored block whose clearance is the experimental variable, and the trial protocol built around it.
That division is the general one. The substrate owes a cell the mechanism — mount an arm, measure a wrench, close a Cartesian loop. What is being inserted into what, and what counts as having inserted it, is the experiment’s to state.
Three things decide whether such a world measures anything at all:
Where the sensor cuts. A site force sensor reports the wrench transmitted through that site from the body’s children, so the tool must hang below it. A peg attached above the measurement site produces a wrench that is identically zero — which looks like a well-behaved controller, not like a broken world. The
ur5emodel shipsfts_site(the cut) andtool_site(the attach point, further out) so the two cannot be confused.Gravity and tool mass. With gravity on and a realistically-massed tool, any metric that integrates force is dominated by the tool’s own weight. Zeroing is a command, as it is on real hardware:
force_torqueexposes atareservice (std_srvs/Trigger, the analogue of a driver’szero_ftsensor),WrenchReader.tare()for an in-process controller, andtare_at_sfor a world that wants it done once at a stated time. Prefer a command – a time has to stay in step with the scenario’s own timing, and fires mid-approach if that slips. All three forget the offset onreset, so a repetition really is one.A tare is not gravity compensation. It cancels the load at the pose it was captured at, exactly as the zero button on a real sensor does: the tool’s weight is fixed in the world frame while the sensor frame turns with the tool, so rotating after taring brings the weight back. Tare per approach on a tool that turns. Where an experiment needs a wrench that is clean at every pose,
sim.gravity: [0, 0, 0]or a near-massless tool remain the honest answers.The controller’s plant, not its gains.
cartesian_admittancecloses a loop aroundarm_controller’s position servo, which is stiff. Admittance gains taken from a system with a soft joint controller will oscillate and diverge on contact. Tune against a stability criterion fixed in advance, and record the result as a calibration — theur5emodel is the worked example, including the sweeps. Where the plant itself is what a reconstruction has to match, the spawn’sactuators:block states it:control: impedancewith the joint stiffness and damping the reference used, in place of the model’s own servo. That is a property of the experiment rather than of the arm, so it belongs in the world and not in the shared MJCF — see Architecture & porting playbook, “Actuator overrides”, and note that a cell running at zero gravity gets identical physics fromimpedanceandposition.
A trial plugin of this shape — approach → act → succeed/timeout/abort → write — calls
ctx.request_stop() when it resolves, so a run ends when the trial does instead of being
padded to a guessed --seconds. Two rules are worth copying from a trial-protocol plugin: give it
an explicit failure condition as well as a success one (a trial that can only succeed cannot produce a
success rate, it can only hang), and write the raw observable rather than the metric, because a
force-energy definition belongs to the analysis where it can still be argued with.
Manipulation: what a grasping world needs¶
Four things have to line up before an object can be picked up, and three of them are opt-in because they cost something a navigation world should not pay:
A movable object.
motion: physics— the default — adds a<freejoint/>, registers the joint as the entity’sbase_joint(which is whatsimulation_interfaces’SetEntityStaterequires to re-seat it), and re-seats it onresetso repetitions of a trial really are repetitions. Pair it withpublish_tf: dynamic— nothing else publishes a movable body’s pose.graspable_boxis the reference prop, sized and contact-tuned for a parallel gripper.Solver effort.
sim: {noslip_iterations: 10}. Without it a firmly held object creeps out of the jaws; see “Solver options” inarchitecture.rstfor the measurements.Scoped actuator ownership, if the arm shares its entity with anything else.
arm_controllerclaims every joint actuator matching the entity prefix by default, which is right for a standalone arm and wrong for a humanoid or a mobile manipulator — it then also claims the legs or the wheels and fights their owner, writing position targets into what may be torque actuators. Give itjoints:(andgripper_actuator:, which cannot be inferred once one entity carries two grippers), and each controller also reports only its own joints, so several can share one/joint_statestopic.``mass`` / ``friction`` on the spawn, if either is a factor you want to vary — they are ordinary world-YAML keys, so an ordinary parameter sweep varies them and needs no new variation plugin.
unitree_g1_dex1’s manifest is a worked example of (3): two arm_controller instances on one
entity, each owning its seven arm joints and its own Dex1 gripper, alongside g1_locomotion on the
twelve leg motors.
Two more if the target moves (dynamic grasping):
A conveyance, not a mover.
prop_trajectory(inroqsim_assets) carries the object along a prescribed 2-D path by friction, on force-driven slide joints. Reach for it rather thanmoving_boxorwalker: both are mocap, and a mocap body has no velocity in MuJoCo’s dynamics, so friction against it transfers no tangential force — it will slide out from under the object it is supposed to be carrying while staying in contact. Mocap blocks; a driven joint carries.A velocity command path, if the controller is reactive. Resolved-rate and QP whole-body controllers emit joint velocities, and this plugin’s actuators are position servos, so
arm_controller’svelocity_commands: trueintegratestarget += qd·dtinto the held target (clamped to the joint range, with avelocity_timeout_swatchdog so a dropped stream cannot leave the arm drifting). Position servos are kept deliberately — a MuJoCo<velocity>actuator sags under gravity at zero command. Be aware of what that costs a metric: the achieved profile carries the servo’s own dynamics, so where end-effector acceleration is the measured quantity, check the tracking error and report the gains as part of the setup.
An arm carried by spawn_robot also needs arm_controller’s rest stance: a robot spawn sets
the base pose and no joint stance, so the arm falls back to qpos0. For the Panda that is not neutral
but an actively bad pose — its link5 and hand collision geoms overlap by 0.030 m at all-zeros.
rest seeds the spawn qpos and the held target by joint name, and re-seats on reset so repeated
trials start identically. frankie’s manifest is the worked example of (5), (6) and rest.
Scoring the trial, not self-reporting it¶
A trial’s verdict belongs to the experiment, not to the substrate — deciding what counts as
success is the thing the experiment is for. tiago_pick’s pick_place_metrics is the
reference implementation, and the pattern generalises even though the plugin is not shipped here:
- pick_place_metrics:
target: parcel
grasp_links: [gripper_fingertip_left_link, gripper_fingertip_right_link]
container: dropbox # omit to score a pick alone
lift_check: 0.05 # m the object must rise ...
hold_s: 5.0 # ... and stay held, both pads on it
metrics_out: metrics.csv
It reports picked (lifted and stayed held through a dwell, with a
contact_debounce_s tolerance so a momentary loss of one pad is not a slip), placed (came to
rest inside the container and supported by it), and success. Nothing in it knows what arm,
gripper or base it is watching — the gripping links are named in config — so the same rule scores
different platforms.
What matters structurally is not which package holds it but which side it sits on: the verdict must be on the SIM side, out of reach of whatever is driving. Two reasons:
Comparability. When an in-process phase machine and an external MoveIt node solve the same trial, each scoring itself makes the comparison partly a comparison of two verdicts.
Observability. Contact state is not on the ROS graph, and neither is “resting inside the box”: a client can see the object’s TF, but a parcel balanced on the rim looks identical to one on the floor of the container.
A driver that wants to react to a slip reads held() and decides for itself — the observer
reports and never terminates. Keep the rule platform-agnostic anyway (name the gripping links in
config rather than hard-coding them): that is what would let it move up into the substrate the day a
second experiment scores a pick.
Manipulation: where a workpiece lives¶
The substrate ships robots, not the things they work on. A bored block whose clearance is swept at 0.1 mm, or an intersecting-pipe weldment whose dimensions were chosen because the paper never stated its own, is one experiment’s geometry: reused by nothing, and installed by everyone if it sits in a family package. So a workpiece ships with the experiment that defines it, and there is no special mechanism for that — it uses the two doors any downstream package uses:
an
roqsim.modelsentry point naming a module withMODELS_DIR, after whichspawn_model: {model: my_workpiece}resolves by bare name;a path-loaded plugin (
my_workpiece.py:MyWorkpiecePlugin, relative to the world file), for a workpiece whose geometry does not exist until the world configures it — a fit expressed as a number, or goal poses derived from the same definition as the shape they lie on. No entry point, no install, no wheel: the world and the plugin travel together.
What the substrate owes such a cell is the arm, the sensing, the control law and the trial machinery — all of which are addressed by name and none of which know what is being welded or inserted.
Writing your own¶
Subclass roqsim.plugin.Plugin, implement the hooks you need, add validate_config, and
either register a roqsim.plugins entry-point or reference the class directly from the world
YAML. Referenced directly, the ref (my_pkg.mod:MyPlugin or ./plugins/x.py:Foo) contains a
colon and is the plugins-list entry’s key, so quote it — - "my_pkg.mod:MyPlugin": {...} —
to keep the colon from splitting the key.
If your config names a file, implement ``sources``. roqsim.config.world_sources — what
roqsim scenes inputs and the exporters’ --manifest report, and what a run harness
stages a world by — walks the YAML’s extends chain and the MJCF’s assets. It cannot see
into a plugin’s config, so a mesh or a CSV named there is invisible to every caller asking
“what does this world need?” unless the plugin says so. Return absolute paths; entries that do
not exist are dropped, and the hook must never raise (callers treat it as best-effort). It is
the same rule as transport_only: a capability is declared by the plugin, never listed in
the core.
And resolve it against ``self.base_dir``, not the CWD. That attribute is the directory of the
world document the entry was declared in, so a path written beside the world resolves the same
wherever the world is opened from. That matters more than it sounds: the working directory is not
the document’s directory in general, and need not be the same twice. A world may be opened by
absolute path from somewhere unrelated, or copied under a different root by a tool that stages its
inputs — and a CWD-relative path then names a different file, or none. resolve_model and
load_manifest both take a base_dir for this; pass self.base_dir. A bundled model name
is a provider lookup and is unaffected.
A spawn plugin can bundle a model’s default plugins by implementing expand via
roqsim.manifest.expand_manifest (see the manifest mechanism in the Developer guide /
architecture reference). See the porting playbook in the Developer guide.