(B) Computation Graph: Peer-to-Peer Network of
ROS nodes (processes).
(C) File-system level: ROS Tools for managing source code,
build instructions, and message definitions.
Tools-based software design
Tools for:
Building ROS packages (colcon build)
Running ROS nodes (ros2 run, ros2 launch)
Viewing network topology (rqt_graph)
Monitoring network traffic (ros2 topic)
Recording and replaying data (ros2 bag)
Many cooperating processes, instead of a single monolithic program.
Multiple language support
ROS is implemented natively in each language, on top of a shared C core (rcl).
Quickly define messages in a language-independent format (.msg files).
Lightweight
Encourages standalone libraries with no ROS dependencies: Don’tput ROS dependencies in the core of your algorithm!
Use ROS only at the edges of your interconnected software modules: Downstream/Upstream interface.
For Assignment 1: write your PID class as plain Python. Only the node that subscribes and publishes should import rclpy.
ROS re-uses code from a variety of projects:
OpenCV : Computer Vision Library
Point Cloud Library (PCL) : 3D Data Processing
MoveIt 2 : Motion Planning
Peer to peer messaging
No central server through which messages are routed.
ROS 2: no master at all. Nodes discover each other automatically through DDS (Data Distribution Service) on the local network.
Messaging types:
Topics : asynchronous data streaming (many-to-many)
Services : synchronous request / reply
Actions : goals with feedback and cancellation
Parameters : per-node configuration values, settable at runtime
Peer to peer messaging
Discovery: DDS multicasts “I exist, I publish X”; nodes on the same ROS_DOMAIN_ID find each other. No roscore.
Publish: Will not block until receipt, messages get queued.
Quality of Service (QoS): Each publisher and subscriber declares a QoS profile: history depth (queue size), reliable vs. best effort, durability. Publisher and subscriber QoS must be compatible or you silently receive nothing.
Sensor drivers and Gazebo publish laser scans as best effort. Subscribe with qos_profile_sensor_data.
Transport: UDP by default (DDS), shared memory on the same machine.
Free & open source
Permissive licenses : can develop commercial applications
Drivers (cameras, lidars, joysticks, IMUs, and others)
Perception, planning, control libraries (Nav2, MoveIt 2, ros2_control)
Interfaces to other libraries: OpenCV, PCL, etc.
Part 2: ROS 1 vs. ROS 2
Why ROS 2?
ROS 1 (2007 to 2025) was built for a single robot, on a single trusted network, with a single central master.
ROS 2 (2017 onwards) was redesigned for:
Multi-robot systems and unreliable networks: DDS middleware, no master, QoS policies.
Real time and embedded: C core, deterministic executors, micro-ROS on microcontrollers.
Security: authentication and encryption between nodes (SROS2).
Production use: lifecycle-managed nodes, first-class actions, typed parameters.
Cross platform: Linux, Windows, macOS.
ROS 1 Noetic reached end of life in May 2025. Everything new is ROS 2.
ROS 2 distributions
Distro
Ubuntu
Released
Supported until
Gazebo
Humble Hawksbill (LTS)
22.04
May 2022
May 2027
Fortress
Jazzy Jalisco (LTS)
24.04
May 2024
May 2029
Harmonic
Kilted Kaiju
24.04
May 2025
Nov 2026
Ionic
Lyrical Luth (LTS)
26.04
May 2026
May 2031
Jetty
One distro per Ubuntu release; do not mix a distro with a different Ubuntu version.
For this course we recommend Jazzy on Ubuntu 24.04. Humble also works. Lyrical is very new, so fewer packages are available yet.
Check yours with echo $ROS_DISTRO.
What stays the same
The concepts from ROS 1 carry over almost unchanged:
Lab machines: ROS 2 and Gazebo are installed. Use VNC or run locally in the lab.
macOS / Windows: run Ubuntu 24.04 in a VM (UTM, VirtualBox, WSL2 with WSLg) or in Docker (osrf/ros:jazzy-desktop-full). Gazebo needs a GPU or software rendering (LIBGL_ALWAYS_SOFTWARE=1), so expect it to be slow.
Sanity check after installing:
source /opt/ros/jazzy/setup.bashros2 run demo_nodes_cpp talker # terminal 1ros2 run demo_nodes_py listener # terminal 2
Callbacks are only run while the node is being spun. If your node never prints anything, check that you called rclpy.spin.
Topic names and types must match exactly: ros2 topic info /chatter shows publishers, subscribers, and the type.
Execution model
rclpy.spin(node): single-threaded executor. Callbacks run one at a time, in the order events arrive. Simple and sufficient for Assignment 1.
A long computation inside a callback delays all other callbacks (including the next laser scan). Keep callbacks short.
rclpy.spin_once(node, timeout_sec=...) if you need your own loop.
Multi-threaded executors and callback groups exist for when you need concurrency; not needed here.
Logging levels: self.get_logger().debug / info / warn / error. Filter at runtime with --ros-args --log-level debug.
Time and timestamps
now =self.get_clock().now() # rclpy.time.Timet = rclpy.time.Time.from_msg(scan.header.stamp)dt = (now -self.last_time).nanoseconds *1e-9
Every sensor message has a header.stamp (when it was measured) and header.frame_id (which coordinate frame).
Use timestamps, not time.time(), to compute dt in a controller: simulation may run slower or faster than wall clock.
In Gazebo, nodes should use simulated time: launch with parameter use_sim_time: true and the /clock topic drives get_clock(). WHY?
Part 5: The messages you need
Inspecting message types
ros2 interface show sensor_msgs/msg/LaserScanros2 interface show geometry_msgs/msg/Twistros2 interface show std_msgs/msg/Float32ros2 interface list |grep-i laser
std_msgs/Header header # stamp, frame_id (e.g. "laser")float32 angle_min # start angle of the scan [rad]float32 angle_max # end angle of the scan [rad]float32 angle_increment # angular distance between measurements [rad]float32 time_increment # time between measurements [s]float32 scan_time # time between scans [s]float32 range_min # minimum valid range [m]float32 range_max # maximum valid range [m]float32[] ranges # range data [m] (len = (angle_max-angle_min)/angle_increment + 1)float32[] intensities # may be empty
Beam i is at angle \(\theta_i = \text{angle_min} + i \cdot \text{angle_increment}\) in the frame_id frame.
Values outside [range_min, range_max], or inf / nan, mean “no return”: filter them out before taking a minimum.
Frame conventions (REP 103)
Robot body frame base_link: x forward, y left, z up.
Angles are counter-clockwise about z: \(\theta = 0\) is straight ahead, \(\theta = +\pi/2\) is the left, \(\theta = -\pi/2\) is the right.
The Husky lidar frame is usually aligned with base_link (check with ros2 run tf2_ros tf2_echo base_link laser).
So the wall on the robot’s left shows up in the beams with \(\theta \approx +\pi/2\).
Units: meters, radians, seconds. Twist.linear.x is m/s, Twist.angular.z is rad/s (positive = turn left).
geometry_msgs/msg/Twist and std_msgs/msg/Float32
from geometry_msgs.msg import Twistfrom std_msgs.msg import Float32cmd = Twist()cmd.linear.x =1.0# forward speed [m/s]; differential drive: linear.y is ignoredcmd.angular.z = omega # yaw rate [rad/s], output of your controllerself.cmd_pub.publish(cmd)err_msg = Float32()err_msg.data =float(error) # ROS 2 checks types: numpy.float64 must be cast to floatself.err_pub.publish(err_msg)
A differential drive robot can only realize linear.x and angular.z.
The velocity controller stops the robot if it does not receive commands for a while: publish on every scan callback.
Part 6: Working with laser scans
From ranges to geometry
import numpy as npdef scan_to_arrays(scan): ranges = np.asarray(scan.ranges, dtype=float) angles = scan.angle_min + np.arange(len(ranges)) * scan.angle_increment valid = np.isfinite(ranges) & (ranges >= scan.range_min) & (ranges <= scan.range_max)return angles[valid], ranges[valid]def polar_to_cartesian(angles, ranges):# points in the laser frame: x forward, y leftreturn ranges * np.cos(angles), ranges * np.sin(angles)
Work with numpy arrays, not Python loops: scans have 700+ beams at 10 to 50 Hz.
Always filter first. A single inf in np.min or np.mean silently poisons the result.
Cartesian points are what you need for fitting lines (least squares, Tutorial 3), building maps, or drawing in rviz2 as a Marker.
Sector summaries
Robots rarely need all 700 beams. Summarize a scan by angular sectors:
def sector_min(angles, ranges, center, half_width):"""Closest valid return within +/- half_width of `center` (radians), or None.""" window = np.abs(angles - center) < half_widthreturnfloat(ranges[window].min()) if window.any() elseNonefront_clearance = sector_min(angles, ranges, center=0.0, half_width=np.deg2rad(15))
Front clearance is the basis of every emergency stop: if front_clearance < 0.5: stop.
A sector minimum is robust to single noisy beams; a single beam is not. Consider a low percentile instead of min for very noisy sensors.
Handle the None case: an open doorway or an out-of-range wall is not an error.
Assignment 1 asks you to turn a scan into a scalar error for a controller. Which beams, which statistic, and what sign convention is your design decision; think about it with the sim in Gazebo and ros2 topic echo in front of you.
Points live in frames
A lidar mounted at \((x_L, y_L)\) with yaw \(\psi_L\) on the robot reports points in the laser frame. To use them together with odometry or a map they must be expressed in base_link or odom:
\(T^{b}_{L}\) reads “the pose of laser expressed in base_link”; it maps laser-frame points to body-frame points.
Chain transforms by multiplication: \(T^{o}_{L} = T^{o}_{b}\, T^{b}_{L}\). Invert to go back: \(T^{L}_{b} = (T^{b}_{L})^{-1}\).
This is exactly what tf2 stores and looks up for you, over time. ros2 run tf2_ros tf2_echo base_link laser prints \(T^{b}_{L}\).
Exercise 2 in code/standalone makes you build, chain, and invert these matrices by hand.
Part 7: Anatomy of a controller node
The plumbing, not the math
A closed-loop controller in ROS 2 is a subscriber callback that publishes a command:
class MyController(Node):def__init__(self):super().__init__("my_controller")self.declare_parameter("gain", 1.0) # tunable at runtimeself.cmd_pub =self.create_publisher(Twist, "cmd_vel", 10)self.dbg_pub =self.create_publisher(Float32, "error", 10) # for plotting / baggingself.create_subscription(LaserScan, "scan", self.on_scan, qos_profile_sensor_data)self.last_stamp =Nonedef on_scan(self, scan: LaserScan): stamp = Time.from_msg(scan.header.stamp) dt =0.0ifself.last_stamp isNoneelse (stamp -self.last_stamp).nanoseconds *1e-9self.last_stamp = stamp error = ... # your perception code: scan -> scalar u = ... # your control law: error, dt, gains -> commandself.dbg_pub.publish(Float32(data=float(error))) cmd = Twist(); cmd.linear.x =1.0; cmd.angular.z =float(u)self.cmd_pub.publish(cmd)
Perception and control are plain functions with no rclpy in them: unit test them without ROS.
Publish your intermediate signal (the error) on its own topic. It costs nothing and you will need it for tuning, rqt_plot, and ros2 bag.
Practical control details in ROS 2
dt from timestamps, not wall-clock time: Gazebo may run slower or faster than real time. With use_sim_time: true, get_clock().now() follows /clock.
Rate: you publish one command per scan (10 to 50 Hz). Missing messages (QoS mismatch) or a slow callback shows up as a lower rate in ros2 topic hz cmd_vel.
Saturation: real robots have velocity limits. Clamp your output and know when it is clamped.
Integrators and windup: if you accumulate anything over time, clamp it and reset it when parameters change or the robot is teleoperated.
Startup: the first callback has no history (dt = 0, no previous error). Guard against division by zero.
Watchdogs: velocity controllers stop the robot when commands stop arriving. Publishing nothing is a safe default; publishing a stale command is not.
A workflow for Assignment 1
Bring up the simulation and look before you code: ros2 topic list, ros2 topic echo --once /husky_1/scan, ros2 topic hz, rviz2 with the LaserScan display.
Teleoperate and watch the scan change as you drive toward and away from walls. Convince yourself which beam indices see which side of the robot.
Perception first: write and test the scan-to-scalar function on a recorded bag (ros2 bag record /husky_1/scan, then replay), publish it, plot it with rqt_plot.
Control second: start with one gain, make it a parameter, tune with ros2 param set while the robot drives. Record the error topic for every run.
Only then worry about corners and edge cases. Keep a log of what each parameter set did.
ros2 run teleop_twist_keyboard teleop_twist_keyboard --ros-args-r cmd_vel:=/husky_1/cmd_vel
Part 8: Parameters, launch files, bags, debugging
Configuring Parameters
from rcl_interfaces.msg import SetParametersResultclass MyController(Node):def__init__(self):super().__init__("my_controller")self.declare_parameter("gain", 1.0)self.gain =self.get_parameter("gain").valueself.add_on_set_parameters_callback(self.on_params) # runs BEFORE the change is applieddef on_params(self, params):for p in params:if p.name =="gain":if p.value <0.0:return SetParametersResult(successful=False, reason="gain must be >= 0")self.gain = p.value # reset any accumulated state here tooself.get_logger().info(f"gain <- {p.value}")return SetParametersResult(successful=True)
ros2 param list /my_controllerros2 param get /my_controller gainros2 param set /my_controller gain 2.5 # takes effect immediately via the callbackros2 param dump /my_controller > tuned.yaml # save what worked; load with --params-fileros2 run rqt_reconfigure rqt_reconfigure # GUI sliders (Part C of A1)
Parameters are typed: declaring gain as 1.0 (float) means ros2 param set gain 2 (int) is rejected. Always pass 2.0.
Give parameters a ParameterDescriptor with a floating_point_range and rqt_reconfigure shows a slider (see param_demo.py).
GitHubROS 2 needed (colcon build the package ros2_ws_src/csc477_tut02):
minimal_publisher / minimal_subscriber: run them, then inspect with ros2 topic, ros2 node, rqt_graph.
fake_laser_publisher + obstacle_monitor: complete the monitor so it publishes /front_clearance (Float32) and /obstacle_ahead (Bool); move the simulated obstacle with ros2 param set and watch the topics follow.
param_demo: change gain at runtime from the CLI and from rqt_reconfigure.
ros2 launch csc477_tut02 tut02.launch.py, then ros2 bag record /front_clearance and export to CSV.
Common pitfalls
Forgot to source install/setup.bash after building → Package 'x' not found.
Subscriber receives nothing → QoS mismatch (ros2 topic info -v), or wrong topic name (ros2 topic list), or you never called rclpy.spin.
AssertionError: The 'data' field must be of type 'float' → cast numpy scalars with float().
Robot does not move → the velocity topic name is wrong, or linear.x is 0, or the simulation is paused.
Everything oscillates wildly → kp too large, or dt is wrong (check with ros2 topic hz), or the sign of angular.z is flipped.
Parameters silently ignored → set an int where a float was declared, or set them before the node declared them.