Build a Nav2 Readiness Inspector
Build a ROS 2 tool that checks Nav2 odometry, lidar, and TF2 readiness.
Introduction
30 Second Summary
A robot can look ready when its dashboard fills with data. A stale reading or broken connection can still leave it unable to navigate safely.
In this project, you will build a terminal-based ROS 2 readiness inspector that checks whether Nav2 can trust live odometry, lidar, and TF2 data. You will run it beside Gazebo and RViz to watch localization turn a failed global transform into a ready navigation system.
What You'll Build
You'll watch a compact terminal report update every second as the simulated robot moves from RESULT: NOT READY to RESULT: READY.
By the end of this project, you'll have:
- A live navigation simulation where you can inspect realistic robot data in Gazebo and RViz.
- A reusable inspector that you can point at another robot by changing odom_topic, scan_topic, and map_frame.
- A compact PASS/FAIL report that separates odometry, lidar, and global localization health.
- Secret Mission: Add a runtime-adjustable minimum valid lidar sample threshold and deliberately trigger a quality failure.
Are there any prerequisites?
You need a graphical Ubuntu 24.04 session with ROS 2 Jazzy, Nav2, and VS Code already working. You also need internet access for the Ubuntu packages used in Step 1.
Before We Start
Before any hands-on work, define the diagnostic question this inspector must answer. It must validate live odometry, lidar, and map-to-robot TF2 connectivity because stale, invalid, or disconnected navigation data prevents autonomous navigation.
Prepare the Jazzy Workspace
Your readiness inspector needs a reproducible ROS 2 data source. Missing build tooling would stop the diagnostic before it reaches live navigation data.
The official minimal TurtleBot simulation provides the Jazzy data source. This step also creates a structured Python package for the inspector.
In this step, get ready to:
- Confirm that the Jazzy distribution is active.
- Install the required build tooling plus official TurtleBot simulation assets.
- Scaffold the inspector package inside a new ROS 2 workspace.
Confirm the Jazzy environment
Sourcing selects which ROS 2 installation your terminal uses. The ROS_DISTRO value gives you a direct check before you install or scaffold anything.
- Use Ubuntu's application search to open a Bash terminal.
- Confirm the active ROS 2 distribution by running these commands:
source /opt/ros/jazzy/setup.bash
echo $ROS_DISTRO
What do these commands check?
- The first command loads the Jazzy environment into this terminal session.
- The second command prints the distribution selected by that environment.
You'll see jazzy on the final line. That confirms your terminal is using the correct ROS 2 distribution.
Do You See a Different Distribution?
- Rerun the sourcing commands above in the same terminal.
- Check that the setup file path begins with /opt/ros/jazzy.
- Still stuck? Help me check why my Ubuntu terminal is not using ROS 2 Jazzy.
Install the build and simulation assets
The colcon build tool assembles packages inside a ROS 2 workspace. The simulation assets supply the Gazebo world used by the Nav2 quickstart.
Administrator access can feel sensitive. Ubuntu uses it here to update the local package index before installing packages from your configured repositories.
- Prepare to enter your Ubuntu account password if prompted.
- Install the build tooling plus simulation assets by running these commands:
sudo apt update
sudo apt install python3-colcon-common-extensions ros-$ROS_DISTRO-nav2-minimal-tb\*
What Does This Install?
- The package update refreshes Ubuntu's local package index.
- The python3-colcon-common-extensions package provides the build tooling used by ROS 2 workspaces.
- The minimal TurtleBot packages provide the official simulation assets used by this project.
- Wait for the installation to finish.
You'll return to the terminal prompt after the packages finish installing. The simulation source plus workspace builder are now available locally.
Did the Installation Stop Early?
- Confirm that your Ubuntu session has internet access.
- Rerun the installation commands after any other package operation finishes.
Still stuck? Help me troubleshoot the ROS 2 package installation. You can also share the non-sensitive terminal output with the NextWork community.
Scaffold the inspector package
An ament_python scaffold creates the metadata plus module structure expected by ROS 2. VS Code gives you one place to inspect those generated files.
The final command opens the new workspace in VS Code. Because you created this workspace yourself, it is safe to trust the folder if VS Code asks for confirmation.
- Create the workspace plus scaffolded package by running these commands:
mkdir -p ~/nav_readiness_ws/src
cd ~/nav_readiness_ws/src
ros2 pkg create --build-type ament_python --license Apache-2.0 --node-name readiness_inspector nav_readiness_inspector --dependencies rclpy nav_msgs sensor_msgs tf2_ros
code ~/nav_readiness_ws
What Does the Scaffold Create?
- The workspace stores source packages inside ~/nav_readiness_ws/src.
- The package scaffold creates package.xml plus the Python setup files.
- The scaffold also creates a resource marker plus starter tests.
- The generated Python module contains __init__.py plus the starter readiness_inspector.py node.
You'll see the generated package files listed in the terminal. VS Code opens with ~/nav_readiness_ws as the workspace.
- Switch back to the Ubuntu terminal from earlier.
Before you check, which top-level scaffold files do you expect the package command to have created?
- List the scaffolded package contents by running this command:
ls ~/nav_readiness_ws/src/nav_readiness_inspector
What Does This Check Prove?
This command lists the package's top-level scaffold. The result proves that the workspace contains the generated package structure.
You'll see package.xml, setup.cfg, plus setup.py.
You'll also see resource, test, plus nav_readiness_inspector.
- Return to VS Code.
- Expand src/nav_readiness_inspector/nav_readiness_inspector using the file sidebar.
- Confirm that __init__.py plus readiness_inspector.py are listed.
That's the workspace foundation in place. Your package now has the exact scaffold needed for the live diagnostic.
Is the Package Structure Missing?
- Check that the terminal still uses the sourced Jazzy environment.
- Confirm that VS Code opened ~/nav_readiness_ws.
- Still stuck? Help me troubleshoot the ROS 2 package scaffold.
Your Jazzy workspace is ready. Next, you'll launch the official Nav2 simulation to test what visible topic names can prove.
Check the Simulation Topics
Your ROS 2 workspace is ready. The next question is whether the simulated robot exposes the signals that Nav2 needs.
A visible topic name confirms only that the graph advertises an endpoint. This baseline cannot establish navigation readiness.
In this step, get ready to:
- Launch the official TurtleBot simulation with its navigation tools.
- Confirm that the simulation advertises /odom plus /scan before setting an initial pose.
Launch the official Jazzy simulation
The official launch uses Gazebo to simulate the robot. RViz shows the navigation view.
- Switch back to the sourced Ubuntu terminal from the previous step.
- Start the official Nav2 TurtleBot simulation by running this command:
ros2 launch nav2_bringup tb3_simulation_launch.py headless:=False
What does this launch start?
The launch starts the official TurtleBot simulation with Gazebo plus RViz. The running process supplies the ROS 2 graph that you inspect next.
A quiet first launch is normal while simulation assets initialize.
- Keep the launch terminal running.
- Wait until Gazebo shows the simulated TurtleBot environment.
- Wait until RViz shows the navigation view.
- Leave 2D Pose Estimate unused so the initial pose remains unset.
You now have Gazebo plus RViz open beside the running launch terminal. The simulation remains in its pre-localization state.
Simulation windows still loading?
- Give the first launch more time because the simulation assets may still be initializing.
- Return to the launch terminal to check its latest output if either window closes.
- Use the latest terminal output when you ask for help with the simulation launch..
Check the advertised topics
Topic discovery gives you a quick view of the names on the ROS 2 graph. This weak check asks whether the two expected sensor inputs are visible.
- Create a second Ubuntu terminal window for the inspection command.
Before you run the filter, which topic names do you expect the simulation to advertise?
- Run this inspection sequence to source Jazzy before filtering the topic list:
source /opt/ros/jazzy/setup.bash
ros2 topic list | grep -E '^/(odom|scan)$'
What does this check prove?
You'll see /odom on one line. You'll see /scan on another.
- The ros2 CLI lists topic names discovered on the graph.
- The grep filter retains the two exact topic matches.
- The output proves topic visibility.
- Message freshness remains untested.
- Frame metadata remains untested.
- Lidar quality remains untested.
- Global TF2 connectivity remains untested.
You have now seen the gap firsthand. The graph has the expected names while navigation readiness remains open.
Missing one of the topics?
- Wait for both simulator windows to finish loading before repeating the topic check.
- Confirm that the original launch terminal is still running.
- Share the launch output when you ask for help finding a missing simulation topic..
The simulation now advertises both inputs, but the weak check stops there. Next, you'll build the inspector that tests whether those inputs are usable.
Build the Live Inspector
Your topic check found /odom. It also found /scan. Those names only prove that publishers exist.
The inspector now tests the signals that Nav2 needs for navigation. Your ROS 2 node will check message freshness. It will inspect frame metadata. It will count usable lidar ranges. It will also ask TF2 whether the navigation frames connect.
Why a diagnostic node?
A diagnostic node evaluates the data your robot already publishes. This keeps the project focused on navigation readiness without introducing unverified robot geometry.
The node discovers frame names from incoming messages. You can later reuse the same checks with a different robot by changing topic parameters.
In this step, get ready to:
- Replace the scaffolded package files with the inspector implementation.
- Build the package with colcon.
- Run the inspector before localization completes.
Replace the package files
An ament Python package needs metadata that declares its runtime dependencies. It also needs an entry point that lets ros2 run find the inspector.
- Switch back to VS Code from the workspace setup.
- Select package.xml inside ~/nav_readiness_ws/src/nav_readiness_inspector from the Explorer sidebar.
- Replace the file contents with this package manifest:
<?xml version="1.0"?>
<package format="3">
<name>nav_readiness_inspector</name>
<version>0.0.0</version>
<description>Checks whether odometry, lidar, and TF2 data are ready for Nav2.</description>
<maintainer email="learner@example.com">ROS 2 Learner</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_python</buildtool_depend>
<exec_depend>rclpy</exec_depend>
<exec_depend>nav_msgs</exec_depend>
<exec_depend>sensor_msgs</exec_depend>
<exec_depend>tf2_ros</exec_depend>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
What does this manifest define?
- The runtime dependency entries make the ROS message packages available when the inspector starts.
- The tf2_ros dependency provides access to the transform buffer.
- The build type tells colcon to package the project with ament_python.
- Save package.xml.
- Select setup.py from the same package folder.
- Replace the file contents with this package configuration:
from setuptools import find_packages, setup
package_name = 'nav_readiness_inspector'
setup(
name=package_name,
version='0.0.0',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='ROS 2 Learner',
maintainer_email='learner@example.com',
description='Checks whether odometry, lidar, and TF2 data are ready for Nav2.',
license='Apache-2.0',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'readiness_inspector = '
'nav_readiness_inspector.readiness_inspector:main',
],
},
)
How does the executable work?
The console entry point maps the executable name readiness_inspector to the main() function. That mapping lets ROS 2 start the Python node through the package index.
- Save setup.py.
- Select setup.cfg from the same package folder.
- Replace the file contents with this executable-path configuration:
[develop]
script_dir=$base/lib/nav_readiness_inspector
[install]
install_scripts=$base/lib/nav_readiness_inspector
Why does this path matter?
The configuration installs Python executables under the package-specific library path. ROS 2 searches that path when you run the package executable.
- Save setup.cfg.
- Keep resource/nav_readiness_inspector empty.
- Keep nav_readiness_inspector/__init__.py empty.
- Return to the inspection terminal from earlier.
- Check the updated package metadata by running:
cd ~/nav_readiness_ws
source /opt/ros/jazzy/setup.bash
colcon build --symlink-install --packages-select nav_readiness_inspector
What does this build check?
Colcon reads the updated manifest. It also installs the package through the entry point in setup.py.
The symlinked installation reflects later Python edits after each rebuild. This keeps the edit-and-test loop short.
You should see Summary: 1 package finished. The workspace now also contains build, install, and log directories.
Package metadata build failed?
Check that each XML tag in package.xml has a matching closing tag. Confirm that the indentation and punctuation in setup.py match the block above.
Still stuck? Help me troubleshoot this nav_readiness_inspector package build.
Assemble and build the inspector
The node stores the latest odometry message. It also stores the latest lidar scan. Local receipt times reveal whether either stream has stopped updating.
Sensor streams use sensor-data QoS. The inspector uses the matching profile so it can consume the simulated data.
- Switch back to VS Code.
- Select nav_readiness_inspector/readiness_inspector.py inside the package folder.
- Replace the starter node with this first section:
import math
from nav_msgs.msg import Odometry
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from rclpy.time import Time
from sensor_msgs.msg import LaserScan
from tf2_ros.buffer import Buffer
from tf2_ros.transform_listener import TransformListener
class NavReadinessInspector(Node):
FRESHNESS_LIMIT_SECONDS = 1.0
def __init__(self):
super().__init__('nav_readiness_inspector')
self.odom_topic = self.declare_parameter(
'odom_topic', '/odom').get_parameter_value().string_value
self.scan_topic = self.declare_parameter(
'scan_topic', '/scan').get_parameter_value().string_value
self.map_frame = self.declare_parameter(
'map_frame', 'map').get_parameter_value().string_value
self.last_odom = None
self.last_scan = None
self.last_odom_received_at = None
self.last_scan_received_at = None
What does this section prepare?
- The three parameters keep the topic names and global frame configurable.
- The message variables retain the newest odometry data and lidar data.
- The receipt-time variables support the freshness checks.
- The freshness limit marks data older than one second as stale.
- Place the cursor directly after self.last_scan_received_at = None.
- Add the transform listener and subscriptions with this section:
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
self.odom_subscription = self.create_subscription(
Odometry,
self.odom_topic,
self.odom_callback,
qos_profile_sensor_data,
)
self.scan_subscription = self.create_subscription(
LaserScan,
self.scan_topic,
self.scan_callback,
qos_profile_sensor_data,
)
self.report_timer = self.create_timer(1.0, self.report_readiness)
self.get_logger().info(
f'Inspecting odometry on {self.odom_topic}, '
f'lidar on {self.scan_topic}, and global frame {self.map_frame}'
)
How does data reach the inspector?
- The transform listener fills a local buffer with frame relationships.
- Each subscription sends incoming data to its matching callback.
- The timer requests a readiness report every second.
- The startup log displays the selected topics and global frame.
- Save readiness_inspector.py.
- Return to the inspection terminal.
- Confirm that the first node section remains buildable by running:
colcon build --symlink-install --packages-select nav_readiness_inspector
What does this checkpoint prove?
This build checks the imports and constructor syntax before the file grows. A short checkpoint makes misplaced indentation easier to find.
You should see Summary: 1 package finished.
Seeing a Python build failure?
Check that the constructor body uses eight spaces of indentation. Confirm that both subscription calls end with their closing parentheses.
Still stuck? Help me fix the constructor section.
- Return to readiness_inspector.py in VS Code.
- Place the cursor after the constructor's closing parenthesis.
- Add the message callbacks and freshness helpers:
def odom_callback(self, message):
self.last_odom = message
self.last_odom_received_at = self.get_clock().now()
def scan_callback(self, message):
self.last_scan = message
self.last_scan_received_at = self.get_clock().now()
def age_seconds(self, received_at):
if received_at is None:
return None
age = self.get_clock().now() - received_at
return age.nanoseconds / 1_000_000_000
def is_fresh(self, received_at):
age = self.age_seconds(received_at)
return age is not None and age <= self.FRESHNESS_LIMIT_SECONDS
How does freshness work?
- Each callback stores its newest message.
- Each callback records the local arrival time from the node clock.
- The age helper converts elapsed nanoseconds into seconds.
- The freshness helper compares that age with the one-second limit.
- Place the cursor after the is_fresh() method.
- Add the transform and lidar-quality helpers:
def can_transform(self, target_frame, source_frame):
if not target_frame or not source_frame:
return False
return self.tf_buffer.can_transform(
target_frame,
source_frame,
Time(),
)
def valid_scan_count(self):
if self.last_scan is None:
return 0
return sum(
1
for distance in self.last_scan.ranges
if math.isfinite(distance)
and self.last_scan.range_min <= distance <= self.last_scan.range_max
)
@staticmethod
def result_line(label, passed, detail):
status = 'PASS' if passed else 'FAIL'
return f'[{status}] {label}: {detail}'
What makes a signal usable?
- The transform helper rejects missing frame names before querying the buffer.
- An empty time request checks the latest available transform.
- The lidar helper counts finite distances inside the sensor's reported limits.
- The result helper gives every diagnostic the same PASS or FAIL format.
- Save readiness_inspector.py.
- Return to the inspection terminal.
- Check the new helper methods by running:
colcon build --symlink-install --packages-select nav_readiness_inspector
What does this checkpoint cover?
This build validates the callback methods and helper indentation. It also checks the multiline lidar expression for valid Python syntax.
You should see Summary: 1 package finished again.
Helper methods not building?
Confirm that each method definition aligns with def __init__. Check the closing parenthesis after the lidar range expression.
Still stuck? Help me debug the freshness and transform helpers.
- Return to readiness_inspector.py.
- Place the cursor after the result_line() method.
- Add the readiness calculations with this method section:
def report_readiness(self):
odom_age = self.age_seconds(self.last_odom_received_at)
scan_age = self.age_seconds(self.last_scan_received_at)
odom_parent = (
self.last_odom.header.frame_id if self.last_odom is not None else ''
)
robot_base = (
self.last_odom.child_frame_id if self.last_odom is not None else ''
)
scan_frame = (
self.last_scan.header.frame_id if self.last_scan is not None else ''
)
odom_fresh = self.is_fresh(self.last_odom_received_at)
odom_tf_ok = self.can_transform(odom_parent, robot_base)
odom_ok = odom_fresh and odom_tf_ok
valid_ranges = self.valid_scan_count()
scan_fresh = self.is_fresh(self.last_scan_received_at)
scan_tf_ok = self.can_transform(robot_base, scan_frame)
lidar_ok = scan_fresh and valid_ranges > 0 and scan_tf_ok
global_tf_ok = self.can_transform(self.map_frame, robot_base)
ready = odom_ok and lidar_ok and global_tf_ok
How are the checks connected?
- Odometry passes when its newest message is fresh and its two message-derived frames connect.
- Lidar passes when its scan is fresh and contains a usable range.
- The lidar frame must also connect to the robot base discovered from odometry.
- Overall readiness requires the global frame to connect to that same robot base.
- Save readiness_inspector.py.
- Return to the inspection terminal.
- Check the readiness calculations by running:
colcon build --symlink-install --packages-select nav_readiness_inspector
What does this checkpoint isolate?
This build checks the frame-extraction expressions. It also validates the combined readiness conditions before output formatting is added.
You should see Summary: 1 package finished.
Readiness method not building?
Check the parentheses around each frame expression. Confirm that every line inside report_readiness() uses eight spaces.
Still stuck? Help me fix the readiness calculations.
- Return to readiness_inspector.py.
- Place the cursor directly after the ready calculation.
- Complete the report formatting with this section:
odom_age_text = (
'no messages received' if odom_age is None
else f'age={odom_age:.2f}s, frames={odom_parent}->{robot_base}'
)
scan_age_text = (
'no messages received' if scan_age is None
else f'age={scan_age:.2f}s, valid_ranges={valid_ranges}, '
f'frame={scan_frame}'
)
global_detail = (
f'{self.map_frame}->{robot_base} available'
if global_tf_ok
else f'{self.map_frame}->{robot_base or "unknown"} unavailable'
)
report = [
'',
'Nav2 Readiness Report',
self.result_line('Odometry', odom_ok, odom_age_text),
self.result_line('Lidar', lidar_ok, scan_age_text),
self.result_line('Global transform', global_tf_ok, global_detail),
f'RESULT: {"READY" if ready else "NOT READY"}',
]
self.get_logger().info('\n'.join(report))
What will the report reveal?
- The odometry detail shows message age and both message-derived frames.
- The lidar detail shows message age and the usable range count.
- The global detail names the transform path being tested.
- The final result passes only when all three checks pass together.
- Place the cursor after the completed NavReadinessInspector class.
- Add the node lifecycle and executable entry point:
def main(args=None):
rclpy.init(args=args)
node = NavReadinessInspector()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
How does the node stay running?
The main() function initializes ROS 2. It then spins the inspector so subscriptions and timer callbacks keep running.
The cleanup block destroys the node after you stop it from the terminal. It then shuts down the ROS client cleanly.
- Save readiness_inspector.py.
- Return to the inspection terminal.
- Build the completed package and source its installation by running:
cd ~/nav_readiness_ws
source /opt/ros/jazzy/setup.bash
colcon build --symlink-install --packages-select nav_readiness_inspector
source install/setup.bash
What does the final build do?
Colcon installs the completed node and its console entry point. Sourcing the workspace makes that executable available in the current terminal.
You should see Summary: 1 package finished. Your terminal is now ready to start the inspector.
Final build not finishing?
Read the first file and line number shown by the build output. Compare that location with the matching section above.
Still stuck? Help me diagnose the final inspector build.
Use the tabs below to compare the completed node before you run it.
✔️ Awesome, I've got everything!
Great. Confirm that readiness_inspector.py is saved before continuing.
ⓧ I'd like to double check the full code
import math
from nav_msgs.msg import Odometry
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from rclpy.time import Time
from sensor_msgs.msg import LaserScan
from tf2_ros.buffer import Buffer
from tf2_ros.transform_listener import TransformListener
class NavReadinessInspector(Node):
FRESHNESS_LIMIT_SECONDS = 1.0
def __init__(self):
super().__init__('nav_readiness_inspector')
self.odom_topic = self.declare_parameter(
'odom_topic', '/odom').get_parameter_value().string_value
self.scan_topic = self.declare_parameter(
'scan_topic', '/scan').get_parameter_value().string_value
self.map_frame = self.declare_parameter(
'map_frame', 'map').get_parameter_value().string_value
self.last_odom = None
self.last_scan = None
self.last_odom_received_at = None
self.last_scan_received_at = None
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
self.odom_subscription = self.create_subscription(
Odometry,
self.odom_topic,
self.odom_callback,
qos_profile_sensor_data,
)
self.scan_subscription = self.create_subscription(
LaserScan,
self.scan_topic,
self.scan_callback,
qos_profile_sensor_data,
)
self.report_timer = self.create_timer(1.0, self.report_readiness)
self.get_logger().info(
f'Inspecting odometry on {self.odom_topic}, '
f'lidar on {self.scan_topic}, and global frame {self.map_frame}'
)
def odom_callback(self, message):
self.last_odom = message
self.last_odom_received_at = self.get_clock().now()
def scan_callback(self, message):
self.last_scan = message
self.last_scan_received_at = self.get_clock().now()
def age_seconds(self, received_at):
if received_at is None:
return None
age = self.get_clock().now() - received_at
return age.nanoseconds / 1_000_000_000
def is_fresh(self, received_at):
age = self.age_seconds(received_at)
return age is not None and age <= self.FRESHNESS_LIMIT_SECONDS
def can_transform(self, target_frame, source_frame):
if not target_frame or not source_frame:
return False
return self.tf_buffer.can_transform(
target_frame,
source_frame,
Time(),
)
def valid_scan_count(self):
if self.last_scan is None:
return 0
return sum(
1
for distance in self.last_scan.ranges
if math.isfinite(distance)
and self.last_scan.range_min <= distance <= self.last_scan.range_max
)
@staticmethod
def result_line(label, passed, detail):
status = 'PASS' if passed else 'FAIL'
return f'[{status}] {label}: {detail}'
def report_readiness(self):
odom_age = self.age_seconds(self.last_odom_received_at)
scan_age = self.age_seconds(self.last_scan_received_at)
odom_parent = (
self.last_odom.header.frame_id if self.last_odom is not None else ''
)
robot_base = (
self.last_odom.child_frame_id if self.last_odom is not None else ''
)
scan_frame = (
self.last_scan.header.frame_id if self.last_scan is not None else ''
)
odom_fresh = self.is_fresh(self.last_odom_received_at)
odom_tf_ok = self.can_transform(odom_parent, robot_base)
odom_ok = odom_fresh and odom_tf_ok
valid_ranges = self.valid_scan_count()
scan_fresh = self.is_fresh(self.last_scan_received_at)
scan_tf_ok = self.can_transform(robot_base, scan_frame)
lidar_ok = scan_fresh and valid_ranges > 0 and scan_tf_ok
global_tf_ok = self.can_transform(self.map_frame, robot_base)
ready = odom_ok and lidar_ok and global_tf_ok
odom_age_text = (
'no messages received' if odom_age is None
else f'age={odom_age:.2f}s, frames={odom_parent}->{robot_base}'
)
scan_age_text = (
'no messages received' if scan_age is None
else f'age={scan_age:.2f}s, valid_ranges={valid_ranges}, '
f'frame={scan_frame}'
)
global_detail = (
f'{self.map_frame}->{robot_base} available'
if global_tf_ok
else f'{self.map_frame}->{robot_base or "unknown"} unavailable'
)
report = [
'',
'Nav2 Readiness Report',
self.result_line('Odometry', odom_ok, odom_age_text),
self.result_line('Lidar', lidar_ok, scan_age_text),
self.result_line('Global transform', global_tf_ok, global_detail),
f'RESULT: {"READY" if ready else "NOT READY"}',
]
self.get_logger().info('\n'.join(report))
def main(args=None):
rclpy.init(args=args)
node = NavReadinessInspector()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
What should match?
Compare the imports and class indentation first. Then compare each method name with your file.
The completed file ends with the main() call under the module entry-point condition.
Run the inspector before localization
The simulation is still publishing sensor data. The initial pose is still unset. This gives the inspector a real incomplete transform chain to diagnose.
Before you run the node, consider whether every readiness check can pass without that initial pose.
- Start the completed inspector from the sourced inspection terminal by running:
ros2 run nav_readiness_inspector readiness_inspector
What does this command start?
The command starts the executable registered in setup.py. The node then evaluates its subscriptions and transform buffer every second.
Allow several reports to print. You should see [PASS] Odometry and [PASS] Lidar once the simulation data arrives.
You should also see [FAIL] Global transform followed by RESULT: NOT READY. The missing global connection is the intended shortfall.
That is the diagnostic doing its job. It has separated visible sensor topics from a complete navigation-ready transform chain.
Sensor checks still failing?
Confirm that the simulation terminal is still running. Wait for the simulator to finish initializing before judging the sensor checks.
Confirm that the inspection terminal was sourced after the final build. Keep the initial pose unset so the global transform remains the only intended failure.
Still stuck? Help me interpret my Nav2 readiness report.
Your inspector has isolated the missing global transform. Next, you will provide the initial pose and watch the same report become ready.
Complete the Transform Chain
The inspector already proves that live odometry is usable. It also proves that live lidar is usable.
Its failed global check isolates the missing localization link between map and the robot base. Now you'll use AMCL in RViz to supply the robot's initial pose.
That estimate lets Nav2 complete the TF2 chain that the readiness report requires.
In this step, get ready to:
- Supply an initial pose so AMCL can establish global localization.
- Confirm all three readiness checks pass at the same time.
Provide the initial pose
AMCL needs a map-level estimate of the robot's starting position. The estimate anchors localization to the map.
Placing the pose arrow can feel fiddly because its direction matters.
- Switch back to the RViz window from the running simulation.
- Select 2D Pose Estimate from the RViz toolbar.
- Move the pointer to the robot's approximate location on the map.
- Press the primary mouse button at that location.
- Drag the pose arrow in the robot's forward direction.
- Release the mouse button to submit the estimate.
You should see the particle cloud appear around the robot as localization initializes. This confirms AMCL accepted your estimate.
What did the initial pose unlock?
The initial pose gives AMCL a starting point for localization. AMCL can now establish the connection from map to odom.
The existing odometry path carries that connection from odom to the robot base discovered from incoming messages.
No particle cloud in RViz?
- Select 2D Pose Estimate again if the arrow disappeared before you released the mouse button.
- Place the estimate over the robot's visible location on the map.
- Help me troubleshoot AMCL localization in RViz.
Confirm navigation readiness
The inspector declares readiness only when every live check passes during the same report. Localization should now supply the global connection that was missing.
Before you check the next report, do you expect the global result to change immediately or after AMCL processes the pose?
- Switch back to the inspector terminal from the previous step.
- Watch the next complete readiness report.
You'll see [PASS] Odometry. You'll also see [PASS] Lidar.
You'll see [PASS] Global transform. The report ends with RESULT: READY.
That is the key transition complete: your inspector now proves the simulated robot is ready for navigation.
Why do all three passes matter?
- The [PASS] Odometry line shows fresh local motion data with a usable frame connection.
- The [PASS] Lidar line shows fresh scan data with valid ranges. It also shows that the sensor frame reaches the robot base.
- The [PASS] Global transform line shows that map reaches the discovered robot base through TF2.
When all three checks pass together, RESULT: READY becomes a meaningful navigation gate.
Still seeing NOT READY?
- Confirm the particle cloud remains visible in RViz.
- Repeat the 2D Pose Estimate closer to the robot's visible map location.
- Watch another complete inspector report after AMCL processes the new estimate.
- Help me diagnose the remaining readiness failure.
Secret mission
Add a Lidar Quality Threshold
Fresh lidar messages can still contain too few usable range samples for safe navigation. Add a runtime-adjustable quality threshold that can deliberately block readiness without stopping the sensor stream.
Clean Up Your Resources
Clean Up Your Resources
Everything in this project runs locally, so keeping the workspace creates no ongoing service charges. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- A local ROS 2 workspace at ~/nav_readiness_ws containing the rebuilt nav_readiness_inspector package.
- A running TurtleBot simulation using Gazebo, RViz, and Nav2.
- A running /nav_readiness_inspector node with the min_valid_scan_samples runtime parameter.
Keep everything running
No action is needed while you are still testing the readiness inspector. The workspace and installed packages remain local with no ongoing service charges.
- Keep the simulation terminal running while you continue testing.
- Keep the inspector terminal running while you adjust its runtime threshold.
- Switch back to the simulation terminal when you finish testing.
- Press Ctrl+C to stop Gazebo, RViz, and Nav2.
- Switch back to the inspector terminal.
- Press Ctrl+C to stop the inspector.
- Retain ~/nav_readiness_ws for future robot diagnostics work.
Pause - I'll come back to this later
Shut down the running processes to free your computer's memory. Your workspace stays intact so you can restart the project later.
- Switch back to the simulation terminal.
- Press Ctrl+C to stop Gazebo, RViz, and Nav2.
- Switch back to the inspector terminal.
- Press Ctrl+C to stop the inspector.
- Restart the TurtleBot simulation later by running these commands in a terminal:
source /opt/ros/jazzy/setup.bash
ros2 launch nav2_bringup tb3_simulation_launch.py headless:=False
What does this restart do?
The first command loads the Jazzy environment. The second command relaunches the TurtleBot simulation with Gazebo and RViz.
- Restart the readiness inspector in another terminal by running these commands:
cd ~/nav_readiness_ws
source /opt/ros/jazzy/setup.bash
source install/setup.bash
ros2 run nav_readiness_inspector readiness_inspector
What does this restart do?
- The first command returns to ~/nav_readiness_ws.
- The next two commands load the Jazzy installation plus the rebuilt workspace.
- The final command starts /nav_readiness_inspector with its runtime-adjustable lidar threshold.
Delete - I don't want to use this again
Deleting the workspace is permanent. Your ROS 2 and TurtleBot simulation packages stay installed for future projects.
- Switch back to the simulation terminal.
- Press Ctrl+C to stop Gazebo, RViz, and Nav2.
- Switch back to the inspector terminal.
- Press Ctrl+C to stop the inspector.
- Remove the local workspace by running this command:
rm -rf ~/nav_readiness_ws
What does this remove?
This deletes the local workspace at ~/nav_readiness_ws. The installed Ubuntu packages stay available for future ROS 2 projects.
Nice Work!
Nice Work!
You did it! You built a ROS 2 readiness inspector that turns live navigation data into a clear RESULT: READY verdict for Nav2.
You've learned how to:
- Consume live odometry plus lidar streams through sensor-data QoS in a reusable Python node.
- Build readiness diagnostics that detect stale messages through local receipt times. Validate message-derived frames with TF2 without hardcoding a robot model.
- Diagnose a missing global transform between map and the robot base. Complete the transform chain through AMCL localization until the inspector reports RESULT: READY.
- Complete the optional Secret Mission by adding a runtime-adjustable min_valid_scan_samples threshold. Force a lidar quality failure at runtime. Restore RESULT: READY by lowering the threshold.
Ready to quiz yourself?