Skip to navigation

Forklift Controller

View as Markdown

Drives the simulated forklift. The forklift-controller service runs one ROS 2 node per truck, closed-loop-testing/forklift-controller/robot_controller.py, that reads the forklift’s odometry, steers toward the next pose on a stored route, and publishes cmd_vel to the Forklift Control Graph. The code comes from the forklift-controller image, not from a bind mount of the checkout, so source edits need up -d --build.

Role in the Loop

The controller is a stimulus generator. It keeps the forklift driving a fixed route so that perception always has a vehicle to detect, Behavior Analytics always has ROI and tripwire crossings to report, and the Safety Core always has a decision to make. Under PSF_APP=pxc or both it also obeys the Safety Core’s proximity decisions (see Proximity Decisions). Nothing else drives the forklift — character motion comes from the IRA behavior trees inside Isaac Sim (see Isaac Sim Configuration).

waypoints/<map>/<robot>.json /safety/proximity/pair (pxc / both)
│ │
v v
forklift-controller ── /forklift_b/cmd_vel ──> Control graph ──> articulation
^ │
│ physics
└────────── /forklift_b/odom <── Odometry graph <────────────────┘

The controller publishes in the ROS twist convention; the sign flips for the forklift asset’s vehicle frame are applied on the Isaac Sim side (reverse_logic in the model block of robots.yaml — see Forklift Control Graph).

ROS 2 Interface

One node per robot, named <robot>_controller (forklift_b_controller for the default truck), so two controllers in one ROS domain do not collide. The robot is the service’s ROBOT_ID, which must name a robot in the scenario’s fleet file. Every topic below uses RELIABLE QoS with depth 10. The drive and feedback topic names come from the robot’s own block in the fleet file — control.cmd_vel_topic and odometry.odom_topic, the same keys Isaac Sim builds its graphs from — so the two sides cannot disagree.

DirectionTopicTypeNotes
Subscribes/forklift_b/odom (odometry.odom_topic)nav_msgs/OdometryPosition feedback. No cmd_vel is published before the first sample arrives.
Subscribes/safety/commandstd_msgs/String (JSON)Never namespaced — every controller in the ROS domain receives every message.
Subscribes/clockrosgraph_msgs/ClockDetects simulation stop, play, and reset.
Subscribes/safety/proximity/pair (proximity.pair_topic)std_msgs/String (JSON)Only when proximity is enabled (PSF_APP is pxc or both).
Publishes/forklift_b/cmd_vel (control.cmd_vel_topic)geometry_msgs/Twist20 Hz while moving.
Publishes/<robot>/statestd_msgs/String (JSON)5 Hz.
Publishes/<robot>/proximity/state (proximity.state_topic)std_msgs/String (JSON)The proximity level being applied; Isaac Sim’s indicator disc reads it.
Publishes/<robot>/markersvisualization_msgs/MarkerArray5 Hz, a state label above the forklift for rviz.

The state message carries robot_id, state, speed_factor, pose_idx, total_poses, and completed, plus proximity, proximity_separation_m and proximity_fault — the cheapest way to watch progress without parsing the log. A latched nav_msgs/Path publisher on /<robot>/planned_path is also created but never written to, so the route cannot be visualized from ROS.

Route Following

The route is loaded once at startup from the waypoint file. Each pose is converted from the file’s world coordinates into the odometry frame by subtracting origin. With pose inversion on (the default), the route is then rotated 180° about that origin: x and y are negated and π is added to theta. Separately, drive.heading_offset — 180° by default — is added to the yaw read from odometry. Both defaults exist for the shipped forklift asset (see robot_front in Forklift Odometry Graph).

Advancing Along the Route

A pose is reached when the forklift is within 0.35 m of it, or within drive.end_tolerance for the last drive.end_pose_count poses of the route. The index then advances by one.

Steering is pure pursuit: the controller walks forward from the current pose, up to 15 poses and never across a segment boundary, until it has accumulated max(0.8, drive.speed × 1.5) metres of path, and steers at that carrot. Angular velocity is the heading error times 1.2, clamped to drive.angular_speed. On a segment marked reverse the desired heading is rotated by π and linear.x is negative.

Linear speed is then shaped — scaled by max(0.4, distance / 0.5) inside 0.5 m of the target, by 0.4 above 30° of heading error and by 0.2 above 60°, then floored at 0.15 m/s in magnitude so the forklift keeps steering authority.

At the end of the route, drive.loop: true (the shipped robots.yaml) wraps the index back to zero and logs Loop: Restarting path. With looping off the controller publishes a stop, logs ✓ Path completed!, and transitions to IDLE — the scene keeps rendering but stops producing crossings.

Overshoot Recovery

Following live odometry means the forklift can pass a pose it never came close enough to retire, and then circle back for it. Three guards catch that.

Skip ahead: when the target is more than 1 m away and at least three poses remain after the current target, the controller scans up to 9 poses ahead within the same segment and jumps to the nearest one — ⏭️ SKIP AHEAD. Occasional lines are normal; a burst of them means the route is tighter than the forklift can turn at drive.speed.

Passed-pose skip: when the distance to the target has grown for more than 20 control ticks (roughly one second), the closest approach was under 0.5 m, and the forklift is now more than 0.3 m past that closest approach, the pose is retired — ⏭️ SKIPPING pose. The last pose of the route is excluded from this skip. The distance printed in this line always reads infm — the pose index is the useful part.

Spiral timeout: at the final pose only, when it has been the target for longer than drive.spiral_timeout and the forklift is within 2 m, the controller declares the route complete — 🔄 SPIRAL DETECTED. This ends the run: it stops and goes IDLE even with looping on.

Simulation Stop, Play, and Reset

The controller tracks /clock so that an operator driving the timeline in the Isaac Sim UI does not leave it commanding a frozen scene:

  • No /clock for 2 seconds — publishes a stop and holds, logging ⏸️ SIM STOPPED (no /clock) - pausing robot. It resumes when the clock does.
  • Simulation time jumping backwards by more than 0.5 s — restarts the route, logging 🔄 SIM RESET detected. Only the backwards jump restarts the route: a pause and resume that leaves simulation time where it was resumes from the current pose.
  • An odometry position jump of more than 5 m — restarts the route, logging ⚡ ODOM JUMP detected.

A restart does not return to pose 0. The controller scores every pose by distance plus a heading penalty of 3 m per radian and continues from the best match, logging 🔁 Path reset.

State Machine and Speed Scaling

StateSpeed factorEntered by
IDLE0.0The idle command, a completed route, or 🔄 SPIRAL DETECTED
MOVING1.0Startup (unconditional, no command); proceed, start, go, resume
PAUSED0.0stop, yield, pause
SLOW0.3slow, buffer, caution

Published linear speed is drive.speed times the state’s factor times the command’s speed_factor (clamped to 0.0–1.0, 1.0 when absent), before the near-target and heading-error shaping above, and before the proximity limit (Proximity Decisions). In IDLE and PAUSED nothing is published at all: the controller emits one zero Twist on entering the state and then stops feeding the loop.

The node transitions to MOVING as soon as it starts, with no command and no handshake with the simulation. 🔄 State: IDLE → MOVING therefore appears in every startup log and is not evidence that the forklift is moving — an advancing 📍 pose= line is.

Safety Commands

/safety/command is the topic the communication layer republishes each safety decision on. The controller subscribes to it and reads three fields out of the JSON payload: robot_id (optional; a message naming a different robot is ignored), command, and speed_factor.

An ATL decision does not change how the forklift drives. The communication layer publishes command as a numeric opcode and the controller acts on string verbs only, so the decision is discarded without a log line and the stimulus keeps running under any decision. Observe the decision through the in-scene safety indicator and the communication layer log instead. Proximity decisions are different: they arrive on their own topic and do drive the truck (next section).

The string verbs in the state table above are what move it, and the bundled helper sends them. It accepts proceed, stop, slow, idle, go, pause, and resume, with --speed to override the speed factor and --robot to target one controller:

# run inside the container, which carries the helper and the ROS 2 environment
docker exec forklift-controller bash -lc \
"source /opt/ros/jazzy/setup.bash && python3 /app/test/send_command.py slow"

Expected output:

[INFO] [...] [forklift_b_controller]: 📥 [forklift_b] Command: slow | speed_factor=0.30
[INFO] [...] [forklift_b_controller]: 🔄 State: MOVING → SLOW (speed_factor=0.30)
[INFO] [...] [forklift_b_controller]: 📍 pose=9 ▶FWD dist=0.71m cmd=(0.15,0.08) robot=(5.02,-13.37,178°)

speed_factor=0.30: the helper’s slow default, applied on top of the SLOW state’s own 0.3.

cmd=(0.15,…): the linear value has hit the 0.15 m/s floor — the two 0.3 factors compound.

reset is handled before the state machine: it re-snaps to the nearest heading-compatible pose, exactly as after a simulation reset, and starts moving again.

Proximity Decisions

With PSF_APP=pxc or both, the communication layer republishes each proximity decision of the Safety Core on /safety/proximity/pair, and the controller limits the Twist it is about to publish:

  • STOP — zero linear and angular speed.
  • REDUCE — linear speed capped at proximity.reduce_speed; angular speed is scaled by the same ratio, so a truck slowed mid-corner stays on the corner it was driving.
  • NORMAL — the route speed.

A STOP holds proximity.stop_hold_s past the last packet asking for one, and a REDUCE proximity.reduce_hold_s. Silence keeps the last level: no person in view means no decision, not NORMAL. The link itself is watched: the decision maker heartbeats every 5 s, and proximity.stale_s with no message at all stops the truck with fault psf_link_stale until the next decision. A safety-critical packet without a level (an SDM fault) also stops the truck.

The Safety Core names no robot in a proximity decision, so every truck on one pair_topic obeys every decision. The controller logs a WARNING when more than one truck shares the topic; give a second truck its own proximity.pair_topic or enabled: false.

Waypoint File

Each controller reads one route, /app/waypoints/<map id>/<ROBOT_ID>.json. The whole closed-loop-testing/forklift-controller/waypoints/ tree is bind-mounted read-only at /app/waypoints, and the map id comes from the scenario’s waypoints: entry in scenarios.yaml (warehouse_20x20 for warehouse_20x20_1fl), or from WAYPOINTS_MAP for a single run. Coordinates are metres in that scene’s world frame, so the wrong map drives a lane that was never validated in that warehouse. Author routes with the Forklift Waypoint Generator on the Isaac Sim Configuration page, which also documents the export format.

Three top-level keys are read:

  • origin — world_x and world_y, the zero of the odometry frame. Every pose is expressed relative to it. It must be where the truck spawns (spawn.position in robots.yaml); deployments/scripts/preflight.py compares the two before Isaac Sim boots.
  • poses — the interpolated route: x and y in world metres, theta in radians.
  • segments — run-length ranges over poses, pose_count entries each, whose reverse flag drives that stretch backwards. The shipped file has two: a forward leg out and a reverse leg back.

Everything else in the file is authoring metadata and is not read — including the waypoints array itself and the velocity field on each of its entries. Route speed comes from drive.speed and is uniform; there is no per-waypoint or per-segment speed.

pose_count is walked cumulatively and a pose past the last range falls back to forward travel, so a hand-edited file whose pose_count values sum to less than the number of poses drives its trailing reverse stretch forwards with no warning.

Configuration

How a truck drives lives in the fleet file, not in the environment: one environment variable would set every truck in the fleet at once. Each knob resolves as a launch flag (launch_controller.sh, docker_run.sh), else the robot’s drive: block, else its model’s drive: block, else the built-in default. The controller prints the value and source of each one at startup ([drive]).

robots.yaml keyDefaultDescription
drive.speed1.5Base linear speed in m/s, uniform over the whole route
drive.angular_speed0.4Angular velocity clamp in rad/s
drive.heading_offset180.0Degrees added to the yaw read from odometry
drive.looptrueRestart the route at the end, so the Safety Core always has a moving target
drive.no_invertfalsetrue loads the route without the 180° rotation about origin
drive.end_tolerance1.0Arrival tolerance in metres for the last poses of the route
drive.end_pose_count5How many poses from the end use that larger tolerance
drive.spiral_timeout10.0Seconds at the final pose before the route is auto-completed
proximity.enabledPSF_APP is pxc / bothFollow the Safety Core’s proximity decisions
proximity.reduce_speed0.5m/s cap while the decision is REDUCE
proximity.stop_hold_s / reduce_hold_s2.0 / 1.0Seconds a STOP / REDUCE outlives the last packet that asked for it
proximity.stale_s10.0Seconds of silence, heartbeats included, before the link counts as dead

drive: refuses unknown keys, so a key indented one level too far is an error, not a silent default.

The service itself reads four variables: ROBOT_ID (set on the compose service, forklift_b), and SCENARIO, WAYPOINTS_MAP and PSF_APP from the profile env file.

Removed in 1.4. FORKLIFT_WAYPOINTS_DIR and --no-namespace are refused by name, so the controller does not start with them set. FORKLIFT_USE_NAMESPACE, FORKLIFT_ROBOT_ID, FORKLIFT_WAYPOINT_FILE and the drive variables (FORKLIFT_BASE_SPEED, FORKLIFT_HEADING_OFFSET, FORKLIFT_LOOP_PATH, FORKLIFT_ANGULAR_SPEED, FORKLIFT_NO_INVERT, FORKLIFT_END_TOLERANCE, FORKLIFT_END_POSE_COUNT, FORKLIFT_SPIRAL_TIMEOUT) are no longer read: use the drive: block, ROBOT_ID on the service, and SCENARIO / WAYPOINTS_MAP.

To follow a route of your own in the same warehouse, put one file per truck under a new directory and select it for the run:

# closed-loop-testing/forklift-controller/waypoints/my_routes/forklift_b.json
# in deployments/profiles/sil.env:
WAYPOINTS_MAP=my_routes

Recreate the service:

cd deployments && docker compose --env-file profiles/sil.env up -d --force-recreate forklift-controller

docker logs forklift-controller should show [waypoints] /app/waypoints/my_routes/forklift_b.json and Loaded path: <N> poses with the pose count of your file.

A missing route file is a silent failure — the service logs one Failed to load path: line, then reports Up forever and publishes no cmd_vel. Run deployments/scripts/preflight.py before Isaac Sim boots to check the fleet file, the controller services and the waypoint files together.

Logs to Watch

docker logs --tail 20 forklift-controller

Expected output:

[scenario] warehouse_20x20_1fl: robots=robots.yaml waypoints=warehouse_20x20
[drive] angular_speed=0.4(robots.yaml) end_pose_count=5(default) ... speed=1.5(robots.yaml) spiral_timeout=10.0(default)
[topics] cmd_vel=/forklift_b/cmd_vel odom=/forklift_b/odom proximity_pair=/safety/proximity/pair proximity_state=/forklift_b/proximity/state
[waypoints] /app/waypoints/warehouse_20x20/forklift_b.json
[INFO] [...] [forklift_b_controller]: Loaded path: 41 poses
[INFO] [...] [forklift_b_controller]: Odom received: (1.02, -13.41, 179.8°)
[INFO] [...] [forklift_b_controller]: 📍 pose=7 ▶FWD dist=0.62m cmd=(1.50,0.12) robot=(4.21,-13.38,178°)

[scenario] / [drive] / [topics] / [waypoints]: what the controller resolved, and where each drive value came from — the first lines to read when a run behaves oddly.

Loaded path: 41 poses: the waypoint file parsed, printed once at startup.

Odom received: the first odometry sample. Until it appears the controller is running but publishing no cmd_vel.

📍 pose=: printed every two seconds while moving. robot= coordinates that change from line to line are the only proof that the forklift is driving.

🔄 SEGMENT TRANSITION: a segment boundary, reading FORWARD→REVERSE or REVERSE→FORWARD. On the shipped route the first one is the forklift backing out of the trailer.

🦺 PXC [forklift_b] normal -> reduce_speed: a change of the proximity level applied (pxc / both), with the separation and the resulting speed.

Limitations

Decisions are not addressed to a truck

Cause: /safety/command is global, and the Safety Core names no robot in a proximity decision, so every controller in the ROS domain acts on every command, and every truck on one pair_topic obeys every proximity decision.

Workarounds: name robot_id in every command message; give a second truck its own proximity.pair_topic or proximity.enabled: false; or run one controller per ROS domain. Two-truck runs use the multi-robot compose profile (SCENARIO=warehouse_40x20), which starts forklift-controller-b2 for forklift_b2.

Up is not proof of motion

Cause: the service has no healthcheck and no depends_on, and the node starts, subscribes, and waits regardless of whether odometry ever arrives. A count of running containers is therefore not a check that the loop is closed.

Workarounds: gate on the 📍 pose= line advancing, or on /<robot_id>/state reporting a rising pose_idx.

Next Steps