feat: Add pure pursuit path follower for Nav2 (Issue #333)
Implements saltybot_pure_pursuit package: - Pure pursuit algorithm for path following with configurable parameters - Lookahead distance (0.5m default) for target point on path - Goal tolerance (0.1m) for goal detection - Heading error correction to reduce speed when misaligned with path - Publishes Twist commands on /cmd_vel_tracked for Nav2 integration - Subscribes to /odom (odometry) and /path (Path trajectory) - Tracks and publishes cross-track error for monitoring Pure pursuit geometry: - Finds closest point on path to robot current position - Looks ahead specified distance along path from closest point - Computes steering angle to follow circular arc to lookahead point - Reduces linear velocity when heading error is large (with correction enabled) - Clamps velocities to configurable maximums Configuration parameters: - lookahead_distance: 0.5m (typical range: 0.1-1.0m) - goal_tolerance: 0.1m (distance to goal before stopping) - heading_tolerance: 0.1 rad (unused but can support in future) - max_linear_velocity: 1.0 m/s - max_angular_velocity: 1.57 rad/s - use_heading_correction: true (reduces speed on large heading errors) Comprehensive test suite: 20+ tests covering: - Geometric calculations (distance, quaternion conversions) - Path following logic (empty path, straight/curved/spiral paths) - Steering calculations (heading errors, velocity limits) - Edge cases and realistic scenarios - Control loop integration - Parameter variations Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4fd7306d01
commit
1c8430e68a
@ -0,0 +1,22 @@
|
|||||||
|
# Pure Pursuit Path Follower Configuration
|
||||||
|
pure_pursuit:
|
||||||
|
ros__parameters:
|
||||||
|
# Path following parameters
|
||||||
|
lookahead_distance: 0.5 # Distance to look ahead on the path (meters)
|
||||||
|
goal_tolerance: 0.1 # Distance tolerance to goal (meters)
|
||||||
|
heading_tolerance: 0.1 # Heading tolerance in radians (rad)
|
||||||
|
|
||||||
|
# Speed parameters
|
||||||
|
max_linear_velocity: 1.0 # Maximum linear velocity (m/s)
|
||||||
|
max_angular_velocity: 1.57 # Maximum angular velocity (rad/s)
|
||||||
|
|
||||||
|
# Control parameters
|
||||||
|
linear_velocity_scale: 1.0 # Scale factor for linear velocity
|
||||||
|
angular_velocity_scale: 1.0 # Scale factor for angular velocity
|
||||||
|
use_heading_correction: true # Apply heading error correction
|
||||||
|
|
||||||
|
# Publishing frequency (Hz)
|
||||||
|
publish_frequency: 10
|
||||||
|
|
||||||
|
# Enable/disable path follower
|
||||||
|
enable_path_following: true
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
import os
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
from launch_ros.substitutions import FindPackageShare
|
||||||
|
from launch.substitutions import PathJoinSubstitution
|
||||||
|
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
config_dir = PathJoinSubstitution(
|
||||||
|
[FindPackageShare('saltybot_pure_pursuit'), 'config']
|
||||||
|
)
|
||||||
|
config_file = PathJoinSubstitution([config_dir, 'pure_pursuit_config.yaml'])
|
||||||
|
|
||||||
|
pure_pursuit = Node(
|
||||||
|
package='saltybot_pure_pursuit',
|
||||||
|
executable='pure_pursuit_node',
|
||||||
|
name='pure_pursuit',
|
||||||
|
output='screen',
|
||||||
|
parameters=[config_file],
|
||||||
|
remappings=[
|
||||||
|
('/odom', '/odom'),
|
||||||
|
('/path', '/path'),
|
||||||
|
('/cmd_vel_tracked', '/cmd_vel_tracked'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return LaunchDescription([
|
||||||
|
pure_pursuit,
|
||||||
|
])
|
||||||
29
jetson/ros2_ws/src/saltybot_pure_pursuit/package.xml
Normal file
29
jetson/ros2_ws/src/saltybot_pure_pursuit/package.xml
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
|
||||||
|
<package format="3">
|
||||||
|
<name>saltybot_pure_pursuit</name>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<description>Pure pursuit path follower for Nav2 autonomous navigation</description>
|
||||||
|
|
||||||
|
<maintainer email="sl-controls@saltybot.local">SaltyBot Controls</maintainer>
|
||||||
|
<license>MIT</license>
|
||||||
|
|
||||||
|
<author email="sl-controls@saltybot.local">SaltyBot Controls Team</author>
|
||||||
|
|
||||||
|
<buildtool_depend>ament_cmake</buildtool_depend>
|
||||||
|
<buildtool_depend>ament_cmake_python</buildtool_depend>
|
||||||
|
|
||||||
|
<depend>rclpy</depend>
|
||||||
|
<depend>nav_msgs</depend>
|
||||||
|
<depend>geometry_msgs</depend>
|
||||||
|
<depend>std_msgs</depend>
|
||||||
|
|
||||||
|
<test_depend>ament_copyright</test_depend>
|
||||||
|
<test_depend>ament_flake8</test_depend>
|
||||||
|
<test_depend>ament_pep257</test_depend>
|
||||||
|
<test_depend>pytest</test_depend>
|
||||||
|
|
||||||
|
<export>
|
||||||
|
<build_type>ament_python</build_type>
|
||||||
|
</export>
|
||||||
|
</package>
|
||||||
@ -0,0 +1,269 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Pure pursuit path follower for Nav2 autonomous navigation.
|
||||||
|
|
||||||
|
Implements the pure pursuit algorithm for following a path or trajectory.
|
||||||
|
The algorithm computes steering commands to make the robot follow a path
|
||||||
|
by targeting a lookahead point on the path.
|
||||||
|
|
||||||
|
The pure pursuit algorithm:
|
||||||
|
1. Finds the closest point on the path to the robot
|
||||||
|
2. Looks ahead a specified distance along the path
|
||||||
|
3. Computes a circular arc passing through robot position to lookahead point
|
||||||
|
4. Publishes velocity commands to follow this arc
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from nav_msgs.msg import Path, Odometry
|
||||||
|
from geometry_msgs.msg import Twist, PoseStamped
|
||||||
|
from std_msgs.msg import Float32
|
||||||
|
|
||||||
|
|
||||||
|
class PurePursuitNode(Node):
|
||||||
|
"""ROS2 node implementing pure pursuit path follower."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('pure_pursuit')
|
||||||
|
|
||||||
|
# Parameters
|
||||||
|
self.declare_parameter('lookahead_distance', 0.5)
|
||||||
|
self.declare_parameter('goal_tolerance', 0.1)
|
||||||
|
self.declare_parameter('heading_tolerance', 0.1)
|
||||||
|
self.declare_parameter('max_linear_velocity', 1.0)
|
||||||
|
self.declare_parameter('max_angular_velocity', 1.57)
|
||||||
|
self.declare_parameter('linear_velocity_scale', 1.0)
|
||||||
|
self.declare_parameter('angular_velocity_scale', 1.0)
|
||||||
|
self.declare_parameter('use_heading_correction', True)
|
||||||
|
self.declare_parameter('publish_frequency', 10)
|
||||||
|
self.declare_parameter('enable_path_following', True)
|
||||||
|
|
||||||
|
# Read parameters
|
||||||
|
self.lookahead_distance = self.get_parameter('lookahead_distance').value
|
||||||
|
self.goal_tolerance = self.get_parameter('goal_tolerance').value
|
||||||
|
self.heading_tolerance = self.get_parameter('heading_tolerance').value
|
||||||
|
self.max_linear_velocity = self.get_parameter('max_linear_velocity').value
|
||||||
|
self.max_angular_velocity = self.get_parameter('max_angular_velocity').value
|
||||||
|
self.linear_velocity_scale = self.get_parameter('linear_velocity_scale').value
|
||||||
|
self.angular_velocity_scale = self.get_parameter('angular_velocity_scale').value
|
||||||
|
self.use_heading_correction = self.get_parameter('use_heading_correction').value
|
||||||
|
publish_frequency = self.get_parameter('publish_frequency').value
|
||||||
|
self.enable_path_following = self.get_parameter('enable_path_following').value
|
||||||
|
|
||||||
|
# Current state
|
||||||
|
self.current_pose = None
|
||||||
|
self.current_path = None
|
||||||
|
self.goal_reached = False
|
||||||
|
|
||||||
|
# Subscriptions
|
||||||
|
self.sub_odom = self.create_subscription(
|
||||||
|
Odometry, '/odom', self._on_odometry, 10
|
||||||
|
)
|
||||||
|
self.sub_path = self.create_subscription(
|
||||||
|
Path, '/path', self._on_path, 10
|
||||||
|
)
|
||||||
|
|
||||||
|
# Publishers
|
||||||
|
self.pub_cmd_vel = self.create_publisher(Twist, '/cmd_vel_tracked', 10)
|
||||||
|
self.pub_tracking_error = self.create_publisher(Float32, '/tracking_error', 10)
|
||||||
|
|
||||||
|
# Timer for control loop
|
||||||
|
period = 1.0 / publish_frequency
|
||||||
|
self.timer = self.create_timer(period, self._control_loop)
|
||||||
|
|
||||||
|
self.get_logger().info(
|
||||||
|
f"Pure pursuit initialized. "
|
||||||
|
f"Lookahead: {self.lookahead_distance}m, "
|
||||||
|
f"Goal tolerance: {self.goal_tolerance}m"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_odometry(self, msg: Odometry) -> None:
|
||||||
|
"""Callback for odometry messages."""
|
||||||
|
self.current_pose = msg.pose.pose
|
||||||
|
|
||||||
|
def _on_path(self, msg: Path) -> None:
|
||||||
|
"""Callback for path messages."""
|
||||||
|
self.current_path = msg
|
||||||
|
|
||||||
|
def _quaternion_to_yaw(self, quat):
|
||||||
|
"""Convert quaternion to yaw angle."""
|
||||||
|
siny_cosp = 2 * (quat.w * quat.z + quat.x * quat.y)
|
||||||
|
cosy_cosp = 1 - 2 * (quat.y * quat.y + quat.z * quat.z)
|
||||||
|
yaw = math.atan2(siny_cosp, cosy_cosp)
|
||||||
|
return yaw
|
||||||
|
|
||||||
|
def _yaw_to_quaternion(self, yaw):
|
||||||
|
"""Convert yaw angle to quaternion."""
|
||||||
|
from geometry_msgs.msg import Quaternion
|
||||||
|
cy = math.cos(yaw * 0.5)
|
||||||
|
sy = math.sin(yaw * 0.5)
|
||||||
|
return Quaternion(x=0.0, y=0.0, z=sy, w=cy)
|
||||||
|
|
||||||
|
def _distance(self, p1, p2):
|
||||||
|
"""Compute Euclidean distance between two points."""
|
||||||
|
dx = p1.x - p2.x
|
||||||
|
dy = p1.y - p2.y
|
||||||
|
return math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
def _find_closest_point_on_path(self):
|
||||||
|
"""Find closest point on path to current robot position."""
|
||||||
|
if self.current_path is None or len(self.current_path.poses) < 2:
|
||||||
|
return None, 0, float('inf')
|
||||||
|
|
||||||
|
closest_idx = 0
|
||||||
|
closest_dist = float('inf')
|
||||||
|
|
||||||
|
# Find closest waypoint
|
||||||
|
for i, pose in enumerate(self.current_path.poses):
|
||||||
|
dist = self._distance(self.current_pose.position, pose.pose.position)
|
||||||
|
if dist < closest_dist:
|
||||||
|
closest_dist = dist
|
||||||
|
closest_idx = i
|
||||||
|
|
||||||
|
return closest_idx, closest_dist, closest_idx
|
||||||
|
|
||||||
|
def _find_lookahead_point(self):
|
||||||
|
"""Find lookahead point on path ahead of closest point."""
|
||||||
|
if self.current_path is None or len(self.current_path.poses) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
closest_idx, _, _ = self._find_closest_point_on_path()
|
||||||
|
if closest_idx is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find point at lookahead distance along path
|
||||||
|
current_dist = 0.0
|
||||||
|
for i in range(closest_idx, len(self.current_path.poses) - 1):
|
||||||
|
p1 = self.current_path.poses[i].pose.position
|
||||||
|
p2 = self.current_path.poses[i + 1].pose.position
|
||||||
|
segment_dist = self._distance(p1, p2)
|
||||||
|
current_dist += segment_dist
|
||||||
|
|
||||||
|
if current_dist >= self.lookahead_distance:
|
||||||
|
# Interpolate between p1 and p2
|
||||||
|
overshoot = current_dist - self.lookahead_distance
|
||||||
|
if segment_dist > 0:
|
||||||
|
alpha = 1.0 - (overshoot / segment_dist)
|
||||||
|
from geometry_msgs.msg import Point
|
||||||
|
lookahead = Point()
|
||||||
|
lookahead.x = p1.x + alpha * (p2.x - p1.x)
|
||||||
|
lookahead.y = p1.y + alpha * (p2.y - p1.y)
|
||||||
|
lookahead.z = 0.0
|
||||||
|
return lookahead
|
||||||
|
|
||||||
|
# If we get here, return the last point
|
||||||
|
return self.current_path.poses[-1].pose.position
|
||||||
|
|
||||||
|
def _calculate_steering_command(self, lookahead_point):
|
||||||
|
"""Calculate steering angle using pure pursuit geometry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lookahead_point: Target lookahead point on the path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (linear_velocity, angular_velocity)
|
||||||
|
"""
|
||||||
|
if lookahead_point is None or self.current_pose is None:
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
# Vector from robot to lookahead point
|
||||||
|
dx = lookahead_point.x - self.current_pose.position.x
|
||||||
|
dy = lookahead_point.y - self.current_pose.position.y
|
||||||
|
distance_to_lookahead = math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
# Check if goal is reached
|
||||||
|
if distance_to_lookahead < self.goal_tolerance:
|
||||||
|
self.goal_reached = True
|
||||||
|
return 0.0, 0.0
|
||||||
|
|
||||||
|
# Robot heading
|
||||||
|
robot_yaw = self._quaternion_to_yaw(self.current_pose.orientation)
|
||||||
|
|
||||||
|
# Angle to lookahead point
|
||||||
|
angle_to_lookahead = math.atan2(dy, dx)
|
||||||
|
|
||||||
|
# Heading error
|
||||||
|
heading_error = angle_to_lookahead - robot_yaw
|
||||||
|
|
||||||
|
# Normalize angle to [-pi, pi]
|
||||||
|
while heading_error > math.pi:
|
||||||
|
heading_error -= 2 * math.pi
|
||||||
|
while heading_error < -math.pi:
|
||||||
|
heading_error += 2 * math.pi
|
||||||
|
|
||||||
|
# Pure pursuit curvature: k = 2 * sin(alpha) / Ld
|
||||||
|
# where alpha is the heading error and Ld is lookahead distance
|
||||||
|
if self.lookahead_distance > 0:
|
||||||
|
curvature = (2.0 * math.sin(heading_error)) / self.lookahead_distance
|
||||||
|
else:
|
||||||
|
curvature = 0.0
|
||||||
|
|
||||||
|
# Linear velocity (reduce when heading error is large)
|
||||||
|
if self.use_heading_correction:
|
||||||
|
heading_error_abs = abs(heading_error)
|
||||||
|
linear_velocity = self.max_linear_velocity * math.cos(heading_error)
|
||||||
|
linear_velocity = max(0.0, linear_velocity) # Don't go backwards
|
||||||
|
else:
|
||||||
|
linear_velocity = self.max_linear_velocity
|
||||||
|
|
||||||
|
# Angular velocity from curvature: omega = v * k
|
||||||
|
angular_velocity = linear_velocity * curvature
|
||||||
|
|
||||||
|
# Clamp velocities
|
||||||
|
linear_velocity = min(linear_velocity, self.max_linear_velocity)
|
||||||
|
angular_velocity = max(-self.max_angular_velocity,
|
||||||
|
min(angular_velocity, self.max_angular_velocity))
|
||||||
|
|
||||||
|
# Apply scaling
|
||||||
|
linear_velocity *= self.linear_velocity_scale
|
||||||
|
angular_velocity *= self.angular_velocity_scale
|
||||||
|
|
||||||
|
return linear_velocity, angular_velocity
|
||||||
|
|
||||||
|
def _control_loop(self) -> None:
|
||||||
|
"""Main control loop executed at regular intervals."""
|
||||||
|
if not self.enable_path_following or self.current_pose is None:
|
||||||
|
# Publish zero velocity
|
||||||
|
cmd = Twist()
|
||||||
|
self.pub_cmd_vel.publish(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find lookahead point
|
||||||
|
lookahead_point = self._find_lookahead_point()
|
||||||
|
|
||||||
|
if lookahead_point is None:
|
||||||
|
# No valid path, publish zero velocity
|
||||||
|
cmd = Twist()
|
||||||
|
self.pub_cmd_vel.publish(cmd)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Calculate steering command
|
||||||
|
linear_vel, angular_vel = self._calculate_steering_command(lookahead_point)
|
||||||
|
|
||||||
|
# Publish velocity command
|
||||||
|
cmd = Twist()
|
||||||
|
cmd.linear.x = linear_vel
|
||||||
|
cmd.angular.z = angular_vel
|
||||||
|
self.pub_cmd_vel.publish(cmd)
|
||||||
|
|
||||||
|
# Publish tracking error
|
||||||
|
if self.current_path and len(self.current_path.poses) > 0:
|
||||||
|
closest_idx, tracking_error, _ = self._find_closest_point_on_path()
|
||||||
|
error_msg = Float32(data=tracking_error)
|
||||||
|
self.pub_tracking_error.publish(error_msg)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = PurePursuitNode()
|
||||||
|
try:
|
||||||
|
rclpy.spin(node)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
5
jetson/ros2_ws/src/saltybot_pure_pursuit/setup.cfg
Normal file
5
jetson/ros2_ws/src/saltybot_pure_pursuit/setup.cfg
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[develop]
|
||||||
|
script_dir=$base/lib/saltybot_pure_pursuit
|
||||||
|
[egg_info]
|
||||||
|
tag_build =
|
||||||
|
tag_date = 0
|
||||||
34
jetson/ros2_ws/src/saltybot_pure_pursuit/setup.py
Normal file
34
jetson/ros2_ws/src/saltybot_pure_pursuit/setup.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from setuptools import setup, find_packages
|
||||||
|
|
||||||
|
package_name = 'saltybot_pure_pursuit'
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name=package_name,
|
||||||
|
version='0.1.0',
|
||||||
|
packages=find_packages(exclude=['test']),
|
||||||
|
data_files=[
|
||||||
|
('share/ament_index/resource_index/packages',
|
||||||
|
['resource/saltybot_pure_pursuit']),
|
||||||
|
('share/' + package_name, ['package.xml']),
|
||||||
|
('share/' + package_name + '/config', ['config/pure_pursuit_config.yaml']),
|
||||||
|
('share/' + package_name + '/launch', ['launch/pure_pursuit.launch.py']),
|
||||||
|
],
|
||||||
|
install_requires=['setuptools'],
|
||||||
|
zip_safe=True,
|
||||||
|
author='SaltyBot Controls',
|
||||||
|
author_email='sl-controls@saltybot.local',
|
||||||
|
maintainer='SaltyBot Controls',
|
||||||
|
maintainer_email='sl-controls@saltybot.local',
|
||||||
|
keywords=['ROS2', 'pure_pursuit', 'path_following', 'nav2'],
|
||||||
|
classifiers=[
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'License :: OSI Approved :: MIT License',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
'Topic :: Software Development',
|
||||||
|
],
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'pure_pursuit_node=saltybot_pure_pursuit.pure_pursuit_node:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
@ -0,0 +1,397 @@
|
|||||||
|
"""Unit tests for pure pursuit path follower node."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import math
|
||||||
|
from nav_msgs.msg import Path, Odometry
|
||||||
|
from geometry_msgs.msg import PoseStamped, Pose, Point, Quaternion, Twist
|
||||||
|
from std_msgs.msg import Header
|
||||||
|
|
||||||
|
import rclpy
|
||||||
|
|
||||||
|
from saltybot_pure_pursuit.pure_pursuit_node import PurePursuitNode
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rclpy_fixture():
|
||||||
|
"""Initialize and cleanup rclpy."""
|
||||||
|
rclpy.init()
|
||||||
|
yield
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def node(rclpy_fixture):
|
||||||
|
"""Create a pure pursuit node instance."""
|
||||||
|
node = PurePursuitNode()
|
||||||
|
yield node
|
||||||
|
node.destroy_node()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPurePursuitGeometry:
|
||||||
|
"""Test suite for pure pursuit geometric calculations."""
|
||||||
|
|
||||||
|
def test_node_initialization(self, node):
|
||||||
|
"""Test that node initializes with correct defaults."""
|
||||||
|
assert node.lookahead_distance == 0.5
|
||||||
|
assert node.goal_tolerance == 0.1
|
||||||
|
assert node.max_linear_velocity == 1.0
|
||||||
|
assert node.enable_path_following is True
|
||||||
|
|
||||||
|
def test_distance_calculation(self, node):
|
||||||
|
"""Test Euclidean distance calculation."""
|
||||||
|
p1 = Point(x=0.0, y=0.0, z=0.0)
|
||||||
|
p2 = Point(x=3.0, y=4.0, z=0.0)
|
||||||
|
dist = node._distance(p1, p2)
|
||||||
|
assert abs(dist - 5.0) < 0.01
|
||||||
|
|
||||||
|
def test_distance_same_point(self, node):
|
||||||
|
"""Test distance between same point is zero."""
|
||||||
|
p = Point(x=1.0, y=2.0, z=0.0)
|
||||||
|
dist = node._distance(p, p)
|
||||||
|
assert abs(dist) < 0.001
|
||||||
|
|
||||||
|
def test_quaternion_to_yaw_north(self, node):
|
||||||
|
"""Test quaternion to yaw conversion for north heading."""
|
||||||
|
quat = Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
yaw = node._quaternion_to_yaw(quat)
|
||||||
|
assert abs(yaw) < 0.01
|
||||||
|
|
||||||
|
def test_quaternion_to_yaw_east(self, node):
|
||||||
|
"""Test quaternion to yaw conversion for east heading."""
|
||||||
|
# 90 degree rotation around z-axis
|
||||||
|
quat = Quaternion(x=0.0, y=0.0, z=0.7071, w=0.7071)
|
||||||
|
yaw = node._quaternion_to_yaw(quat)
|
||||||
|
assert abs(yaw - math.pi / 2) < 0.01
|
||||||
|
|
||||||
|
def test_yaw_to_quaternion_identity(self, node):
|
||||||
|
"""Test yaw to quaternion conversion."""
|
||||||
|
quat = node._yaw_to_quaternion(0.0)
|
||||||
|
assert abs(quat.w - 1.0) < 0.01
|
||||||
|
assert abs(quat.z) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
class TestPathFollowing:
|
||||||
|
"""Test suite for path following logic."""
|
||||||
|
|
||||||
|
def test_empty_path(self, node):
|
||||||
|
"""Test handling of empty path."""
|
||||||
|
node.current_pose = Pose(position=Point(x=0.0, y=0.0, z=0.0))
|
||||||
|
node.current_path = Path()
|
||||||
|
|
||||||
|
lookahead = node._find_lookahead_point()
|
||||||
|
assert lookahead is None
|
||||||
|
|
||||||
|
def test_single_point_path(self, node):
|
||||||
|
"""Test handling of single-point path."""
|
||||||
|
node.current_pose = Pose(position=Point(x=0.0, y=0.0, z=0.0))
|
||||||
|
|
||||||
|
path = Path()
|
||||||
|
path.poses = [PoseStamped(pose=Pose(position=Point(x=1.0, y=0.0, z=0.0)))]
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
lookahead = node._find_lookahead_point()
|
||||||
|
assert lookahead is None or lookahead.x == 1.0
|
||||||
|
|
||||||
|
def test_straight_line_path(self, node):
|
||||||
|
"""Test path following on straight line."""
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create straight path along x-axis
|
||||||
|
path = Path()
|
||||||
|
for i in range(5):
|
||||||
|
pose = PoseStamped(pose=Pose(
|
||||||
|
position=Point(x=float(i), y=0.0, z=0.0)
|
||||||
|
))
|
||||||
|
path.poses.append(pose)
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
# Robot heading east towards path
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(path.poses[1].pose.position)
|
||||||
|
assert lin_vel >= 0
|
||||||
|
assert abs(ang_vel) < 0.5 # Should have small steering error
|
||||||
|
|
||||||
|
def test_curved_path(self, node):
|
||||||
|
"""Test path following on curved path."""
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create circular arc path
|
||||||
|
path = Path()
|
||||||
|
for i in range(9):
|
||||||
|
angle = (i / 8.0) * (math.pi / 2)
|
||||||
|
x = math.sin(angle)
|
||||||
|
y = 1.0 - math.cos(angle)
|
||||||
|
pose = PoseStamped(pose=Pose(position=Point(x=x, y=y, z=0.0)))
|
||||||
|
path.poses.append(pose)
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
lookahead = node._find_lookahead_point()
|
||||||
|
assert lookahead is not None
|
||||||
|
|
||||||
|
def test_goal_reached(self, node):
|
||||||
|
"""Test goal reached detection."""
|
||||||
|
goal_pos = Point(x=1.0, y=0.0, z=0.0)
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=1.05, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(goal_pos)
|
||||||
|
# With goal_tolerance=0.1, we should be at goal
|
||||||
|
assert abs(lin_vel) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
class TestSteeringCalculation:
|
||||||
|
"""Test suite for steering command calculations."""
|
||||||
|
|
||||||
|
def test_zero_heading_error(self, node):
|
||||||
|
"""Test steering when robot already aligned with target."""
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0) # Facing east
|
||||||
|
)
|
||||||
|
|
||||||
|
# Target directly ahead
|
||||||
|
target = Point(x=1.0, y=0.0, z=0.0)
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(target)
|
||||||
|
|
||||||
|
assert lin_vel > 0
|
||||||
|
assert abs(ang_vel) < 0.1 # Minimal steering
|
||||||
|
|
||||||
|
def test_90_degree_heading_error(self, node):
|
||||||
|
"""Test steering with 90 degree heading error."""
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0) # Facing east
|
||||||
|
)
|
||||||
|
|
||||||
|
# Target north
|
||||||
|
target = Point(x=0.0, y=1.0, z=0.0)
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(target)
|
||||||
|
|
||||||
|
assert lin_vel >= 0
|
||||||
|
assert ang_vel != 0 # Should have significant steering
|
||||||
|
|
||||||
|
def test_velocity_limits(self, node):
|
||||||
|
"""Test that velocities are within limits."""
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
target = Point(x=100.0, y=100.0, z=0.0)
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(target)
|
||||||
|
|
||||||
|
assert lin_vel <= node.max_linear_velocity
|
||||||
|
assert abs(ang_vel) <= node.max_angular_velocity
|
||||||
|
|
||||||
|
def test_heading_correction_enabled(self, node):
|
||||||
|
"""Test heading correction when enabled."""
|
||||||
|
node.use_heading_correction = True
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Large heading error
|
||||||
|
target = Point(x=0.0, y=10.0, z=0.0) # Due north
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(target)
|
||||||
|
|
||||||
|
# Linear velocity should be reduced due to heading error
|
||||||
|
assert lin_vel < node.max_linear_velocity
|
||||||
|
|
||||||
|
def test_heading_correction_disabled(self, node):
|
||||||
|
"""Test that heading correction can be disabled."""
|
||||||
|
node.use_heading_correction = False
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
target = Point(x=0.0, y=10.0, z=0.0)
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(target)
|
||||||
|
|
||||||
|
# Linear velocity should be full
|
||||||
|
assert abs(lin_vel - node.max_linear_velocity) < 0.01
|
||||||
|
|
||||||
|
def test_negative_lookahead_distance(self, node):
|
||||||
|
"""Test behavior with invalid lookahead distance."""
|
||||||
|
node.lookahead_distance = 0.0
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
target = Point(x=1.0, y=1.0, z=0.0)
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(target)
|
||||||
|
|
||||||
|
# Should not crash, handle gracefully
|
||||||
|
assert isinstance(lin_vel, float)
|
||||||
|
assert isinstance(ang_vel, float)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPurePursuitScenarios:
|
||||||
|
"""Integration-style tests for realistic scenarios."""
|
||||||
|
|
||||||
|
def test_scenario_follow_straight_path(self, node):
|
||||||
|
"""Scenario: Follow a straight horizontal path."""
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Horizontal path
|
||||||
|
path = Path()
|
||||||
|
for i in range(10):
|
||||||
|
pose = PoseStamped(pose=Pose(position=Point(x=float(i), y=0.0, z=0.0)))
|
||||||
|
path.poses.append(pose)
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(path.poses[1].pose.position)
|
||||||
|
assert lin_vel > 0
|
||||||
|
assert abs(ang_vel) < 0.5
|
||||||
|
|
||||||
|
def test_scenario_s_shaped_path(self, node):
|
||||||
|
"""Scenario: Follow an S-shaped path."""
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# S-shaped path
|
||||||
|
path = Path()
|
||||||
|
for i in range(20):
|
||||||
|
x = float(i) * 0.5
|
||||||
|
y = 2.0 * math.sin(x)
|
||||||
|
pose = PoseStamped(pose=Pose(position=Point(x=x, y=y, z=0.0)))
|
||||||
|
path.poses.append(pose)
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
lookahead = node._find_lookahead_point()
|
||||||
|
assert lookahead is not None
|
||||||
|
|
||||||
|
def test_scenario_spiral_path(self, node):
|
||||||
|
"""Scenario: Follow a spiral path."""
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Spiral path
|
||||||
|
path = Path()
|
||||||
|
for i in range(30):
|
||||||
|
angle = (i / 30.0) * (4 * math.pi)
|
||||||
|
radius = 0.5 + (i / 30.0) * 2.0
|
||||||
|
x = radius * math.cos(angle)
|
||||||
|
y = radius * math.sin(angle)
|
||||||
|
pose = PoseStamped(pose=Pose(position=Point(x=x, y=y, z=0.0)))
|
||||||
|
path.poses.append(pose)
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(path.poses[5].pose.position)
|
||||||
|
assert isinstance(lin_vel, float)
|
||||||
|
assert isinstance(ang_vel, float)
|
||||||
|
|
||||||
|
def test_scenario_control_loop(self, node):
|
||||||
|
"""Scenario: Control loop with valid path and odometry."""
|
||||||
|
node.enable_path_following = True
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a simple path
|
||||||
|
path = Path()
|
||||||
|
for i in range(5):
|
||||||
|
pose = PoseStamped(pose=Pose(position=Point(x=float(i), y=0.0, z=0.0)))
|
||||||
|
path.poses.append(pose)
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
# Run control loop
|
||||||
|
node._control_loop()
|
||||||
|
|
||||||
|
# Should complete without error
|
||||||
|
|
||||||
|
def test_scenario_disabled_path_following(self, node):
|
||||||
|
"""Scenario: Path following disabled."""
|
||||||
|
node.enable_path_following = False
|
||||||
|
node.current_pose = Pose(position=Point(x=0.0, y=0.0, z=0.0))
|
||||||
|
|
||||||
|
# Create path
|
||||||
|
path = Path()
|
||||||
|
path.poses = [PoseStamped(pose=Pose(position=Point(x=1.0, y=0.0, z=0.0)))]
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
# Run control loop
|
||||||
|
node._control_loop()
|
||||||
|
|
||||||
|
# Should publish zero velocity
|
||||||
|
|
||||||
|
def test_scenario_no_odometry(self, node):
|
||||||
|
"""Scenario: Control loop when odometry not received."""
|
||||||
|
node.current_pose = None
|
||||||
|
path = Path()
|
||||||
|
path.poses = [PoseStamped(pose=Pose(position=Point(x=1.0, y=0.0, z=0.0)))]
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
# Run control loop
|
||||||
|
node._control_loop()
|
||||||
|
|
||||||
|
# Should handle gracefully
|
||||||
|
|
||||||
|
def test_scenario_velocity_scaling(self, node):
|
||||||
|
"""Scenario: Velocity scaling parameters."""
|
||||||
|
node.linear_velocity_scale = 0.5
|
||||||
|
node.angular_velocity_scale = 0.5
|
||||||
|
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
target = Point(x=1.0, y=0.0, z=0.0)
|
||||||
|
lin_vel, ang_vel = node._calculate_steering_command(target)
|
||||||
|
|
||||||
|
# Scaled velocities should be smaller
|
||||||
|
assert lin_vel <= node.max_linear_velocity * 0.5 + 0.01
|
||||||
|
|
||||||
|
def test_scenario_large_lookahead_distance(self, node):
|
||||||
|
"""Scenario: Large lookahead distance."""
|
||||||
|
node.lookahead_distance = 5.0
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Path
|
||||||
|
path = Path()
|
||||||
|
for i in range(10):
|
||||||
|
pose = PoseStamped(pose=Pose(position=Point(x=float(i), y=0.0, z=0.0)))
|
||||||
|
path.poses.append(pose)
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
lookahead = node._find_lookahead_point()
|
||||||
|
assert lookahead is not None
|
||||||
|
|
||||||
|
def test_scenario_small_lookahead_distance(self, node):
|
||||||
|
"""Scenario: Small lookahead distance."""
|
||||||
|
node.lookahead_distance = 0.05
|
||||||
|
node.current_pose = Pose(
|
||||||
|
position=Point(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=Quaternion(x=0.0, y=0.0, z=0.0, w=1.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Path
|
||||||
|
path = Path()
|
||||||
|
for i in range(10):
|
||||||
|
pose = PoseStamped(pose=Pose(position=Point(x=float(i), y=0.0, z=0.0)))
|
||||||
|
path.poses.append(pose)
|
||||||
|
node.current_path = path
|
||||||
|
|
||||||
|
lookahead = node._find_lookahead_point()
|
||||||
|
assert lookahead is not None
|
||||||
Loading…
x
Reference in New Issue
Block a user