Docs menu
Drive intent
The /drive_intent topic: what a driving node plans to do and which limit is in charge, published as JSON for the dashboard without risk to the control path.
/drive_intent carries what a driving node is trying to do and why, once per decision. The dashboard draws it as a curved arrow ahead of the car and a panel naming the limit currently holding the speed down. Measured speed and heading show what the car did. Intent shows the plan before the car drives it, so a wrong plan can be seen before it reaches a wall.
gap_follow_node, pure_pursuit_node and neural_racer_node publish it. The schema and the prediction code are in src/drive_intent/GitHub, which imports no ROS, plus a single C++ header for the team repositories.
The topic
| Topic | Type | Rate |
|---|---|---|
/drive_intent |
std_msgs/String containing one JSON object |
intent_rate_hz, 20 Hz by default, against a 40 Hz control loop |
JSON in a string was chosen over a custom message type so that racerbot_a and racerbot_b, which build in their own workspaces, can publish it with no build dependency beyond std_msgs. The cost is no type checking at compile time. schema.validate() enforces the format, and every consumer treats incoming messages as untrusted: the dashboard drops a malformed one with a warning.
Safety rules for publishers
Intent is a diagnostic inside nodes that steer the car. Three rules keep it from affecting the drive command:
- Publish after the command. The intent call is the last statement of each tick, after the drive command has gone out.
- Wrap it in one
try/except. An error in the intent code must never stop the node. After repeated failures,FailureLatchinthrottle.pyturns intent off, logs once, and the node keeps driving. - Read only, write nothing. Intent uses only values the control path already computed, and the control path reads nothing intent produces.
Both nodes have tests that break intent generation on purpose and assert that the car still drives, for example test_gap_follow_intent.pyGitHub.
The message
{
"v": 1,
"stamp": 1786062657.489,
"node": "gap_follow_node",
"frame": "base_link",
"state": "corner_fallback",
"severity": "caution",
"horizon_s": 1.5,
"desired_steering": 0.2130,
"commanded_steering": 0.1400,
"desired_speed": 2.400,
"commanded_speed": 1.900,
"path": [{"x": 0.0, "y": 0.0, "v": 2.4}],
"commanded_path": [{"x": 0.0, "y": 0.0, "v": 1.9}],
"factors": [
{"name": "curve cap", "value": 2.410, "unit": "m/s", "binding": false},
{"name": "corner cap", "value": 1.900, "unit": "m/s", "binding": true}
],
"targets": [{"kind": "gap_target", "x": 1.761, "y": -0.385}]
}| Field | Meaning |
|---|---|
v |
Schema version. Consumers reject a version they do not know. |
stamp |
The publisher’s clock, in seconds |
node, state |
Which node, and its decision state, such as gap_follow or deadman_released |
severity |
drive, caution or stop, derived from the state and the commanded speed. Zero commanded speed is always stop. |
desired_* |
What the algorithm asked for, before the steering and acceleration rate limits |
commanded_* |
What was published this tick |
path, commanded_path |
Predicted trajectories in the car’s frame, as x, y and speed. The dashboard draws the first solid and the second dashed. |
factors |
Every speed limit evaluated this tick. binding marks the one in charge. |
targets |
Points of interest, such as the gap target |
reason |
Optional. A sentence explaining the decision, sent on a state change and once per decision_log_period_sec |
wedge |
Optional. An angular span to shade, such as the gap gap_follow chose |
validate() limits each path to 512 points, factors and targets to 32 entries each, and reason to 2000 characters.
How the path is predicted
path answers one question: if the algorithm got exactly what it asked for, where would the car be over the next horizon_s seconds? It is not where the car is going, which odometry already shows. predict.pyGitHub rolls the bicycle model forward in samples steps of = horizon_s / (samples − 1).
Each step holds the speed and steering constant, so the car moves along an arc of curvature . Over a step of length the heading turns by , and integrating the heading along the arc gives the end point exactly, the same formula Stanley’s delay compensation derives:
def arc_step(x, y, yaw, speed, steering, wheelbase, dt):
kappa = curvature_from_steering(steering, wheelbase)
ds = speed * dt
if abs(kappa) < STRAIGHT_CURVATURE_EPS:
return x + ds * math.cos(yaw), y + ds * math.sin(yaw), yaw
dyaw = kappa * ds
radius = 1.0 / kappa
nx = x + radius * (math.sin(yaw + dyaw) - math.sin(yaw))
ny = y - radius * (math.cos(yaw + dyaw) - math.cos(yaw))
return nx, ny, yaw + dyawThe exact arc is used instead of a simple Euler step because over a 1.5 s horizon at full lock the two differ by several centimetres, the scale at which someone reads the arrow to decide whether the car will clip a cone. Below a curvature of m⁻¹ the step is a straight line, because would overflow.
The steering at each step depends on the node:
| Node | Steering along the path |
|---|---|
gap_follow_node |
Constant. It chose a heading, not a path, so its plan is one arc. |
pure_pursuit_node |
Re-evaluated at every step against the racing line, so the arrow bends through the corner ahead |
pure_pursuit_node with MPC |
MPC’s own planned sequence, replayed rather than solved again |
neural_racer_node |
The network’s own forecast of its path |
Why factors matter most
The speed a node commands is the minimum of several limits, and a single number hides which one won. factors lists all of them, and bind_min() marks the smallest as binding, or every one of them on a tie. Reading the binding factor answers “why is the car this slow here” directly.
Add intent to a new node
- Import
drive_intentand build the message from values the tick already computed. - Call it after the drive command, inside one
try/except, behind aFailureLatch. - Throttle it to
intent_rate_hz. - Add a test that breaks intent and asserts the node still publishes its command.
The porting guide for the team repositories, including the C++ header, is in docs/drive-intent.mdGitHub.
Checked against
car 1 commit 0265263
and
car 2 commit b81b291, before the two workspaces merged.