Authoring a new world¶
End-to-end guide for creating a world that Arena can load. Covers the manual path; see scripts/generate_world for the AI-assisted generation pipeline.
Prerequisites¶
- Arena workspace built (
colcon build). arena_simulation_setupon$AMENT_PREFIX_PATH.touch_worldscript available:ros2 run arena_simulation_setup touch_world.
1. Create the world directory¶
export WORLD=my_world
mkdir -p \
$ARENA_DIR/arena_simulation_setup/worlds/$WORLD/scenarios/default \
$ARENA_DIR/arena_simulation_setup/worlds/$WORLD/assets
$ARENA_DIR is the workspace source root. If unset, touch_world uses the
current working directory.
WorldIdentifier resolves a name against, in order: ARENA_WORLD_PATH roots,
then the writable local root $ARENA_ASSETS_DIR_LOCAL/worlds, then this
package's worlds/, then a network bucket named by WORLD_BUCKETS. Worlds
saved through resolve_write_sync() (the world generator, the worlds MCP
tools) land in the writable local root, outside the repo and the install
space. Once authored, publish a world with
arena asset push world <name>, which refuses to publish if any asset it
references would not resolve for someone else. arena asset find world <name>
shows which source a name currently resolves against. See
tree/README.md.
2. Author world.yaml¶
A world is one file, worlds/$WORLD/world.yaml, holding every level. The
minimum valid file is one level with one walled zone:
format: arena-world/2
levels:
- id: '0'
zones:
- name: main_room
polygon: [[0, 0], [10, 0], [10, 10], [0, 10]]
walls: outline
The full shape, every key optional except format, levels, a level's id
and a zone's name:
format: arena-world/2
defaults: # inherited by every level and item that does not set its own
floor: Concrete_Smooth # material, or false for no floor
ceiling: {height: 2.7, material: Concrete_Smooth} # or false for open zones
wall: {material: Plaster_Wall} # or {kind: patient_room_walls}
door: {kind: sliding, material: Aluminum_Anodized}
schedules: [...] # world scope, see M2 kinds below
signals: [...]
levels:
- id: '0'
pedestrian_walls: true # false drops the occupancy-detected pedestrian walls
zones:
- name: central_hallway
polygon: [[8, 0], [12, 0], [12, 34.2], [8, 34.2]]
floor: Marble
ceiling: {height: 3.0, cast_shadows: false, lights: {fixture: panel, spacing: 2.4, lumens: 3600}}
walls: outline # or {outline: true, open_to: [lobby], material: ..., kind: ...}
semantics: [{state: max_speed, value: 1.5}]
walls:
- {path: [[0, 10], [0, 20], [8, 20]], kind: patient_room_walls}
- {path: [[20, 0], [24, 0], [24, 4], [20, 4]], closed: true}
doors:
- {name: main_entrance, span: [[9.25, 0], [10.75, 0]]}
elevators:
- {name: lift_0, position: [5, 0], destinations: [lift_1]}
objects:
- {name: desk_1, model: Common/Desk, pose: [2.0, 1.0, 1.5708]}
humans: []
lights:
- {name: exit_strip, fixture: tube, lumens: 300, pose: {x: 10, y: 34, z: 2.2}}
sounds:
- {name: hall_siren, asset_id: alarm_loop, position: [4.0, 2.3]}
microphones:
- {zone: central_hallway, placement: ceiling, position: [10, 5, 3.0]}
Key points:
- A zone is a named region: its
polygon(an open ring, the interior is navigable floor), itsfloorandceiling, its runtimesemantics, and optionally walls along its outline. It holds nothing else. Walls, doors, elevators, objects, humans, lights, sounds and microphones are lists of the level, and which zone a thing belongs to is a semantic fact (see KG). - Every name (zone, door, elevator, object, human, light, sound, schedule,
signal) is unique across the whole world, so a reference never needs a
level. Loading fails on a repeated name, on a key the format does not know
(the error names the YAML path, e.g.
levels[0].doors[3]: unknown keys ['heigth']) and on a reference to an unknown name. - Positions are
[x, y],[x, y, z]or{x, y[, z]}. Poses take the forms ofPose.parsein utils/geometry.py:[x, y],[x, y, yaw](radians),{x, y[, z][, roll][, pitch][, yaw]},[x, y, z, roll, pitch, yaw],[x, y, z, w, x, y, z]or a zone name. - A material is
Name(domainCommon),Domain/Nameor[Name, {modifiers}]. defaultsapply to every zone, wall, outline and door that does not set the key itself. Withoutdefaults, a zone has aConcrete_Smoothfloor and ceiling, and walls and doors take the package defaults.walls:entries are polylines,closed: truejoins the last point back to the first,kind: <name>references aWallIdentifierasset andmaterial:an inline material, never both.walls: outlineon a zone draws a wall along every polygon edge,open_to:leaves open the edges it shares with the named zones. Repeated walls are merged, and a door cuts every wall it lies on, so draw walls straight through doorways.objects:andhumans:take the same schema as a scenario's lists (see 4. Add a scenario) and exist in every scenario run against the world.
Linked levels¶
A level can come from another world instead of being written out:
- {from: <world>[:<level id>], id: <level id>}. <world> is a world name,
resolved like any world, or a path relative to this world's directory
(./floors/2), so a tall building keeps each floor in its own small format 2
world under a subfolder:
format: arena-world/2
levels:
- from: ./floors/ground # floors/ground/world.yaml, a one-level world
- from: ./floors/first
- {id: '2', from: 'office_1:0'} # level 0 of the office_1 world, as level 2 here
id defaults to the level's id in its source, and the level id may be left
out when the source has a single level. A linked level is taken as its
source defines it, defaults included. Its only other key is prefix:, put
before every name of the level and every reference to one, so the same floor
can repeat (- {id: '3', from: ./floors/typical, prefix: f3_}) and two worlds
sharing a zone name can sit side by side. Names stay
unique across the composed world, elevator destinations that point outside
it are dropped with a warning, and a link back into a world already being
read is an error. Assets local to a linked world (<world>/assets/) resolve
after this world's own. Saving the world writes the links, not the levels
they name. arena asset push world refuses a world whose ./ or ../ link
leaves its folder, or that links a world only this machine has.
A linked level cannot set the destinations of its elevators, so the destinations that leave the composed world are dropped and the linked levels whose elevators are then left without one ride between each other: every linked level with exactly one such cabin joins a ring in level order (two levels ride to each other, three as 0 to 1, 1 to 2, 2 to 0). A level with several such cabins stays out of the ring with a warning naming them.
Any world name also takes a comma list, world:=hospital_1,office_1, which
stacks the listed worlds as the levels of one world without writing a file:
each level of each world links in order with ids from 0, a level filter
picks levels (world:=hospital_2[0],three_storied_residential[1]), and every
name gets the world name as prefix (hospital_1_, a second listing of the
same world hospital_1_2_). The composed world is written to
$ARENA_ASSETS_DIR/_composed/<list>/world.yaml and loads like any linked
world, the elevator ring included. It has no scenarios, the worlds' own
scenarios name the unprefixed entities.
Ceilings¶
ceiling: on a zone is false for an open zone or a mapping of height
(top height in metres, derived when absent), material, cast_shadows
(default false) and lights (the ceiling rig, see Lights). With no
height, the ceiling sits on the tallest wall standing on or within 0.3 m of
the zone polygon, at least 2.0 m.
Ceilings are opaque from below and transparent from above. Isaac hides them
unless the world declares lights, sim.isaac.viewport.ceilings:=on|off forces
them. The walls a zone with a ceiling borders or holds reach it, and a door
lower than the ceiling gets a visual-only lintel above it. They are
visual-only (no collision). With cast_shadows false the ceiling does not
occlude the sun, so interiors stay lit without global illumination.
Doors and elevators¶
A doors: entry sits on the level: name, span: [[x, y], [x, y]], plus
its kind, timing and material. Full field reference:
Door fields.
An elevators: entry is one cabin on its level: name, position, size,
door_side (inferred from the nearest wall when absent) and destinations,
the names of the cabins a ride goes to, on any level, so a shaft is one cabin
per level linking the others and two cabins on one level make a teleporting
pair. Full field reference: Elevator fields.
A cabin with several destinations goes to the one in its writable semantic
field target (a cabin name, e.g. set by a robot task or a scenario
timeline). With target empty it cycles round-robin over the list, one step
per departure. A written target stays until rewritten, writing "" returns
to round-robin. A single-destination elevator publishes target: "".
Elevator capacity: <int> (default null = unlimited) caps the cabin: while
the agents inside (robots plus pedestrians) are at or above it, outside calls
are ignored, so the door closes after hold_time and the cabin departs with
whoever is inside. Departures from several cabins to the same destination queue
up and arrive in order. A rider whose teleport fails (refused by the sim, or
not seen to move within 10 s sim time) is released: the source cabin unseals
and retries the ride, the destination stops holding its door for them.
Elevator call_mode: level | edge (default level) sets how agents outside
the cabin call it. level: any agent within activation_distance of the hall
door calls every tick, so the door stays open (and a pending departure is
cancelled) for as long as anyone waits there. edge: a call only (re)opens the
door when an agent newly enters the call radius, or when the door is shut and
no departure is pending (nobody new inside). Agents merely standing in the
lobby no longer hold the door: it closes after hold_time (the slab safety
reopen still applies) and the cabin departs. Use edge for crowded lobbies.
A door's activation_distance takes a scalar or a pair [a, b] (the larger
value is the trigger radius).
Door kind is one of sliding (default, slab slides along the span),
sliding_top (slab lifts), teleport (slab vanishes) and hinged: the slab
rotates about the first span point by up to 90 deg over transition_time.
swing_direction: 1 (default) opens towards the left normal of the span,
swing_direction: -1 towards the right. Like sliding doors, a hinged slab
never moves into an agent: an opening step that would hit someone in the swept
arc holds, a close with anyone in the remaining arc is refused and the door
re-opens (blocked_close).
doors:
- name: office_door
span: [[2.0, 0.0], [2.9, 0.0]]
kind: hinged
swing_direction: -1
elevators:
- name: hub
position: [5.0, 0.0]
destinations: [elev_a, elev_b]
capacity: 4
call_mode: edge
Pedestrians see mechanisms as walls where they cannot pass: cabin back and
side walls always, and a door's segment while it cannot open for them (a
gate effectively locked with no authorized agent near, or
activation_distance <= 0 with no pressed plate driving it).
Microphones¶
A level's optional microphones: list places audio listeners for the
acoustics pipeline. Full field reference:
microphones.
Semantic annotations¶
Door and elevator state is intrinsic: every spawned door/elevator publishes its full vocabulary with no annotation needed:
| kind | discrete | continuous | predicates | members |
|---|---|---|---|---|
| door | state |
progress |
open, in_transit, triggered, blocked_close (a close is refused because an agent is in the slab path) |
agents within the activation radius |
| elevator | cabin_door, target (writable, see above) |
arriving_eta, occupants (riders committed to a ride), cabin_door_progress, live_occupants (agents inside now), capacity (-1 = unlimited) |
departing, in_transit, dispatched, just_arrived, full |
agents inside the cabin now |
Agents are named as the runtime tracks them: robots by sim path, pedestrians
by their ped name. semantics: on a doors:/elevators: entry is only for
attaching a scripted kind (gate, pressure_plate) to that entry. Zones
accept an optional semantics: list of literal state/predicate primitives:
zones:
- name: lobby
polygon: [...]
semantics:
- {state: max_speed, value: 1.5}
- {predicate: quiet, value: true}
An Elevator entry configures its fire-recall regime as a first-class field,
not a semantics annotation:
elevators:
- name: 1_elevator
position: [5.0, 0.0]
destinations: [2_elevator]
recall_on: alarm
Full field reference: worlds/README.md.
KG¶
What a world means, as opposed to what the runtime executes, lives in an
optional worlds/$WORLD/kg.yaml beside world.yaml: a knowledge-graph
scene of entities, facts and constraints (format: arena-kg/1), typed
by the vocabulary packages in
kg/packages and shared
with the airchitect world generator.
format: arena-kg/1
uses: [core@2]
entities:
- {id: room:reception, props: {type: Main Entrance, description: A spacious reception and waiting hall}}
- {id: room:triage}
- {id: obj:desk_1, props: {label: Reception Desk}}
- {id: obj:kiosk_0, props: {label: Information Kiosk, placed: false}}
facts:
- {subject: room:reception, verb: contains, object: obj:desk_1}
- {subject: room:triage, verb: contains, object: obj:desk_1}
- {subject: room:emergency, verb: left_of, object: room:triage}
- Ids are
<kind>:<world name>:room:<zone>,door:<door>,obj:<object>,light:<light>,sound:<sound>,elevator:<cabin>,floor:<level id>. Those must name something inworld.yaml, except anobjwithplaced: false(an object a generator asked for but never placed). Roles, regions, agents, profiles, purposes and materials live only here. - The KG carries no geometry (
boundary,line,pose,model, ...):world.yamlowns it. - Membership is
contains(a thing may sit in several zones), connectivityroom connects roomwithdoor: door:<name>. Route instructions, the acoustic room graph and the prompt modes read them. - Facts are stated (
source: generated, the default) or derived (source: derived). On load, derived facts are recomputed: a door belongs to the zones whose boundary it lies on and connects them, an object, light, sound or elevator to the zones holding its position. Only things no stated fact covers get derived ones, so a statedcontainsalways wins.kg.derive:=allignores the stated memberships and connections of things inworld.yamland derives them all, the rest of the KG stays.kg.derive:=offkeeps the stated facts only,autois the default. - A linked level brings the KG of its source world along: its
entities, stated facts and constraints join this world's wherever what they
name is part of this world,
floor:<id>follows the level's new id, and this world's own KG wins every conflict. Saving writes only this world's own part back. - What people use a zone for is
room has_purpose purpose:<work|break|transit|private|unknown>(packagespace@1). The scenario generator places its crowds by it, aparams: {by: llm, ...}marks a model's proposal that a human has not reviewed, see generative/scenario. - Nothing in the KG drives the runtime. Mechanism semantics (presets,
light_on,recall_on, zone states) stay inworld.yaml.
M2 kinds, params, and timelines¶
M2 adds scriptable/derived kinds on top of the intrinsic door/elevator
vocabularies and zone. Each kind takes its config under a params: dict on
the primitive or preset item, which round-trips opaquely (omitted on
serialize when empty):
| kind | attaches to | preset expansion | params keys |
|---|---|---|---|
signal |
a world signals: entry |
state, phase_remaining, stop |
phases (list of {name, duration}), stop_phases, regime, optional stop line position ([x, y], level frame) and radius (evaluation only, ran_red_signal) |
schedule |
a world schedules: entry |
state, active, window_remaining |
windows (list of {start, end, value}), default, regime |
gate |
a doors: entry |
locked, blocked |
authorized (sim_paths/robot names), unlock_on, locked (initial value, default true: a gate authored without it starts locked and logs so) |
pressure_plate |
a doors:/elevators: entry (own position) |
pressed |
position ([x, y], level frame like all geometry), radius, drives (a door name, <level_id>.<name> for a door on another level), latch, press_on, regime |
occupancy_cap |
a zones: entry |
occupancy, cap, over_cap |
cap |
sound |
a sounds: entry (level or scenario) |
sounding, volume_db |
sound_on, regime, sounding/volume_db (initial values) |
light |
every lights: entry and every zone ceiling rig, attached from the light's own fields |
lit, dim, dead_fraction (rigs only) |
light_on |
signal and schedule have no geometry, so they sit at world scope beside
levels:. A sound needs a placement: exactly one of position, attach
(name of an object of the world, whose yaw frame offset is applied in) or
frame (a TF frame the sound rides, offset local to it, so frame:
jackal/base_link is a speaker bolted to that robot). Other Sound fields:
asset_id (required, the audio catalog entry), loop (default true) and
reference_distance_m (default 1.0, must be positive, sets the falloff
reference distance).
schedules:
- name: fire_alarm
semantics:
- {preset: schedule, params: {windows: [], regime: alarm}}
signals:
- name: crosswalk_light
semantics:
- {preset: signal, params: {phases: [{name: go, duration: 20.0}, {name: stop, duration: 10.0}]}}
levels:
- id: '0'
sounds:
- name: hall_siren
asset_id: alarm_loop
position: [4.0, 2.3]
semantics:
- {preset: sound, params: {sound_on: alarm, volume_db: 88.0}}
A preset params: key that names one of the preset's own primitives
(volume_db, sounding, locked, ...) becomes that primitive's initial
value instead of a shared param, so {preset: sound, params: {sounding:
true, volume_db: 62.0}} is a radio that plays from the start at 62 dB. A
top-level value: on a preset item that expands to more than one primitive
is rejected at load: there is no single primitive it could mean. Presets are
written back as authored.
A scenario may carry its own sounds: list with the same schema. Those are
episode-scoped: attached at reset, gone at the next one, and their attach
may also name one of the scenario's own objects:.
Lights¶
A light is one fixture, an ambient light, or a zone's ceiling rig. Each is a
light semantic entity with lit (on/off), dim (dimming in 0..1) and,
for rigs, dead_fraction (share of dead fixtures), all writable through
semantics/set and the scenario timeline and recorded with every other kind.
Worlds that declare no light render as before. A world that declares any
light gets no default lighting at all, so what it declares is all there is
and a blackout is dark. Lights that objects carry (below) do not count as
declared, and neither do the lights a robot carries, so a lamp or a headlight
adds its light on top of the default lighting.
| Where | Fixtures | Placement |
|---|---|---|
level lights: |
dome, sun |
none, a pose aims the sun |
level lights: |
panel, tube, downlight, spot, bulb |
exactly one of pose, attach or frame, the latter two with an offset |
zone ceiling: {lights: ...} |
panel, tube, downlight, spot, bulb |
a grid at spacing metres over the zone polygon, just below the ceiling |
object annotation.yaml lights: |
panel, tube, downlight, spot, bulb |
pose in the object frame, moved, turned and scaled with every placement |
scenario lights: |
panel, tube, downlight, spot, bulb |
as for level lights:, attach names one of the scenario's objects:, level the floor of a literal pose |
robot model_params.yaml lights: |
panel, tube, downlight, spot, bulb |
frame of the robot plus offset, see robots/README.md |
Light fields: name, fixture, lumens (local fixtures, per fixture for a
rig) or lux (dome, sun), cct_K (default 6500), cast_shadows (default
false), cone_deg (spot only, default 40), and one
of light_on (a regime, ! negates) or lit (initial value, default true),
plus dim (default 1.0). A ceiling rig takes fixture (default
panel), spacing (default 2.4), lumens, cct_K, cast_shadows,
light_on/lit, dim and dead_fraction, needs a ceiling, and is
named after the zone. The same fixtures of a rig die at the same
dead_fraction in every run, and raising it only adds dead ones.
pose places a fixture in the level frame, with a height, since a light sits
above the floor, offset in the frame of the object it is attached to or of
its TF frame. A fixture emits along the local -z axis of that pose, a
panel or tube lying along its local x and y, so a pose without an
orientation shines straight down and yaw turns a tube about the vertical.
bulb and dome take no orientation, a sun takes a pose whose position
is zero. A frame light follows its TF frame, so frame: jackal/base_link
with offset: {x: 0.2, y: 0, z: 0.3, pitch: -1.42} is a headlight that looks
ahead and slightly down. A scenario, robot or episode object light whose name
a spawned light already holds is left out with a warning.
A world without lights can still run lit: world.lighting:=auto gives every
zone that has a ceiling, no ceiling rig of its own and no level light placed
inside its polygon a rig of 7200 lm panels at 2.4 m spacing, without touching
the world file. The default world.lighting:=authored renders exactly what
the world declares.
levels:
- id: '0'
zones:
- name: central_hallway
polygon: [[8, 0], [12, 0], [12, 34.2], [8, 34.2]]
ceiling: {lights: {fixture: panel, spacing: 2.4, lumens: 3600, light_on: "!blackout"}}
lights:
- {name: ambient, fixture: dome, lux: 5}
- {name: exit_strip, pose: {x: 10, y: 34, z: 2.2}, fixture: tube, lumens: 300, light_on: blackout}
schedules:
- name: power
semantics:
- {preset: schedule, params: {windows: [{start: 60, end: 90, value: out}], regime: blackout}}
An object's annotation.yaml can carry lights: entries with name (default
light), fixture (default bulb), pose, lumens, cct_K,
cone_deg and glow. arena_assets writes them when it builds an object whose
source model contains lights (hand-written entries win, and the built models
carry no light themselves). glow names the material that glows with the
light, a lamp shade for instance: Isaac, Gazebo and MuJoCo make it emit the
light's color scaled by its dim, and it is dark while the light is off. Every
placement of the object, in a level's objects:, a scenario's objects: or
by an obstacle mode, then gets the light <entity>_<name> for as long as the
placement exists, and a light: key on the placement sets its lit,
light_on or dim:
objects:
- {name: desk_lamp, model: Residential/Desk_Lamp, pose: {x: 2, y: 1, z: 0.75}, light: {light_on: "!daylight"}}
A scenario dims the hall with {entity: central_hallway, field: dim, value:
"0.3..1.0"} in its timeline. Isaac renders every fixture as an area light, and
shades asset materials that only emit a texture by that texture, so they
darken with the lights.
Gazebo renders a rig as one shadowless point light per 2 by 2 block of
fixtures, plus one unattenuated fill at the room center for the light the
room surfaces reflect (40 % reflectance), since its renderer traces no
indirect light.
MuJoCo shades with at most seven lights per view and without shadows from
fixtures, so each camera gets the fixtures merged the coarser the smaller they
look from where it stands, plus the fill of the rig whose room it stands in.
Dome and sun light the whole stage, so the first env's world sets them for
every env of a runtime, and an env whose world declares other ones logs a
warning naming both.
Pedestrian stimuli¶
A sound that propagation marks audible for a pedestrian reaches humansim as a
stimulus named after the catalog category of its asset_id (alarm_loop
has category: alarm, so its stimulus is alarm). An agent type opts in by
declaring a need of that name and a transitions: entry on it, and humansim
sets that need to 100 once the agent's sampled reaction_time has elapsed,
so the transition fires. Agent types without such a need ignore the sound.
See
worlds/hospital_1/scenarios/fire_alarm_evacuation
for the reference: the evacuee agent type idles until alarm rises, then
walks to the static exit object.
gate and pressure_plate reuse an existing doors:/elevators: entry as
their attachment point, occupancy_cap reuses a zones: entry, all via the
same semantics: list. A gate should spawn unlocked (locked: false in its
params): the world is shared by every scenario, so a gate that defaulted to
locked would block scenarios that never touch its regime. A scenario that
wants the door locked at episode start says so explicitly in its timeline:
(see below), leaving the world itself inert:
doors:
- name: door_edge_1_1
span: [[4.0, 1.55], [4.0, 2.45]]
semantics:
- {preset: gate, params: {authorized: [], unlock_on: alarm, locked: false}}
- {preset: pressure_plate, params: {position: [4.0, 2.0], press_on: alarm, drives: door_edge_1_1}}
A regime (or its per-kind alias unlock_on/press_on) names a boolean
asserted by a scripted kind's driving predicate. Other kinds (gate,
pressure_plate) consult that name without a direct wire between the two
entities. A sound consumes a regime the same way, via its sound_on alias,
and a light via light_on. Every follower field accepts a leading ! for the
negation, so light_on: "!blackout" is lit unless blackout is asserted.
An elevator's recall_on field is the same regime-consult
mechanism, just wired as a first-class Elevator field instead of a
semantics: alias, since recall is mechanism configuration rather than
published state. See the fire-alarm worked example below.
Scenario timelines¶
scenario.yaml accepts an optional timeline: list. Each entry fires its
actions against exactly one trigger and needs at least one action:
| trigger | meaning |
|---|---|
at: <seconds> |
fire once at episode second t |
every: <seconds> |
fire at offset (default: one period in), then each period, optional until |
when: {entity, field, is} |
fire on the false-to-true edge of a semantic value |
when: "<atom>" |
fire on the false-to-true edge of a condition atom (see Add a scenario) |
In a when atom the subject robot stands for every robot of the env: the
atom holds while it holds for at least one of them, so
not robot within 3 of 2,2 holds while some robot is farther than 3 m from
(2, 2). A robot's own name (anonymous robots are named after their model,
e.g. jackal) judges only that robot. Without a placed robot such an atom
does not hold.
| action | meaning |
|---|---|
set: [{entity, field, value}] |
write semantic values |
stimulus: [{agents, need, intensity}] |
raise need on each pedestrian named in agents through humansim's notify_stimulus, intensity 1 by default (humansim sets the need to 100 x intensity) |
timeline:
- at: 0.0
set:
- {entity: door_edge_1_1, field: locked, value: "true"}
- at: 12.0
set:
- {entity: fire_alarm, field: active, value: "true"}
The world's door_edge_1_1 spawns unlocked, and this scenario locks it at
t=0 so the corridor starts sealed. At t=12s the fire_alarm schedule's active predicate goes true and
asserts regime alarm: a gate with unlock_on: alarm reports locked=false,
a pressure plate with press_on: alarm reports pressed=true and holds its
drives door open, and an elevator with recall_on: alarm refuses calls and
holds its cabin door open, all as pure regime consults with no per-effect
timeline entry. See
worlds/three_storied_residential/scenarios/fire_alarm/scenario.yaml
for the reference timeline.
A stimulus drives behavior-tree agents whose agent type declares the need
and a transition on it, as for pedestrian stimuli. Here
edge_0 waits until the robot comes within 4.2 m of (12.1, 4.0) and the
d_* group switches at t=8s:
timeline:
- when: "robot within 4.2 of 12.1,4.0"
stimulus: [{agents: [edge_0], need: go, intensity: 1.0}]
- at: 8.0
stimulus: [{agents: [d_1, d_2, d_3], need: form}]
Every agent of a stimulus is one of the scenario's humans, loading fails on
any other name. With the arena human backend loading also fails when the
agent type of an agent (its agent.agent_type) neither declares the need nor
waits for it as the until event of an autonomous step. A stimulus the human
simulator does not take at runtime is logged and skipped, the other agents
still get theirs. Only the arena human backend acts on stimuli: under any
other backend a scenario with stimulus entries fails to load with an error
naming human:=arena.
Every semantic snapshot of the env carries a timeline entity (kind
timeline). Its discrete field stimulus.<agent>.<need> holds the sim time
in seconds of the tick on which that stimulus was first taken by the human
simulator in the episode, and a stimulus that never landed has no field.
3. Generate the map¶
touch_world my_world
This reads worlds/my_world/world.yaml and writes a preview map per level,
map/<level id>.png and map/<level id>.yaml. The runtime does not need
them, it renders its own map from world.yaml.
To regenerate with visible static obstacle footprints:
touch_world my_world \
--assets red \
--assets-label blue \
--assets-bbox "((-.5, .5), (-.5, .5))"
To rewrite world.yaml itself in canonical form (short forms, rounded
numbers, defaults left out) with its kg.yaml:
touch_world my_world --all
touch_world accepts --resolution <float> (metres per pixel, default 0.05).
Format 1 worlds¶
Worlds written before format 2 (one <level id>/world.yaml of zones: per
level, walls, doors, objects and lights nested in zones) still load, with a
deprecation warning, through the legacy loader in
tree/World/legacy.py. It
hoists zone walls, doors, elevators, entities, sounds and lights to the
level, schedules and signals to the world, turns destination: "1.a" into
destinations: [a], drops exact repeats, renames different things that share
a name, and keeps zone descriptions and which zone listed which thing in the
KG. A graph.yaml beside a format 1 world (the airchitect scene
graph) is lifted into the KG too: a node becomes a room with its
type, description and attributes, a wish-list asset an unplaced obj, and
an edge a connection through the door named door_<edge id> or
door_<a>__<b> plus its position facts (an edge with neither door states no
connection, geometry derives it). Migrating writes it into kg.yaml
and removes graph.yaml. Format 1 scenarios (static:/dynamic:, the obstacles:
wrapper, goal:, start_floor) load the same way. Rewrite a world and its
scenarios in place with:
touch_world my_world --migrate
Nothing writes format 1.
4. Add a scenario¶
Create worlds/$WORLD/scenarios/default/scenario.yaml:
format: arena-scenario/2
robots:
- start: [1.0, 1.0, 0.0]
phases:
- {goto: [8.0, 8.0, 0.0]}
An empty scenario (robot start and phases only) is valid. Phases run in order
and are {goto: <pose or zone/door/elevator/pedestrian name>}, {gesture: <name>},
{reach: <named target | random | pose>}, or a hold phase with neither (the robot
stays where it is), or {loop: [<phases>], until: "<...>"} (the phases repeated
in order until the until holds, which drops the running phase and continues
after the loop). Every phase takes until: "<clause>[ or <clause>...]", a
clause being <atom>[ and <atom>...][ for <seconds>] (completes once any clause
has held, every atom together, for its dwell, after arrival for a goto, so
caller plays beckon for 0.5 or robot within 2.5 of caller), hold_time (park
seconds on a goto), signal
(the robot ends the goto itself by sending that signal, such as arrived, and
is judged against the tolerance at that moment), conditions: (clauses judged
over this phase only), text: (the instruction handed to language-conditioned
planners, instruction: is accepted as the same key) and on_failure. A robot
entry may also carry level (the level it starts on), goal_level (the level
its goto phases aim at) and conditions: judged over its whole phase list.
robots:
- start: [1.0, 1.0, 0.0]
phases:
- {goto: kitchen}
- {until: "not visitor_1 in kitchen"}
- {goto: sofa, hold_time: 2, conditions: [{op: never, p: "robot in hallway"}]}
- loop:
- {goto: [10.0, 18.0, 1.5708]}
- {goto: [10.0, 2.0, -1.5708]}
until: "robot sees caller within 12 and caller plays beckon for 0.5"
- {goto: caller, tolerance_radius: 2.5, hold_time: 0}
Atoms are the conditions: grammar of worlds/README.md:
<entity>.<field> == <value>, <subject> in <zone>, <subject> within <r> of
<subject>, <subject> sees <subject> [within <r>] (in range, default 12 m, with
no wall of the rendered wall grid between them), <ped> plays <clip> and not
<atom>. Either within subject may also be a point x,y (no spaces), in the
frame of a scenario pose without level, so robot within 4.2 of 12.1,4.0.
A goto to a pedestrian by name is judged by tolerance_radius around the
pedestrian.
Add static obstacles under objects: and pedestrians under humans:, with
the same schema as a world level's lists plus level (the level an entry
stands on, multi-level worlds). An object's humansim: block (type,
capacity, satisfies, interaction_radius, formation, seats) is
forwarded to humansim as its WorldObject, so a go_to step or an interaction
target: can name the entry by type.
For HumanSim (arena_humansim) pedestrians include an agent: block. The
agent_type is either a built-in type (adult, elder) or a path-relative
agent-type YAML file (./doctor.yaml) defining scripted behavior
(sequences, interactions). waypoint_mode (repeat, reverse, once,
random) sets how the waypoints replay, and a hunav: block carries the
HuNav backend's own keys:
humans:
- name: agent_1
model: female_adult_medical_01
agent: {agent_type: adult, desired_velocity: 1.2}
pose: [2.0, 5.0, 0.0] # yaw in radians
waypoints:
- [2.0, 5.0]
- [8.0, 5.0]
A scenario can also carry regions: (named pedestrian source/sink spawners
for arena_humansim) and conditions: (episode success/failure clauses). Its
sounds:, lights: and timeline: keys are the ones covered above. Scenario
lights count as declared, like the world's, and go at the next reset, and so
do its regions. See
worlds/README.md for the full key list of every
top-level key.
Robots: floors, elevator rides, mechanism map¶
Robot poses are level-local when the robot entry carries level /
goal_level (the level ids of a multi-level world): start is shifted by
level's origin, every goto by goal_level's. A pose given as a
zone/door/elevator name is resolved on the merged world and is never shifted.
When a goto lies on another level than the robot, elevator rides are inserted
automatically (shortest ride sequence over every elevator's destinations).
via: on a goto phase lists elevators to ride explicitly, in order, before heading for the pose. This is how a robot
rides a same-level pair such as elevator_ground -> elevator_top. An entry is
an elevator name, or {elevator: <name>, to: <destination>} to pick
one destination of a multi-destination elevator (default: the destination
cabin closest to where the robot heads next).
robots:
- start: [2.0, 5.0, 0.0]
phases:
- {goto: [31.0, 22.0, 0.0], via: [elevator_ground]}
- start: lobby
level: "0"
goal_level: "2"
phases:
- goto: [4.0, 4.0, 0.0]
via: [{elevator: hub, to: east_lift}]
Each ride is its own phase: the robot drives into the boarding cabin, stays
there until the elevator teleports it, and continues once it stands in the
destination cabin. The ride is judged like any other goto, with the boarding
cabin as its target and robot in <destination cabin> as its until atom, and
a robot waiting in the cabin does not count as stalled. For a multi-destination elevator the robot writes the
elevator's target field (the destination cabin, e.g. east_lift) when the
ride starts and again when it boards, and writes ""
(back to round-robin) when it arrives. Single-destination elevators are never
written.
The planner map is blind to door state by default: doors and elevators are
painted free and a closed door is only seen by the robot's sensors. Setting
the scenario task param mechanism_map: true (suite stage
config: {scenario: {mechanism_map: true}}) serves the robot's planners a map
in which every door the robot cannot open right now is occupied, through the
env's normal map_server (LoadMap, same topic as the static map). A door
counts as unopenable when its activation_distance is <= 0, or when it
carries a gate that is effectively locked and does not list the robot (robot
name or sim path, as in the gate's authorized) and it is not open or opening
at the moment (a plate, regime or authorized pedestrian may hold it open). It
is freed again as soon as that changes. With several robots in one env, one
authorized robot frees the door for all. Benchmarks keep the default (false)
unless the suite is about mechanism-aware planning.
Pedestrians and world mechanisms¶
Everything in this section needs the humansim backend (human:=arena); other
backends ignore the extra keys and walk the plain waypoint list.
Waypoints. A humans: waypoint is a pose list ([x, y], [x, y, z]), a
zone/door/elevator name, or a dict with exactly one of zone:, elevator: or
flat x/y[/z], plus optional level: (the coordinates are then
level-local) and dwell: (seconds to stand there before walking on). {elevator: <name>} is a ride: the ped walks
to the cabin centre, waits inside until the elevator teleports it, then
continues with the next waypoint. When two consecutive waypoints (or the
ped's start and its first waypoint) lie on different levels, the boarding
cabins of the shortest elevator route are inserted as rides automatically.
For a multi-destination elevator the boarding ped writes the elevator's
target (the destination toward its next waypoint) when it reaches the
cabin centre. A ped holding for a ride never sees the next level's waypoint,
so it stays in the cabin however long the elevator takes. waypoint_mode:
reverse becomes an explicit round trip with rides both ways, repeat also
routes the way back to the first waypoint, random with dwell or rides walks
the list in order.
humans:
- name: commuter
model: arenian
pose: [2.0, 3.0, 0.0]
agent: {agent_type: adult}
spawn_delay: 4.0 # appears 4 s into the episode
waypoint_mode: once
waypoints:
- {x: 6.0, y: 3.0, dwell: 5.0} # wait at the board for 5 s
- {elevator: elev_ground} # same-level pair: explicit ride
- {x: 4.0, y: 4.0, level: "2"} # other level: rides inserted
spawn_delay: <s> (default 0) keeps the ped out of the simulation (engine,
roster, physics sim) until that many sim seconds after the episode's spawn.
Mechanism state as stimuli. Every discrete or predicate change of a door,
elevator, signal, schedule, gate, pressure plate or sound reaches every ped as
the stimuli <entity>.<field>==<old> at 0 and <entity>.<field>==<new> at 1,
after each ped's sampled reaction_time. Entity names are authored (no env
prefix, no level suffix), the atom is the conditions: atom without spaces:
elev_a.cabin_door==open, door_a.state==closed, gate_1.locked==true,
sig_1.state==red. Continuous fields (progress, eta, occupants) are not sent.
An agent type reads one as a need pinned at 0 (it then holds 100 while the
state lasts) and waits on it with a transition. A ped spawned later starts
from the current values. until: on an autonomous step can name the same
atom, it fires when the state is entered.
needs:
"elev_a.cabin_door==open":
initial: {mean: 0.0, clip_low: 0.0, clip_high: 0.0}
decay_rate: {mean: 0.0, clip_low: 0.0, clip_high: 0.0}
sequences:
wait:
steps: {stand: {duration: {mean: 3600.0}}}
then: wait
transitions:
- when: {"elev_a.cabin_door==open": {above: 50.0}}
goto: board
To reuse one agent type for several elevators, give the humans: entry
semantic_alias: {elevator: elev_a}: that ped also receives
elevator.<field>==<value> for elev_a, and the agent type keys on
"elevator.cabin_door==open". A ped an elevator teleports receives the
stimulus teleported at 1 (declare it with a fast decay_rate for a pulse).
World objects. Every door is a world object named after it with type
door at the doorway centre. Every elevator is a world object named after it
with type elevator at the cabin centre, plus <name>/queue of type
elevator_queue in the cabin doorway with a line formation that grows out
into the lobby (first slot 0.7 m outside the door, 0.8 m apart). A go_to or
QUEUE_USE step targets them by name or, by type, the nearest one.
Shipped agent types (configs/agent_types/mechanisms/, from a scenario
directory ../../../../configs/agent_types/mechanisms/<file>):
elevator_rider.yaml:QUEUE_USEon the nearestelevator_queue, board the nearestelevatorwhenelevator.cabin_door==open(needssemantic_alias: {elevator: <name>}), back into the line if the door closes first (20 s patience), wait in the cabin, afterteleportedwalk to the nearest marker of typeelevator_exit. The whole line is released at once when the door opens.door_holder.yaml: walk to the nearestdoor, stand in the doorway for about 8 s (4 to 14), then walk to the nearest marker of typedoor_holder_exit.
humans:
- name: rider_1
model: arenian
pose: [2.0, 3.0, 0.0]
semantic_alias: {elevator: elev_a}
agent: {agent_type: ../../../../configs/agent_types/mechanisms/elevator_rider.yaml}
objects:
- {name: exit_a2, model: Common/Object/painting_1, pose: [21.0, 5.5, 0.0], humansim: {type: elevator_exit}}
A marker is any objects: entry with a humansim: {type: ...} block (a wall
painting, as the exit of the fire-alarm scenario). Peds walk to its pose, so put it in
reach.
5. Add world-local assets (optional)¶
Assets placed under worlds/$WORLD/assets/<domain>/<Type>/<name>/ are
resolved before shared-local ($ARENA_ASSETS_DIR_LOCAL), imported ($ARENA_ASSETS_DIR_IMPORTED) and network-fetched
assets. <domain> defaults to Common if you have no reason to scope the
asset further. Five asset kinds can be shipped this way:
| Type | Directory contents | Referenced as |
|---|---|---|
Object |
3D model files (SDF and/or USD), optional annotation.yaml with a bounding_box |
model: on an objects: entry |
Human |
3D model files (SDF) | model: on a humans: entry |
Material |
<name>.mdl plus its texture files |
a zone floor, a ceiling material, a wall or door material |
Wall |
<name>.yaml, a WallDescription (see configs/walls/README.md) |
kind: on a walls: entry or a zone outline |
Sound |
<name>.yaml (a sound manifest, see configs/sounds/README.md) plus its wav files |
asset_id: on a sounds: entry |
To ship a custom wall style:
mkdir -p worlds/$WORLD/assets/Common/Wall/my_style
# create worlds/$WORLD/assets/Common/Wall/my_style/my_style.yaml
Then reference it in world.yaml walls as kind: my_style. The other four
kinds follow the same assets/<domain>/<Type>/<name>/ layout. Objects, humans
and materials carry model or material files instead of a preset YAML, a sound
carries its <name>.yaml manifest beside the wav files.
A wall style can also carry a captured look: a 3D Gaussian splat that Isaac
renders in front of the wall. The
splat wall example shows the wall
kind, the arena_assets README how to build the asset.
A captured object (arena-assets splat --object) is placed like any other
object, as an objects: entry. Isaac renders it and its sensors see it. It is
visual only, so robots drive through it, unless the bundle carries a collider.
Any object bundle can carry a collider mesh that annotation.yaml names under
collider:. It is baked into the bundle's USD and SDF models, where it takes
the place of the visual meshes as the collision. Without one, objects collide
with their visual meshes in Isaac and Gazebo alike. arena-assets collider
attaches one to any object bundle, see
Colliders.
6. Generate with the AI pipeline (alternative)¶
scripts/generate_world posts a natural-language prompt to a generation server and extracts the resulting zip into a world directory:
generate_world "A hospital floor with 4 patient rooms and a central hallway" \
--endpoint http://localhost:5501 \
--outdir my_world
--outdir is resolved relative to <arena_simulation_setup share>/worlds/ if
it is not an absolute path. The server must be running separately; this script
is a thin client.
After generation, run touch_world my_world to ensure map/ is current.
7. Validate¶
from arena_simulation_setup.tree.World import WorldIdentifier
world = WorldIdentifier('my_world').resolve_sync().load()
for level_id, level in world.levels.items():
print(level_id, f"{len(level.zones)} zone(s), {len(level.all_walls)} wall segments")
This raises FileNotFoundError if world.yaml is missing and ValueError
naming the YAML path if it is malformed. The JSON Schema of the KG
comes from arena_simulation_setup.kg.schema_for.
List all scenarios:
view = WorldIdentifier('my_world').resolve_sync()
for scenario_id in view.scenario.listall():
print(scenario_id.name)
Load a scenario:
scenario = view.scenario('default').resolve_sync()
print(scenario.load())
Reachability (NAV@r)¶
nav_at_r reports how much of the floor a robot of footprint radius r can
reach, the NAV@r metric of the IndoorBench paper:
ros2 run arena_simulation_setup nav_at_r my_world
ros2 run arena_simulation_setup nav_at_r my_world --robot jackal --walls-only
ros2 run arena_simulation_setup nav_at_r /path/to/world --radius 0.3 --start 2.0,3.5 --json
--image map.png paints the result onto the map: the dominant component in
green, every stranded component in another color, free cells with clearance
below r in light grey, entities in brown and walls in black.
Each level is rasterized at 0.05 m per cell with the map rasterizer, walls
plus the footprint of every static entity (its bbox, else the
bounding_box of its model's annotation.yaml, entities whose box starts
above 2 m are skipped). A cell is traversable when its clearance is at least
r (default 0.267 m, the Jackal radius), and traversable cells group into
8-connected components. With m_i the share of traversable cells in component
i and w_i its share of spawn cells (clearance of at least 0.45 m), NAV@r is
sum_i w_i * m_i: 1.0 when every traversable cell is reachable from every
spawn, lower the more floor is stranded in other components.
| Column | Meaning |
|---|---|
NAV@r |
the expectation above, n/a when no cell admits a spawn |
components |
number of traversable components |
largest |
share of traversable cells in the largest (dominant) component |
start |
with --start X,Y (level frame, meters): share of the component holding the start, snapped to the nearest traversable cell |
no bbox |
static entities without a resolvable bounding box, out of all static entities. They are not rasterized |
cut-off zones |
zones of at least 2 m2 with no cell in the dominant component although they are traversable on walls only |
--walls-only drops the static entities. A zone that stays cut off there has a
layout fault (a missing, misplaced or too narrow door), one that is cut off
only in the furnished run is blocked by furniture. --robot takes a robot name
from the installed arena_robots package or a robot directory and reads
radius from its caps/mobile.yaml. A world name resolves like at runtime,
and name[0,1] restricts the levels.
The same numbers are available in Python:
from arena_simulation_setup.metrics.nav_at_r import nav_at_r, resolve_world
view, levels = resolve_world('my_world')
for level_id, result in nav_at_r(view, radius=0.267, levels=levels).items():
print(level_id, result.value, result.components, result.cut_off_zones)