====================================================
Exercises
====================================================
This page contains four take-home exercises that reinforce the concepts
from Lecture 11. Each exercise asks you to **write code from scratch**
based on a specification -- no starter code is provided.
All files should be created inside your ``~/enpm605_ws/src/`` workspace
in the appropriate packages (``frame_demo``, ``tf2_demo``, or
``robot_control_demo``).
.. dropdown:: Exercise 1 -- Quaternion Utility Library
:icon: gear
:class-container: sd-border-primary
:class-title: sd-font-weight-bold
**Goal**
Build a Python module that converts between orientation
representations and verify it against ``scipy.spatial.transform``.
.. raw:: html
**Specification**
Create the file ``frame_demo/frame_demo/quat_utils.py`` that
implements the following functions.
1. **``axis_angle_to_quaternion(axis, angle)``**:
- Input: a 3-element unit vector ``axis`` and a rotation
``angle`` in radians.
- Output: a tuple ``(w, x, y, z)`` using the half-angle
formula:
.. math::
w = \cos\!\left(\frac{\theta}{2}\right), \quad
(x, y, z) = \sin\!\left(\frac{\theta}{2}\right) \cdot \mathbf{u}
- Raise ``ValueError`` if the axis is not a unit vector
(tolerance: ``1e-6``).
2. **``quaternion_to_euler(w, x, y, z)``**:
- Input: a unit quaternion ``(w, x, y, z)``.
- Output: a tuple ``(roll, pitch, yaw)`` in radians.
- Use the standard ZYX (Tait-Bryan) convention.
3. **``quaternion_multiply(q1, q2)``**:
- Input: two quaternions as ``(w, x, y, z)`` tuples.
- Output: the Hamilton product ``q1 * q2`` as a
``(w, x, y, z)`` tuple.
4. **``normalize_quaternion(w, x, y, z)``**:
- Input: a quaternion (not necessarily unit).
- Output: the corresponding unit quaternion.
- Raise ``ValueError`` if the magnitude is zero.
Write a ``main()`` function that:
- Computes the quaternion for a :math:`90°` rotation about the
:math:`x`-axis using ``axis_angle_to_quaternion``.
- Computes the quaternion for a :math:`90°` rotation about the
:math:`z`-axis.
- Composes them (z-rotation applied first, then x-rotation) using
``quaternion_multiply``.
- Converts the result to Euler angles.
- Prints all intermediate and final values.
- Verifies the result against
``scipy.spatial.transform.Rotation`` and prints ``PASS`` or
``FAIL``.
**Verification**
.. code-block:: console
cd ~/enpm605_ws && colcon build --symlink-install --packages-select frame_demo
source install/setup.bash
ros2 run frame_demo quat_utils
.. dropdown:: Exercise 2 -- Static Transform Broadcaster Node
:icon: gear
:class-container: sd-border-primary
:class-title: sd-font-weight-bold
**Goal**
Write a ROS 2 node that publishes a static transform from
``base_link`` to a custom sensor frame using parameters loaded
from a YAML file.
.. raw:: html
**Specification**
Create ``frame_demo/frame_demo/sensor_frame_publisher.py``.
1. **``SensorFramePublisher(Node)``** class:
- ``__init__``: declares the following parameters:
- ``parent_frame`` (string, default ``"base_link"``)
- ``child_frame`` (string, default ``"sensor_link"``)
- ``translation`` (double array, default ``[0.0, 0.0, 0.0]``)
- ``rotation_rpy`` (double array, default ``[0.0, 0.0, 0.0]``)
- Converts the roll-pitch-yaw values to a quaternion using
``scipy.spatial.transform.Rotation.from_euler("xyz", [r, p, y])``.
- Creates a ``StaticTransformBroadcaster`` and publishes the
transform once.
- Logs the published transform at ``info`` level.
2. **YAML parameter file** ``config/sensor_frame.yaml``:
.. code-block:: yaml
/sensor_frame_publisher:
ros__parameters:
parent_frame: "base_link"
child_frame: "camera_link"
translation: [0.1, 0.0, 0.25]
rotation_rpy: [0.0, -0.2618, 0.0]
3. **Launch file** ``launch/sensor_frame.launch.py``:
- Starts the ``sensor_frame_publisher`` node with the YAML
parameter file.
4. Register the entry point in ``setup.py`` and install the config
and launch directories.
**Expected behavior**
- The node starts, publishes the static transform, and keeps
running.
- ``ros2 run tf2_ros tf2_echo base_link camera_link`` shows the
transform.
- ``ros2 run rqt_tf_tree rqt_tf_tree --force-discover`` shows
``base_link`` → ``camera_link`` in the tree.
**Verification**
.. code-block:: console
cd ~/enpm605_ws && colcon build --symlink-install --packages-select frame_demo
source install/setup.bash
# Terminal 1
ros2 launch frame_demo sensor_frame.launch.py
# Terminal 2
ros2 run tf2_ros tf2_echo base_link camera_link
# Terminal 3
ros2 run rqt_tf_tree rqt_tf_tree --force-discover
.. dropdown:: Exercise 3 -- Transform Listener and Logger
:icon: gear
:class-container: sd-border-primary
:class-title: sd-font-weight-bold
**Goal**
Write a node that periodically looks up the transform between two
frames and logs the position and orientation (as Euler angles).
.. raw:: html
**Specification**
Create ``frame_demo/frame_demo/frame_logger.py``.
1. **``FrameLogger(Node)``** class:
- ``__init__``: declares two parameters:
- ``target_frame`` (string, default ``"odom"``)
- ``source_frame`` (string, default ``"base_link"``)
- Creates a ``Buffer`` and a ``TransformListener``.
- Creates a timer at 1 Hz.
2. **Timer callback** ``_timer_callback(self)``:
- Calls ``self._tf_buffer.lookup_transform(target, source,
rclpy.time.Time(), timeout=Duration(seconds=0.1))``.
- Extracts the translation ``(x, y, z)``.
- Converts the quaternion to Euler angles using
``scipy.spatial.transform.Rotation``.
- Logs translation and Euler angles (degrees) at ``info`` level.
- Catches ``TransformException`` and logs a warning instead.
**Expected behavior**
- With the ROSbot simulation running, the node logs the robot's
pose in the ``odom`` frame once per second.
- Moving the robot (e.g., with ``teleop_twist_keyboard``) shows
the logged values changing.
**Verification**
.. code-block:: console
# Terminal 1
ros2 launch rosbot_gazebo empty_world.launch.py
# Terminal 2
cd ~/enpm605_ws && colcon build --symlink-install --packages-select frame_demo
source install/setup.bash
ros2 run frame_demo frame_logger
# Terminal 3
ros2 run teleop_twist_keyboard teleop_twist_keyboard --ros-args -p stamped:=true
.. dropdown:: Exercise 4 -- Proportional Go-to-Goal Controller
:icon: gear
:class-container: sd-border-primary
:class-title: sd-font-weight-bold
**Goal**
Implement a two-phase proportional controller that drives a
differential-drive robot to a goal pose (position + final heading)
using odometry feedback.
.. raw:: html
**Specification**
Create ``robot_control_demo/robot_control_demo/goto_goal.py``.
1. **``GoToGoal(Node)``** class:
- ``__init__``: declares the following parameters with defaults:
- ``goal_x`` (double, ``3.0``)
- ``goal_y`` (double, ``2.0``)
- ``goal_heading`` (double, ``1.57``) — desired final yaw in
radians
- ``k_rho`` (double, ``0.5``) — linear gain
- ``k_alpha`` (double, ``1.0``) — angular gain (drive phase)
- ``k_heading`` (double, ``1.5``) — angular gain (rotate phase)
- ``position_tolerance`` (double, ``0.05``) — meters
- ``heading_tolerance`` (double, ``0.05``) — radians
- Creates a publisher on ``/cmd_vel``
(``geometry_msgs/msg/TwistStamped``).
- Subscribes to ``/odometry/filtered``
(``nav_msgs/msg/Odometry``).
- Creates a timer at 20 Hz for the control loop.
- Tracks the current phase: ``DRIVE`` or ``ROTATE``.
2. **Odometry callback** ``_odom_callback(self, msg)``:
- Extracts ``x``, ``y`` from ``msg.pose.pose.position``.
- Extracts yaw from the quaternion using
``scipy.spatial.transform.Rotation``.
3. **Control loop** ``_control_loop(self)``:
- **DRIVE phase**:
- Compute distance error:
:math:`\rho = \sqrt{(x_g - x)^2 + (y_g - y)^2}`
- Compute heading error to goal:
:math:`\alpha = \text{atan2}(y_g - y, x_g - x) - \psi`
- Normalize :math:`\alpha` to :math:`[-\pi, \pi]`.
- Set ``linear.x = k_rho * rho`` and
``angular.z = k_alpha * alpha``.
- Clamp linear velocity to ``[0, 0.5]`` m/s and angular
velocity to ``[-1.0, 1.0]`` rad/s.
- If :math:`\rho <` ``position_tolerance``, switch to
``ROTATE`` phase.
- **ROTATE phase**:
- Compute heading error:
:math:`e_\psi = \psi_{\text{goal}} - \psi`
- Normalize to :math:`[-\pi, \pi]`.
- Set ``linear.x = 0.0`` and
``angular.z = k_heading * e_psi``.
- Clamp angular velocity to ``[-1.0, 1.0]`` rad/s.
- If :math:`|e_\psi| <` ``heading_tolerance``, publish a
zero-velocity command, log ``"Goal reached!"``, and stop
the timer.
4. Register the entry point in ``setup.py``.
**Expected behavior**
- The robot drives toward the goal position with a smooth curved
trajectory (simultaneous linear + angular motion).
- Once within ``position_tolerance`` of the goal, the robot stops
translating and rotates in place to the desired heading.
- Velocities decrease as the robot approaches the goal.
**Verification**
.. code-block:: console
# Terminal 1
ros2 launch rosbot_gazebo empty_world.launch.py
# Terminal 2
cd ~/enpm605_ws && colcon build --symlink-install --packages-select robot_control_demo
source install/setup.bash
ros2 run robot_control_demo goto_goal --ros-args \
-p goal_x:=3.0 -p goal_y:=2.0 -p goal_heading:=1.57
# Optional: visualize in RViz2 with Odometry display on /odometry/filtered