Skip to content

Commit aed3f5c

Browse files
authored
Merge pull request #131 from leggedrobotics/fix/shift-map-xy-axis-swap
Fix axis swap in shift_map_xy causing sideways map movement
2 parents fb0aa55 + 71eacbd commit aed3f5c

File tree

8 files changed

+1487
-8
lines changed

8 files changed

+1487
-8
lines changed

.github/workflows/python-tests.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ name: Python testing
44

55
on:
66
push:
7-
branches: [ "feature/2_semantic_python", "feature/**","dev/**" ]
8-
# pull_request:
9-
# branches: [ "main" ]
7+
branches: [ "feature/2_semantic_python", "feature/**", "dev/**", "fix/**" ]
8+
pull_request:
9+
branches: [ "main", "ros2" ]
1010

1111
permissions:
1212
contents: read
@@ -32,8 +32,8 @@ jobs:
3232
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
3333
- name: Test elevation mapping with pytest
3434
run: |
35-
cd elevation_mapping_cupy/script/elevation_mapping_cupy/tests/
36-
pytest
35+
cd elevation_mapping_cupy/elevation_mapping_cupy/tests/
36+
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -v
3737
- name: Test semantic_sensor with pytest
3838
run: |
3939
cd sensor_processing/semantic_sensor/script/semantic_sensor/tests

AGENTS.md

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
# Testing Guide for elevation_mapping_cupy
2+
3+
## Running Tests Locally
4+
5+
### Prerequisites
6+
7+
```bash
8+
# Source ROS2
9+
source /opt/ros/jazzy/setup.bash
10+
11+
# Build the package
12+
colcon build --packages-select elevation_mapping_cupy
13+
14+
# Source the workspace
15+
source install/setup.bash
16+
```
17+
18+
### Run All Tests via colcon
19+
20+
```bash
21+
colcon test --packages-select elevation_mapping_cupy --event-handlers console_direct+
22+
```
23+
24+
### Run Unit Tests Only (pytest)
25+
26+
These are the primary regression tests for the axis-swap bug. They don't require ROS nodes to be running.
27+
28+
```bash
29+
# Run all unit tests
30+
cd elevation_mapping_cupy/elevation_mapping_cupy/tests/
31+
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest -v
32+
33+
# Run specific test file
34+
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest test_map_shifting.py -v
35+
36+
# Run specific test
37+
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest test_map_shifting.py::TestShiftMapXY::test_shift_x_only_affects_columns -v
38+
```
39+
40+
### Run Integration Tests Only (launch_testing)
41+
42+
These test the full TF → GridMap pipeline with actual ROS nodes.
43+
44+
```bash
45+
# Stop daemon first to avoid DDS issues
46+
ros2 daemon stop
47+
48+
# Run with DDS fixes
49+
FASTDDS_BUILTIN_TRANSPORTS=UDPv4 python3 -m launch_testing.launch_test \
50+
src/elevation_mapping_cupy/elevation_mapping_cupy/test/test_tf_gridmap_integration.py
51+
```
52+
53+
### Test Summary
54+
55+
| Test File | Type | What it tests |
56+
|-----------|------|---------------|
57+
| `test_map_shifting.py` | Unit (pytest) | Axis-swap bug regression - `shift_map_xy()` function |
58+
| `test_map_services.py` | Unit (pytest) | Map service handlers |
59+
| `test_tf_gridmap_integration.py` | Integration (launch_testing) | Full TF → GridMap pipeline |
60+
61+
---
62+
63+
# ROS2 Integration Testing: DDS Discovery Fixes
64+
65+
This document captures lessons learned from fixing DDS discovery issues in `launch_testing` integration tests.
66+
67+
## Problem
68+
69+
DDS discovery between the test fixture process and launched nodes fails intermittently in `launch_testing`. Symptoms:
70+
- Test fixture cannot subscribe to topics published by launched nodes
71+
- `wait_for_gridmap()` times out even though node is publishing
72+
- Tests pass locally sometimes but fail in CI
73+
74+
## Root Causes & Fixes
75+
76+
### 1. ROS2 Daemon RMW Mismatch
77+
78+
**Cause**: The ROS2 daemon may be running with a different RMW implementation than the tests. This causes discovery timeouts.
79+
80+
**Fix**: Stop the daemon before tests run.
81+
82+
```python
83+
# In generate_test_description() or setUpClass()
84+
import subprocess
85+
subprocess.run(['ros2', 'daemon', 'stop'], capture_output=True)
86+
```
87+
88+
**Source**: [ros2/system_tests#460](https://github.com/ros2/system_tests/pull/460)
89+
90+
### 2. FastDDS Shared Memory Transport Issues
91+
92+
**Cause**: FastDDS uses shared memory (SHM) by default for same-machine communication. This can fail in certain environments (Docker, VMs, some Linux configurations).
93+
94+
**Fix**: Force UDPv4 transport instead of shared memory.
95+
96+
```python
97+
# In Python
98+
import os
99+
os.environ['FASTDDS_BUILTIN_TRANSPORTS'] = 'UDPv4'
100+
```
101+
102+
```cmake
103+
# In CMakeLists.txt
104+
add_launch_test(test/my_test.py
105+
ENV FASTDDS_BUILTIN_TRANSPORTS=UDPv4
106+
)
107+
```
108+
109+
**Alternative**: Switch to CycloneDDS:
110+
```bash
111+
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
112+
```
113+
114+
**Source**: [ROS Answers: DDS discovery not working on same machine](https://answers.ros.org/question/407025/)
115+
116+
### 3. Missing Domain ID Isolation
117+
118+
**Cause**: Using `add_launch_test` without isolation can cause cross-talk between parallel tests.
119+
120+
**Fix**: Use `add_ros_isolated_launch_test` for unique ROS_DOMAIN_ID per test.
121+
122+
```cmake
123+
# In CMakeLists.txt
124+
find_package(ament_cmake_ros REQUIRED)
125+
126+
function(add_ros_isolated_launch_test path)
127+
set(RUNNER "${ament_cmake_ros_DIR}/run_test_isolated.py")
128+
add_launch_test("${path}" RUNNER "${RUNNER}" ${ARGN})
129+
endfunction()
130+
131+
add_ros_isolated_launch_test(test/my_integration_test.py
132+
TIMEOUT 180
133+
)
134+
```
135+
136+
**Source**: [ROS2 Integration Testing Docs](https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Testing/Integration.html)
137+
138+
### 4. QoS Profile Mismatch
139+
140+
**Cause**: Incompatible QoS profiles between publisher and subscriber silently prevent message delivery.
141+
142+
**Debug**:
143+
```bash
144+
ros2 topic info /my_topic -v # Shows QoS of all publishers/subscribers
145+
```
146+
147+
**Fix**: Ensure QoS compatibility. Common issues:
148+
- `RELIABLE` subscriber cannot receive from `BEST_EFFORT` publisher
149+
- `TRANSIENT_LOCAL` durability mismatch
150+
151+
**Source**: [ROS2 QoS Documentation](https://docs.ros.org/en/rolling/Concepts/Intermediate/About-Quality-of-Service-Settings.html)
152+
153+
## Complete CMakeLists.txt Example
154+
155+
```cmake
156+
if(BUILD_TESTING)
157+
find_package(ament_cmake_pytest REQUIRED)
158+
find_package(ament_cmake_ros REQUIRED)
159+
find_package(launch_testing_ament_cmake REQUIRED)
160+
161+
# Unit tests (no ROS dependencies)
162+
ament_add_pytest_test(test_my_module
163+
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_my_module.py
164+
TIMEOUT 120
165+
ENV PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 # Avoid launch_testing import issues
166+
)
167+
168+
# Define isolated launch test function
169+
function(add_ros_isolated_launch_test path)
170+
set(RUNNER "${ament_cmake_ros_DIR}/run_test_isolated.py")
171+
add_launch_test("${path}" RUNNER "${RUNNER}" ${ARGN})
172+
endfunction()
173+
174+
# Integration test with DDS fixes
175+
add_ros_isolated_launch_test(test/test_integration.py
176+
TIMEOUT 180
177+
ENV FASTDDS_BUILTIN_TRANSPORTS=UDPv4
178+
)
179+
endif()
180+
```
181+
182+
## Complete Test File Pattern
183+
184+
```python
185+
import os
186+
import subprocess
187+
import unittest
188+
189+
import rclpy
190+
from rclpy.node import Node
191+
from rclpy.executors import SingleThreadedExecutor
192+
193+
import launch
194+
import launch_ros
195+
import launch_testing
196+
197+
def generate_test_description():
198+
# Stop daemon to avoid RMW mismatch
199+
subprocess.run(['ros2', 'daemon', 'stop'], capture_output=True)
200+
201+
# Force UDPv4 transport
202+
os.environ['FASTDDS_BUILTIN_TRANSPORTS'] = 'UDPv4'
203+
204+
node_under_test = launch_ros.actions.Node(
205+
package='my_package',
206+
executable='my_node',
207+
name='my_node',
208+
)
209+
210+
return (
211+
launch.LaunchDescription([
212+
node_under_test,
213+
launch_testing.actions.ReadyToTest(),
214+
]),
215+
{'node_under_test': node_under_test}
216+
)
217+
218+
class TestIntegration(unittest.TestCase):
219+
@classmethod
220+
def setUpClass(cls):
221+
# Also stop daemon here in case generate_test_description ran in different process
222+
subprocess.run(['ros2', 'daemon', 'stop'], capture_output=True)
223+
224+
try:
225+
rclpy.init()
226+
except RuntimeError:
227+
pass # Already initialized
228+
229+
cls.node = Node('test_node')
230+
cls.executor = SingleThreadedExecutor()
231+
cls.executor.add_node(cls.node)
232+
233+
@classmethod
234+
def tearDownClass(cls):
235+
cls.executor.shutdown()
236+
cls.node.destroy_node()
237+
238+
def test_something(self):
239+
# Your test here
240+
pass
241+
```
242+
243+
## Debugging Tips
244+
245+
1. **Check if daemon is running**:
246+
```bash
247+
ros2 daemon status
248+
```
249+
250+
2. **Check RMW implementation**:
251+
```bash
252+
echo $RMW_IMPLEMENTATION
253+
ros2 doctor --report | grep middleware
254+
```
255+
256+
3. **List all topics with QoS**:
257+
```bash
258+
ros2 topic list -v
259+
ros2 topic info /my_topic -v
260+
```
261+
262+
4. **Test DDS discovery manually**:
263+
```bash
264+
# Terminal 1
265+
ros2 topic pub /test std_msgs/String "data: hello"
266+
267+
# Terminal 2
268+
ros2 topic echo /test
269+
```
270+
271+
5. **Force different DDS**:
272+
```bash
273+
RMW_IMPLEMENTATION=rmw_cyclonedds_cpp ros2 topic list
274+
```
275+
276+
## References
277+
278+
- [ROS2 Integration Testing Tutorial](https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Testing/Integration.html)
279+
- [launch_testing GitHub](https://github.com/ros2/launch/tree/rolling/launch_testing)
280+
- [FastDDS Builtin Transports](https://fast-dds.docs.eprosima.com/en/latest/fastdds/transport/transport.html)
281+
- [ROS2 QoS Settings](https://docs.ros.org/en/rolling/Concepts/Intermediate/About-Quality-of-Service-Settings.html)

elevation_mapping_cupy/CMakeLists.txt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,50 @@ install(
3939
DESTINATION share/${PROJECT_NAME}
4040
)
4141

42+
# Install test files for integration tests
43+
install(
44+
DIRECTORY test
45+
DESTINATION share/${PROJECT_NAME}
46+
)
47+
48+
if(BUILD_TESTING)
49+
find_package(ament_cmake_pytest REQUIRED)
50+
find_package(ament_cmake_ros REQUIRED)
51+
find_package(launch_testing_ament_cmake REQUIRED)
52+
53+
# Unit tests (run via pytest, no ROS dependencies)
54+
# These are the PRIMARY regression tests for the axis-swap bug
55+
# Note: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 disables the launch_testing pytest plugin
56+
# which would otherwise try to import the package (triggering cupy import errors)
57+
ament_add_pytest_test(test_map_shifting
58+
${CMAKE_CURRENT_SOURCE_DIR}/${PROJECT_NAME}/tests/test_map_shifting.py
59+
TIMEOUT 120
60+
ENV PYTEST_DISABLE_PLUGIN_AUTOLOAD=1
61+
)
62+
ament_add_pytest_test(test_map_services
63+
${CMAKE_CURRENT_SOURCE_DIR}/${PROJECT_NAME}/tests/test_map_services.py
64+
TIMEOUT 120
65+
ENV PYTEST_DISABLE_PLUGIN_AUTOLOAD=1
66+
)
67+
68+
# Define isolated launch test function for unique ROS_DOMAIN_ID per test
69+
# See: https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Testing/Integration.html
70+
function(add_ros_isolated_launch_test path)
71+
set(RUNNER "${ament_cmake_ros_DIR}/run_test_isolated.py")
72+
add_launch_test("${path}" RUNNER "${RUNNER}" ${ARGN})
73+
endfunction()
74+
75+
# Integration test (launches ROS nodes)
76+
# DDS fixes:
77+
# FASTDDS_BUILTIN_TRANSPORTS=UDPv4: Avoid FastDDS shared memory discovery issues
78+
# See: https://answers.ros.org/question/407025/
79+
# SKIP_DDS_FLAKES=1: In CI, skip (not fail) on known DDS discovery issues.
80+
# Locally, tests fail loudly to catch real regressions.
81+
add_ros_isolated_launch_test(test/test_tf_gridmap_integration.py
82+
TIMEOUT 180
83+
ENV SKIP_DDS_FLAKES=1 FASTDDS_BUILTIN_TRANSPORTS=UDPv4
84+
)
85+
endif()
86+
4287
ament_export_dependencies(${dependencies})
4388
ament_package()

elevation_mapping_cupy/elevation_mapping_cupy/elevation_mapping.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,17 @@ def shift_map_xy(self, delta_pixel):
250250
"""Shift the map along the horizontal axes according to the input.
251251
252252
Args:
253-
delta_pixel (cupy._core.core.ndarray):
254-
253+
delta_pixel (cupy._core.core.ndarray): Shift in [x, y] order (world coordinates).
254+
x corresponds to columns (axis 2), y corresponds to rows (axis 1).
255+
256+
Note:
257+
The map array has shape (layers, height, width) = (layers, rows, cols).
258+
In row-major convention: axis 1 = rows = Y, axis 2 = cols = X.
259+
cp.roll with axis=(1, 2) expects [row_shift, col_shift] = [y_shift, x_shift].
260+
Since delta_pixel is [x, y], we swap to [y, x] for correct axis mapping.
255261
"""
256-
shift_value = delta_pixel.astype(cp.int32)
262+
# Swap [x, y] to [y, x] to match axis=(1, 2) = (rows=Y, cols=X)
263+
shift_value = cp.array([delta_pixel[1], delta_pixel[0]], dtype=cp.int32)
257264
if cp.abs(shift_value).sum() == 0:
258265
return
259266
with self.map_lock:

0 commit comments

Comments
 (0)