Skip to content

Commit 2d10c16

Browse files
committed
WIP
1 parent 898bc70 commit 2d10c16

File tree

5 files changed

+314
-3
lines changed

5 files changed

+314
-3
lines changed

artefacts.yaml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,29 @@ jobs:
2121
- name: bringup
2222
ros_testfile: "src/sam_bot_nav2_gz/test/test_bringup.launch.py"
2323

24+
pytest:
25+
type: test
26+
package:
27+
docker:
28+
build:
29+
dockerfile: ./Dockerfile
30+
runtime:
31+
simulator: gazebo:fortress
32+
framework: null
33+
timeout: 5 #minutes
34+
scenarios:
35+
defaults: # Global to all scenarios, and overriden in specific scenarios.
36+
output_dirs: ["output"]
37+
metrics:
38+
- /odometry_error
39+
- /distance_from_start_gt
40+
- /distance_from_start_est
41+
params:
42+
launch/world: ["empty.sdf"]
43+
controller_server/controller_frequency: [10.0]
44+
settings:
45+
- name: reach_goal
46+
run: "python3 -m pytest src/sam_bot_nav2_gz/test/test_reach_goal_pytest.py -s --junit-xml output/tests_junit.xml"
2447
nav2:
2548
type: test
2649
package:
@@ -39,7 +62,7 @@ jobs:
3962
- /distance_from_start_gt
4063
- /distance_from_start_est
4164
params:
42-
launch/world: ["bookstore.sdf"]
65+
launch/world: ["empty.sdf"]
4366
controller_server/controller_frequency: [10.0]
4467
settings:
4568
- name: reach_goal

src/sam_bot_nav2_gz/test/bridge.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@
33
ros_type_name: "geometry_msgs/msg/PoseArray"
44
gz_type_name: "ignition.msgs.Pose_V"
55
direction: GZ_TO_ROS
6+
- ros_topic_name: "/world/worldWrapper/pose"
7+
gz_topic_name: "/world/worldWrapper/pose"
8+
ros_type_name: "geometry_msgs/msg/TransformStamped"
9+
gz_type_name: "ignition.msgs.Pose"
10+
direction: GZ_TO_ROS
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import rclpy
2+
from rclpy.node import Node
3+
from rclpy.callback_groups import ReentrantCallbackGroup
4+
from geometry_msgs.msg import TransformStamped
5+
import math
6+
import threading
7+
import time
8+
from typing import Tuple
9+
10+
11+
class RobotState:
12+
"""Represents the current state of the robot in simulation"""
13+
14+
def __init__(self):
15+
self.position = (0.0, 0.0)
16+
self._lock = threading.Lock()
17+
18+
def update_transform(self, transform_msg: TransformStamped):
19+
"""Update robot state from transform message"""
20+
with self._lock:
21+
self.position = (
22+
transform_msg.transform.translation.x,
23+
transform_msg.transform.translation.y
24+
)
25+
26+
def distance_to(self, coordinates: Tuple[float, float]) -> float:
27+
"""Calculate distance from current position to target coordinates"""
28+
with self._lock:
29+
dx = self.position[0] - coordinates[0]
30+
dy = self.position[1] - coordinates[1]
31+
return math.sqrt(dx**2 + dy**2)
32+
33+
34+
class SimulationStateUtil(Node):
35+
"""Utility class for accessing simulation state in tests"""
36+
37+
def __init__(self):
38+
super().__init__("simulation_state_util")
39+
self.get_logger().info("Simulation State Util Started")
40+
41+
# Create callback group for parallel execution
42+
self.callback_group = ReentrantCallbackGroup()
43+
44+
# Robot state
45+
self.robot = RobotState()
46+
47+
# Data received flag
48+
self._transform_received = False
49+
50+
# Subscriber
51+
self._setup_subscriptions()
52+
53+
def _setup_subscriptions(self):
54+
"""Set up ROS2 subscription for ground truth transform"""
55+
self.transform_sub = self.create_subscription(
56+
TransformStamped,
57+
"/world/worldWrapper/pose",
58+
self._transform_callback,
59+
10,
60+
callback_group=self.callback_group
61+
)
62+
63+
def _transform_callback(self, msg: TransformStamped):
64+
"""Handle transform messages"""
65+
self.robot.update_transform(msg)
66+
self._transform_received = True
67+
68+
def wait_for_initial_state(self, timeout: float = 10.0) -> bool:
69+
"""Wait for initial data from transform subscription"""
70+
start_time = time.time()
71+
while time.time() - start_time < timeout:
72+
if self._transform_received:
73+
self.get_logger().info("Initial simulation state received")
74+
return True
75+
time.sleep(0.1)
76+
77+
self.get_logger().warn("Timeout waiting for initial simulation state")
78+
return False
79+
80+
def get(self, entity: str):
81+
"""Get simulation entity by name"""
82+
if entity == "robot":
83+
return self.robot
84+
else:
85+
raise ValueError(f"Unknown entity: {entity}")
86+
87+
def shutdown_util(self):
88+
"""Clean shutdown of the utility"""
89+
self.get_logger().info("Shutting down simulation state util")
90+
self.destroy_node()
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import os
2+
import pytest
3+
import rclpy
4+
from ament_index_python.packages import get_package_share_directory
5+
from launch import LaunchDescription
6+
from launch.actions import IncludeLaunchDescription, ExecuteProcess, DeclareLaunchArgument
7+
from launch.substitutions import LaunchConfiguration
8+
from launch.launch_description_sources import PythonLaunchDescriptionSource
9+
from launch_ros.actions import Node
10+
import launch_pytest
11+
from launch_pytest.tools import process as process_tools
12+
from artefacts_toolkit.rosbag import rosbag, image_topics
13+
from artefacts_toolkit.chart import make_chart
14+
from artefacts_toolkit.config import get_artefacts_param
15+
import sys
16+
import os
17+
sys.path.append(os.path.dirname(__file__))
18+
from simulation_state_util import SimulationStateUtil
19+
20+
21+
ARTEFACTS_PARAMS_FILE = os.environ.get(
22+
"ARTEFACTS_SCENARIO_PARAMS_FILE", "scenario_params.yaml"
23+
)
24+
25+
def deep_merge_dicts(source, override):
26+
"""Recursively merge two dictionaries, with values from `override` taking precedence over `source`"""
27+
for key, value in override.items():
28+
if isinstance(value, dict) and key in source:
29+
source[key] = deep_merge_dicts(source[key], value)
30+
else:
31+
source[key] = value
32+
return source
33+
34+
def merge_ros_params_files(source, override, destination):
35+
"""Merge two ROS2 yaml parameter files into one, overriding the values in the first one with the values in `override`"""
36+
import yaml
37+
38+
with open(source, "r") as f:
39+
source_params = yaml.safe_load(f)
40+
41+
with open(override, "r") as f:
42+
override_params = yaml.safe_load(f)
43+
44+
merged_params = deep_merge_dicts(source_params, override_params)
45+
with open(destination, "w") as f:
46+
yaml.dump(merged_params, f)
47+
48+
@pytest.fixture()
49+
def reach_goal_proc():
50+
reach_goal = Node(
51+
package="sam_bot_nav2_gz",
52+
executable="reach_goal.py",
53+
output="screen",
54+
cached_output=True,
55+
)
56+
return reach_goal
57+
58+
59+
@launch_pytest.fixture
60+
def launch_description(reach_goal_proc):
61+
"""Launch description fixture for pytest-based testing"""
62+
try:
63+
world = get_artefacts_param("launch", "world", default="empty.sdf")
64+
except FileNotFoundError:
65+
world = "empty.sdf"
66+
67+
run_headless = LaunchConfiguration("run_headless")
68+
source_params_file = "src/sam_bot_nav2_gz/config/nav2_params.yaml"
69+
new_params_file = "all_params.yaml"
70+
try:
71+
merge_ros_params_files(source_params_file, ARTEFACTS_PARAMS_FILE, new_params_file)
72+
except FileNotFoundError:
73+
pass
74+
75+
launch_navigation_stack = IncludeLaunchDescription(
76+
PythonLaunchDescriptionSource(
77+
[
78+
os.path.join(
79+
get_package_share_directory("sam_bot_nav2_gz"),
80+
"launch",
81+
"complete_navigation.launch.py"
82+
),
83+
]
84+
),
85+
launch_arguments=[
86+
("run_headless", run_headless),
87+
("world_file", world),
88+
("params_file", new_params_file),
89+
],
90+
)
91+
92+
93+
topics = ["/odom"]
94+
metrics = ["/distance_from_start_gt", "/distance_from_start_est", "/odometry_error"]
95+
camera_topics = ["/sky_cam"]
96+
sim_topics = ["/world/dynamic_pose/info"]
97+
bag_recorder, rosbag_filepath = rosbag.get_bag_recorder(
98+
topics + sim_topics + metrics + camera_topics, use_sim_time=True
99+
)
100+
101+
# Gazebo ros bridge
102+
gz_bridge = Node(
103+
package="ros_gz_bridge",
104+
executable="parameter_bridge",
105+
parameters=[{
106+
"config_file": os.path.join(
107+
"src",
108+
"sam_bot_nav2_gz",
109+
"test",
110+
"bridge.yaml"
111+
)}],
112+
output="screen",
113+
)
114+
115+
test_odometry_node = ExecuteProcess(
116+
cmd=[
117+
"python3",
118+
os.path.join(
119+
"src",
120+
"sam_bot_nav2_gz",
121+
"test",
122+
"test_odometry_node.py"
123+
),
124+
]
125+
)
126+
127+
return LaunchDescription(
128+
[
129+
DeclareLaunchArgument(
130+
name="run_headless",
131+
default_value="False",
132+
description="Start GZ in headless mode and don't start RViz (overrides use_rviz)",
133+
),
134+
launch_navigation_stack,
135+
reach_goal_proc,
136+
test_odometry_node,
137+
gz_bridge,
138+
#bag_recorder,
139+
]
140+
)
141+
142+
143+
@pytest.fixture(scope="function")
144+
def simulation_state_util():
145+
"""Fixture that provides access to simulation state during tests"""
146+
if not rclpy.ok():
147+
rclpy.init()
148+
util = SimulationStateUtil()
149+
150+
# Wait for initial state
151+
util.wait_for_initial_state(timeout=15.0)
152+
153+
yield util
154+
155+
# Cleanup
156+
util.shutdown_util()
157+
158+
159+
@pytest.mark.launch(fixture=launch_description)
160+
def test_nav2_started(reach_goal_proc, launch_context):
161+
"""Test that Nav2 starts successfully"""
162+
def validate_nav2_output(output):
163+
return output and 'Nav2 active!' in output
164+
165+
# Get the reach_goal process from the launch context
166+
process_tools.wait_for_output_sync(
167+
launch_context, reach_goal_proc, validate_nav2_output, timeout=30)
168+
169+
170+
@pytest.mark.launch(fixture=launch_description)
171+
def test_reached_goal(reach_goal_proc,launch_context, simulation_state_util):
172+
"""Check that the navigation goal is reached"""
173+
def validate_goal_output(output):
174+
return output and 'Goal succeeded!' in output
175+
176+
# Get the reach_goal process from the launch context
177+
process_tools.wait_for_output_sync(
178+
launch_context, reach_goal_proc, validate_goal_output, timeout=40)
179+
180+
# Additional assertion using simulation state
181+
goal_coordinates = (2.0, 3.0) # Example goal coordinates - adjust as needed
182+
distance = simulation_state_util.get("robot").distance_to(goal_coordinates)
183+
assert distance < 1.0, f"Robot is not close enough to goal at {goal_coordinates}. Distance: {distance}"
184+
185+

src/sam_bot_nav2_gz/world/empty.sdf

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@
2121
<ambient>1.0 1.0 1.0</ambient>
2222
<background>0.8 0.8 0.8</background>
2323
</scene>
24-
24+
<model name="worldWrapper">
25+
<plugin
26+
filename="gz-sim-pose-publisher-system"
27+
name="gz::sim::systems::PosePublisher">
28+
<use_pose_vector_msg>false</use_pose_vector_msg>
29+
<publish_nested_model_pose>true</publish_nested_model_pose>
30+
<static_publisher>false</static_publisher>
31+
</plugin>
2532
<light type="directional" name="sun">
2633
<cast_shadows>true</cast_shadows>
2734
<pose>0 0 10 0 0 0</pose>
@@ -93,5 +100,6 @@
93100
<pose>0.0 0.0 0.0 0 0 0</pose>
94101
</include>
95102

103+
</model>
96104
</world>
97-
</sdf>
105+
</sdf>

0 commit comments

Comments
 (0)