Conventions¶
The quirks of frames, units, and signs that silently break code if violated. These aren't preferences. They're load-bearing. Skim them before reading any mission code or you'll spend hours wondering why the robot is driving the wrong way.
If "frame", "FLU", "ENU", or "anchor frame" are unfamiliar, read Concepts first.
Body-frame offsets are FLU¶
Offsets written in mission code are in the base_link frame, FLU:
| Axis | Direction |
|---|---|
+x |
forward |
+y |
left |
+z |
up |
Anything fed to /mavros/setpoint_position/local is map-frame ENU.
The conversion happens inside ConvertToControlsPose, so mission
code never writes a map-frame pose directly.
Why FLU for body-relative?
Because mission code is naturally body-relative: "go forward 2 m", "turn left 30°", "lift the camera up". FLU lets you write those intuitively without thinking about the world frame. The conversion service handles the world-frame maths for you.
Sign check before you tick
The single most common "the robot did the wrong thing" bug is a sign flip on a body-frame offset. Re-read your offset out loud before pushing: "I'm sending positive x, that's forward in FLU, so the robot should drive forward." Two seconds saves an hour.
Depth sign. ENU is negative below the surface¶
| Stack | Convention | Below the surface is… |
|---|---|---|
| map/ENU (this workspace) | ENU | negative z |
SEARCH_DEPTH, BIN_DEPTH_OVERRIDE_VALUE, every depth constant in
the BT modules is map/ENU. "1 m below the surface" is z = -1.0. If
you cross-reference NED-based code anywhere else, remember to flip the
sign.
If you see a positive depth, suspect a NED leak
A SEARCH_DEPTH = 1.0 somewhere in this workspace would mean "1 m
above the surface". Almost certainly a bug. If you spot one,
it's probably a copy-paste from NED reference code that didn't
get the sign flip.
URDF and SDF must change together¶
The BlueROV2 model is defined in two files:
| File | Drives | Used by |
|---|---|---|
urdf_path (urdf/bluerov2.urdf) |
Foxglove visualisation + ROS TF tree | robot_state_publisher |
model_sdf_path (sdf/bluerov2/model.sdf) |
Gazebo physics, collisions, sensors, thrusters | Gazebo |
Editing one without the other yields a silent mismatch. Foxglove shows one robot, physics simulates a different one, and the TF tree disagrees with where the robot really is. Mirror joint and TF frame names across both files.
Common silent mismatch
Adding a new sensor frame to the URDF without adding the corresponding link/joint to the SDF means the sensor pose looks right in Foxglove but the actual sensor data (camera image, IMU, DVL) comes from a slightly different pose. Pose estimators built against the wrong intrinsics drift, and you'll chase the drift in perception code that's actually fine.
The three independent rel-flags¶
Locomotion.action carries three boolean flags. The action server
evaluates each axis independently:
| Flag | True semantics |
False semantics |
|---|---|---|
move_rel |
pose.position.{x,y} is in base_link; converted to map via TF. |
pose.position.{x,y} is in map directly. |
depth_rel |
depth is added to the current map-z. |
depth is the absolute map-z setpoint. |
heading_rel |
heading is added to the current yaw. |
heading is the absolute map-yaw setpoint. |
Do not assume they travel together. The torpedo's front-yaw search
sends move_rel=True, depth_rel=False, depth=<absolute> so the
vehicle yaws (body-relative) while holding an absolute depth.
Conflating the flags is a common bug. Earlier versions of this code
treated depth_rel as following move_rel and the depth axis drifted
during searches.
Setpoint rate is 1 Hz¶
/mavros/setpoint_position/local is published at 1 Hz
(PUBLISH_RATE_HZ = 1.0 in locomotion_action_server.py). Higher
rates cause ArduSub to keep replanning and the vehicle stalls in
place. Don't raise it. If you need finer control, change the
target pose, not the rate.
BT tick is 10 Hz, executor is multi-threaded¶
BT ticks every 100 ms so action-client futures resolve responsively.
The action server uses a MultiThreadedExecutor with a
ReentrantCallbackGroup. Single-threaded executors deadlock on
goal.get_result() because the result callback can't run while the
action thread is blocked.
Sequence(memory=True) and Selector(memory=True)¶
py_trees Sequence and Selector re-run their children from child 0
on every tick unless memory=True. Mission legs take seconds; ticks
fire every 100 ms. Without memory=True, the tree restarts the first
leg forever.
Always use memory=True for top-level (and most nested)
sequences and selectors in this workspace. The square mission's tree
is Sequence(memory=True); the bin and torpedo trees' top-level is
Sequence(memory=True) wrapping a Selector(memory=True).
Adding a new BT leg / action client¶
Use bluerov_sim/scripts/goto.py as the template. It's a thin
py_trees action client that calls ConvertToControlsPose first, then
sends a Locomotion goal. See
Primitives for the full walkthrough.
Behaviours assemble goals; control logic lives in the server
Anything that looks like "if the error is X then publish Y" belongs in the action server, not in a BT leaf. BT leaves are declarative: "drive to this pose with this anchor". Resist the urge to put PID-ish logic in a behaviour.
Tree layout¶
Behaviour-tree mission code lives in three subdirectories under
bluerov_sim/bluerov_sim/:
| Directory | Contents |
|---|---|
bins/ |
bins.py (root tree), template_selector.py (rotated vs unrotated template picker). |
torpedo/ |
torpedo.py (root tree), move_and_shoot_seq.py (per-torpedo align + fire generator), point_correspondences_check.py (template selection helper). |
shared_trees/ |
search.py (square / layered / front-yaw search builders), plus pose / blackboard / TF-checker helpers shared between bin and torpedo. |
Conventions any new tree should follow:
- Hardcoded namespace. Each task module sets
NAMESPACE = "/bluerov/<task>"at the top of the file and usesfk()("full-key") wrappers when reading/writing the blackboard so keys can't collide across parallel missions. - Use
goto.py. Build legs by wrappinggoto.FromConstant,goto.FromBlackboard,goto.NFromConstant, orgoto.NFromBlackboard. Never call the action server directly from a behaviour. See Primitives. - Cluster in
map. Passout_parents="map"to every clustering helper so the clustered TFs end up in map/ENU. - Pre-seed shared blackboard keys. Set
/global/base_link = "base_link"and any/global/choice_*keys at the top of the root sequence before any behaviour reads them.
Vision → cluster_tf integration¶
cluster_tf only consumes TFs, never pose topics. A pose
estimator must inherit PoseEstimatorTransformPubNode (or
PoseEstimatorDataPubNode) so it broadcasts an object frame.
PoseEstimatorPosePubNode alone causes cluster_tf to collect zero
samples and the BT stalls in the search leg.
| Base class | Broadcasts TF? | Publishes pose topic? | Use for |
|---|---|---|---|
PoseEstimatorTransformPubNode |
✅ | ❌ | cluster_tf-compatible publishers (the default for everything in this workspace). |
PoseEstimatorDataPubNode |
✅ | ✅ | When you also need the pose topic for visualisation or downstream subscribers. |
PoseEstimatorPosePubNode |
❌ | ✅ | Standalone pose publishers (incompatible with cluster_tf). |
Sim time on cluster_tf
Set use_sim_time=True on every cluster_tf node so its
start_time lives on Gazebo's clock. Otherwise every TF stamp
lands "before" the wall-clock start_time and is dropped as
"old". bluerov_cluster.launch.py already does this; double-check
it on any new launch file you write.
DAVE underwater camera plugin¶
If you swap the BlueROV2's standard Gazebo camera for the DAVE
UnderwaterCamera:
- Consuming ROS nodes must decode BGR8, not RGB8.
- There is no
CameraInfotopic. Pose-estimator code needs the intrinsics provided some other way (param file, hardcoded, …). dave_gz_sensor_plugins' install lib must be onGZ_SIM_SYSTEM_PLUGIN_PATH, or Gazebo silently won't load the plugin.
See packages/dave for the full caveats.
See also¶
- Concepts: vocabulary for everything on this page.
- Architecture: where each of these conventions fits into the layered pipeline.
- Primitives.
goto.py, the action server, the search builders.