# ROS 2 odometry and tf2: one point, one frame tree, two lookup times

This small ROS 2 Jazzy exercise publishes synthetic `nav_msgs/Odometry` messages,
explicitly converts their poses into dynamic TF messages, and transforms a
`PointStamped` through a real `TransformListener` and `Buffer`. It runs three
nodes in one Python process inside one isolated container. Messages travel through
ROS publishers and subscriptions on `/odom`, `/tf`, and `/tf_static`.

The fixture is exact by construction. It does not read encoders, integrate wheel
motion, estimate odometry, run SLAM, or test a physical robot. The synthetic motion
includes sideways motion in the body frame and is not a differential-drive model.

## Run

You need Docker. Save `odometry_tf2.py` and `verify.sh` in the same directory, open
a terminal there, then run:

```bash
docker pull ros:jazzy-ros-base@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca

docker run --rm --network none \
  --env ROS_DOMAIN_ID=72 \
  --env RMW_IMPLEMENTATION=rmw_fastrtps_cpp \
  --mount "type=bind,source=$(pwd),target=/lesson,readonly" \
  ros:jazzy-ros-base@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca \
  bash /lesson/verify.sh
```

The pull needs internet access unless that image is already cached. The script
runs with container networking disabled, using the ROS packages already in the
pinned image. The bind mount is read-only. It installs nothing on your host and
removes the container when finished. `verify.sh` sets `ROS_DOMAIN_ID=72` and
`RMW_IMPLEMENTATION=rmw_fastrtps_cpp`, sources Jazzy, and runs the Python file.

The captured run used Ubuntu 24.04.5 ARM64 and Python 3.12.3 on Docker Desktop.
See `environment.txt` for exact versions. Other platforms have not been tested
for this exercise. No ROS daemon or external ROS participant is needed.

`sample-output.txt` contains the successful output. A failed check raises an
exception and exits with a nonzero status; no success line is printed. Transport
waits keep processing callbacks and have ten-second wall-clock deadlines. Those
deadlines detect a stalled test; they are not latency measurements or assertions
about real-time performance.

## Fixture and expected geometry

The parent-to-child frame tree is:

```text
map --static--> odom --dynamic--> base_link --static--> laser
```

All distances are metres. All rotations are right-handed around +z.

| Edge | Translation expressed in its parent | Rotation |
| --- | --- | --- |
| `map -> odom` | `(10, -1, 0)` | identity |
| `odom -> base_link`, t=10 | `(1, 2, 0)` | yaw +90 degrees |
| `odom -> base_link`, t=12 | `(3, 2, 0)` | yaw +90 degrees |
| `base_link -> laser` | `(0.2, 0, 0)` | identity |

The static `map -> odom` offset is a supplied teaching fixture. In a localization
system this edge is generally produced by a localization or mapping component
and can change. Nothing in this example estimates it.

At t=11, tf2 interpolates the dynamic translation to `(2, 2, 0)`; its yaw stays
at +90 degrees. The complete `map <- laser` transform therefore has translation
`(12, 1.2, 0)` and yaw +90 degrees. The input point is one metre along laser +x:

```text
p_laser = (1, 0, 0)
p_base  = (0.2, 0, 0) + p_laser            = (1.2, 0, 0)
p_odom  = (2, 2, 0) + R_z(+90 deg) p_base = (2, 3.2, 0)
p_map   = (10, -1, 0) + p_odom            = (12, 2.2, 0)
```

Applying the inverse transform at the same timestamp recovers `(1, 0, 0)` in
`laser`. The script checks the composed transform, output frame, output stamp,
point coordinates, and inverse result to an absolute coordinate tolerance of
`1e-9` metres.

## Why the explicit bridge matters

`Odometry.header.frame_id` names the pose parent (`odom`), and `child_frame_id`
names the moving body (`base_link`). The pose is expressed in the parent frame;
the twist is expressed in the child frame. Here motion along odom +x at 1 m/s
becomes body-frame linear velocity `(0, -1, 0)` because the body is rotated by
+90 degrees. Angular velocity is zero.

The exact synthetic fixture leaves pose and twist covariance arrays at zero.
These are placeholders for exact supplied data, not unknown-covariance markers
or uncertainty estimates. TF carries neither those covariances nor the twist.

First, the script sends both odometry samples while the bridge is disabled. It
confirms that the odometry callback received them but that the two static TF
subtrees remain disconnected. The failed lookup raises `ConnectivityException`.
Publishing an `Odometry` message does not automatically broadcast a TF edge.

Then it enables `OdometryBridge.on_odometry` and republishes the samples. The
callback copies the message's parent, child, timestamp, position, and quaternion
into a `TransformStamped` and calls `TransformBroadcaster.sendTransform`.
`StaticTransformBroadcaster` publishes the two fixed edges. The listener receives
both kinds through ROS transport; the exercise does not insert transforms
directly into the buffer. After delivery, the script reads
`buffer.all_frames_as_yaml()` and checks the received parents before printing
the compact `BUFFER TREE` line.

This is a narrowly scoped example, not a general-purpose odometry bridge. It
accepts only its known `odom`/`base_link` frame pair. In a robot stack, first
check whether the odometry estimator already broadcasts that edge: two
authorities publishing the same child transform can conflict.

## Time and lookup direction

`lookup_transform(target, source, time)` returns the transform that maps source
coordinates into target coordinates. Thus `lookup_transform("map", "laser",
Time(seconds=11))` supplies `map <- laser`, even though the tree is written with
parent-to-child arrows.

`buffer.transform(point, "map")` uses the point's `header.frame_id` and
`header.stamp` for the lookup. Importing `tf2_geometry_msgs` registers the
`PointStamped` adapter. The lower-level `do_transform_point(point, transform)`
does not verify matching source frames or timestamps; it applies the supplied
transform and copies that transform's header into the result. Using a stamped
buffer lookup avoids silently choosing a transform at another time.

The exercise also deliberately changes the point stamp to zero. In tf2, zero
requests the latest available common time along the path. Here that is t=12,
so the result is `(13, 2.2, 0)` with output stamp 12. Compared with the t=11
measurement's correct result, that is a one-metre error from substituting the
wrong time. The latest lookup succeeds; success alone does not establish that
the transform belongs to the measurement.

These timestamps are synthetic dataset times, not the host's current time. The
test does not publish `/clock`, enable simulation time, or measure transport
delay. It processes all transform samples before asking for t=11. In a running
robot or bag replay, synchronize the clock domain and provide transforms that
cover each measurement stamp. Waiting may allow a pending transform to arrive;
it does not correct the wrong frame or manufacture missing history.

## Failure cases checked

| Request | Expected failure | Meaning in this fixture |
| --- | --- | --- |
| `map <- laser` before bridge | `ConnectivityException` | Both subtrees exist, but no edge joins them |
| `map <- missing_sensor` | `LookupException` | The source frame has never been received |
| `map <- laser` at t=9 | `ExtrapolationException` | Requested time precedes the earliest dynamic sample |
| `map <- laser` at t=13 | `ExtrapolationException` | Requested time follows the latest dynamic sample |

The buffer retains ten seconds of dynamic history relative to its stored
transform stamps; this fixture contains only t=10 and t=12. A ten-second cache
does not supply data at t=9. The script catches the specific expected exception
class and fails if a request unexpectedly succeeds or raises a different error.

The nodes exit after the checks. Commands such as `tf2_echo` in a separate
terminal will not inspect these already-destroyed nodes, and their default
latest lookup would not prove the t=11 result anyway. Read the explicit checks
and captured output for this finite example.

## Try next

1. Before running, calculate the point at t=10: it should be `(11, 2.2, 0)` in
   `map`. Add a stamped lookup and `check_point` call for it.
2. Change the laser offset to `(0.4, 0, 0)` and update the independent expected
   transform and point calculations. At t=11, map y should become 2.4.
3. Change the point's timestamp to 13. Diagnose the extrapolation before
   changing the request to latest; latest would answer a different question.

## Primary references

- [Jazzy Odometry message definition](https://github.com/ros2/common_interfaces/blob/jazzy/nav_msgs/msg/Odometry.msg)
- [Jazzy Buffer lookup and time semantics](https://github.com/ros2/geometry2/blob/jazzy/tf2_ros_py/tf2_ros/buffer.py)
- [Jazzy point transformation adapter](https://github.com/ros2/geometry2/blob/jazzy/tf2_geometry_msgs/src/tf2_geometry_msgs/tf2_geometry_msgs.py)
- [REP 105 frame conventions](https://www.ros.org/reps/rep-0105.html)
