Skip to content

Commit 933157d

Browse files
committed
ROS2 tests in CI
1 parent 31a3b40 commit 933157d

5 files changed

Lines changed: 275 additions & 43 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ jobs:
4343
4444
python_tests:
4545
name: Python tests (${{ matrix.name }})
46-
needs: rust_tests
46+
needs:
47+
- rust_tests
48+
- ros2_tests
4749
runs-on: ${{ matrix.os }}
4850
timeout-minutes: 45
4951
strategy:
@@ -102,6 +104,37 @@ jobs:
102104
if: runner.os == 'macOS'
103105
run: bash ./ci/script.sh python-tests
104106

107+
ros2_tests:
108+
name: ROS2 tests
109+
needs: rust_tests
110+
runs-on: ubuntu-latest
111+
timeout-minutes: 45
112+
container:
113+
image: ubuntu:noble
114+
env:
115+
DO_DOCKER: 0
116+
steps:
117+
- uses: actions/checkout@v5
118+
119+
- uses: actions-rust-lang/setup-rust-toolchain@v1
120+
with:
121+
toolchain: stable
122+
rustflags: ""
123+
124+
- uses: actions/setup-python@v6
125+
with:
126+
python-version: "3.12"
127+
cache: "pip"
128+
cache-dependency-path: open-codegen/setup.py
129+
130+
- name: Setup ROS 2
131+
uses: ros-tooling/[email protected]
132+
with:
133+
required-ros-distributions: jazzy
134+
135+
- name: Run ROS2 Python tests
136+
run: bash ./ci/script.sh ros2-tests
137+
105138
ocp_tests:
106139
name: OCP tests (${{ matrix.name }})
107140
needs: python_tests

ci/script.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ run_python_core_tests() {
6060
generated_clippy_tests
6161
}
6262

63+
run_python_ros2_tests() {
64+
export PYTHONPATH=.
65+
python -W ignore test/test_ros2.py -v
66+
}
67+
6368
run_python_ocp_tests() {
6469
export PYTHONPATH=.
6570
python -W ignore test/test_ocp.py -v
@@ -84,6 +89,11 @@ main() {
8489
setup_python_test_env
8590
run_python_core_tests
8691
;;
92+
ros2-tests)
93+
echo "Running ROS2 Python tests"
94+
setup_python_test_env
95+
run_python_ros2_tests
96+
;;
8797
ocp-tests)
8898
echo "Running OCP Python tests"
8999
setup_python_test_env

open-codegen/test/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,6 @@ The generated benchmark looks like this:
4747
Run
4848
```
4949
python -W ignore test/test_constraints.py -v
50+
python -W ignore test/test_ros2.py -v
5051
python -W ignore test/test.py -v
51-
```
52+
```

open-codegen/test/test.py

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -172,36 +172,6 @@ def setUpRosPackageGeneration(cls):
172172
solver_configuration=cls.solverConfig()) \
173173
.build()
174174

175-
@classmethod
176-
def setUpRos2PackageGeneration(cls):
177-
u = cs.MX.sym("u", 5) # decision variable (nu = 5)
178-
p = cs.MX.sym("p", 2) # parameter (np = 2)
179-
phi = og.functions.rosenbrock(u, p)
180-
c = cs.vertcat(1.5 * u[0] - u[1],
181-
cs.fmax(0.0, u[2] - u[3] + 0.1))
182-
bounds = og.constraints.Ball2(None, 1.5)
183-
meta = og.config.OptimizerMeta() \
184-
.with_optimizer_name("rosenbrock_ros2")
185-
problem = og.builder.Problem(u, p, phi) \
186-
.with_constraints(bounds) \
187-
.with_penalty_constraints(c)
188-
ros_config = og.config.RosConfiguration() \
189-
.with_package_name("parametric_optimizer_ros2") \
190-
.with_node_name("open_node_ros2") \
191-
.with_rate(35) \
192-
.with_description("really cool ROS2 node")
193-
build_config = og.config.BuildConfiguration() \
194-
.with_open_version(local_path=RustBuildTestCase.get_open_local_absolute_path()) \
195-
.with_build_directory(RustBuildTestCase.TEST_DIR) \
196-
.with_build_mode(og.config.BuildConfiguration.DEBUG_MODE) \
197-
.with_build_c_bindings() \
198-
.with_ros2(ros_config)
199-
og.builder.OpEnOptimizerBuilder(problem,
200-
metadata=meta,
201-
build_configuration=build_config,
202-
solver_configuration=cls.solverConfig()) \
203-
.build()
204-
205175
@classmethod
206176
def setUpOnlyParametricF2(cls):
207177
u = cs.MX.sym("u", 5) # decision variable (nu = 5)
@@ -261,24 +231,13 @@ def setUpHalfspace(cls):
261231
def setUpClass(cls):
262232
cls.setUpPythonBindings()
263233
cls.setUpRosPackageGeneration()
264-
cls.setUpRos2PackageGeneration()
265234
cls.setUpOnlyF1()
266235
cls.setUpOnlyF2()
267236
cls.setUpOnlyF2(is_preconditioned=True)
268237
cls.setUpPlain()
269238
cls.setUpOnlyParametricF2()
270239
cls.setUpHalfspace()
271240

272-
def test_ros2_package_generation(self):
273-
ros2_dir = os.path.join(
274-
RustBuildTestCase.TEST_DIR,
275-
"rosenbrock_ros2",
276-
"parametric_optimizer_ros2")
277-
self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "package.xml")))
278-
self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "CMakeLists.txt")))
279-
self.assertTrue(os.path.isfile(
280-
os.path.join(ros2_dir, "launch", "open_optimizer.launch.py")))
281-
282241
def test_python_bindings(self):
283242
import sys
284243
import os

open-codegen/test/test_ros2.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import logging
2+
import os
3+
import shutil
4+
import subprocess
5+
import time
6+
import unittest
7+
8+
import casadi.casadi as cs
9+
import opengen as og
10+
11+
12+
class Ros2BuildTestCase(unittest.TestCase):
13+
"""Integration tests for auto-generated ROS2 packages."""
14+
15+
TEST_DIR = ".python_test_build"
16+
OPTIMIZER_NAME = "rosenbrock_ros2"
17+
PACKAGE_NAME = "parametric_optimizer_ros2"
18+
NODE_NAME = "open_node_ros2"
19+
20+
@staticmethod
21+
def get_open_local_absolute_path():
22+
"""Return the absolute path to the local OpEn repository root."""
23+
cwd = os.getcwd()
24+
return cwd.split('open-codegen')[0]
25+
26+
@classmethod
27+
def solverConfig(cls):
28+
"""Return a solver configuration shared by the ROS2 tests."""
29+
return og.config.SolverConfiguration() \
30+
.with_lbfgs_memory(15) \
31+
.with_tolerance(1e-4) \
32+
.with_initial_tolerance(1e-4) \
33+
.with_delta_tolerance(1e-4) \
34+
.with_initial_penalty(15.0) \
35+
.with_penalty_weight_update_factor(10.0) \
36+
.with_max_inner_iterations(155) \
37+
.with_max_duration_micros(1e8) \
38+
.with_max_outer_iterations(50) \
39+
.with_sufficient_decrease_coefficient(0.05) \
40+
.with_cbfgs_parameters(1.5, 1e-10, 1e-12) \
41+
.with_preconditioning(False)
42+
43+
@classmethod
44+
def setUpRos2PackageGeneration(cls):
45+
"""Generate the ROS2 package used by the ROS2 integration tests."""
46+
u = cs.MX.sym("u", 5)
47+
p = cs.MX.sym("p", 2)
48+
phi = og.functions.rosenbrock(u, p)
49+
c = cs.vertcat(1.5 * u[0] - u[1],
50+
cs.fmax(0.0, u[2] - u[3] + 0.1))
51+
bounds = og.constraints.Ball2(None, 1.5)
52+
meta = og.config.OptimizerMeta() \
53+
.with_optimizer_name(cls.OPTIMIZER_NAME)
54+
problem = og.builder.Problem(u, p, phi) \
55+
.with_constraints(bounds) \
56+
.with_penalty_constraints(c)
57+
ros_config = og.config.RosConfiguration() \
58+
.with_package_name(cls.PACKAGE_NAME) \
59+
.with_node_name(cls.NODE_NAME) \
60+
.with_rate(35) \
61+
.with_description("really cool ROS2 node")
62+
build_config = og.config.BuildConfiguration() \
63+
.with_open_version(local_path=cls.get_open_local_absolute_path()) \
64+
.with_build_directory(cls.TEST_DIR) \
65+
.with_build_mode(og.config.BuildConfiguration.DEBUG_MODE) \
66+
.with_build_c_bindings() \
67+
.with_ros2(ros_config)
68+
og.builder.OpEnOptimizerBuilder(problem,
69+
metadata=meta,
70+
build_configuration=build_config,
71+
solver_configuration=cls.solverConfig()) \
72+
.build()
73+
74+
@classmethod
75+
def setUpClass(cls):
76+
"""Generate the ROS2 package once before all tests run."""
77+
if shutil.which("ros2") is None or shutil.which("colcon") is None:
78+
raise unittest.SkipTest("ROS2 CLI tools are not available in PATH")
79+
cls.setUpRos2PackageGeneration()
80+
81+
@classmethod
82+
def ros2_package_dir(cls):
83+
"""Return the filesystem path to the generated ROS2 package."""
84+
return os.path.join(
85+
cls.TEST_DIR,
86+
cls.OPTIMIZER_NAME,
87+
cls.PACKAGE_NAME)
88+
89+
@classmethod
90+
def ros2_test_env(cls):
91+
"""Return the subprocess environment used by ROS2 integration tests."""
92+
env = os.environ.copy()
93+
ros2_dir = cls.ros2_package_dir()
94+
os.makedirs(os.path.join(ros2_dir, ".ros_log"), exist_ok=True)
95+
env["ROS_LOG_DIR"] = os.path.join(ros2_dir, ".ros_log")
96+
env.setdefault("RMW_IMPLEMENTATION", "rmw_fastrtps_cpp")
97+
env.pop("ROS_LOCALHOST_ONLY", None)
98+
return env
99+
100+
@staticmethod
101+
def _bash(command, cwd, env=None, timeout=180, check=True):
102+
"""Run a bash command and return the completed process."""
103+
return subprocess.run(
104+
["/bin/bash", "-lc", command],
105+
cwd=cwd,
106+
env=env,
107+
text=True,
108+
capture_output=True,
109+
timeout=timeout,
110+
check=check)
111+
112+
def test_ros2_package_generation(self):
113+
"""Verify the ROS2 package files are generated."""
114+
ros2_dir = self.ros2_package_dir()
115+
self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "package.xml")))
116+
self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "CMakeLists.txt")))
117+
self.assertTrue(os.path.isfile(
118+
os.path.join(ros2_dir, "launch", "open_optimizer.launch.py")))
119+
120+
def test_generated_ros2_package_works(self):
121+
"""Build, run, and call the generated ROS2 package."""
122+
ros2_dir = self.ros2_package_dir()
123+
env = self.ros2_test_env()
124+
125+
self._bash(
126+
f"source install/setup.bash >/dev/null 2>&1 || true; "
127+
f"colcon build --packages-select {self.PACKAGE_NAME}",
128+
cwd=ros2_dir,
129+
env=env,
130+
timeout=600)
131+
132+
node_process = subprocess.Popen(
133+
[
134+
"/bin/bash",
135+
"-lc",
136+
f"source install/setup.bash && "
137+
f"ros2 run {self.PACKAGE_NAME} {self.NODE_NAME}"
138+
],
139+
cwd=ros2_dir,
140+
env=env,
141+
text=True,
142+
stdout=subprocess.PIPE,
143+
stderr=subprocess.STDOUT)
144+
145+
try:
146+
node_seen = False
147+
topics_seen = False
148+
for _ in range(6):
149+
node_result = self._bash(
150+
"source install/setup.bash && "
151+
"ros2 node list --no-daemon --spin-time 5",
152+
cwd=ros2_dir,
153+
env=env,
154+
timeout=30,
155+
check=False)
156+
topic_result = self._bash(
157+
"source install/setup.bash && "
158+
"ros2 topic list --no-daemon --spin-time 5",
159+
cwd=ros2_dir,
160+
env=env,
161+
timeout=30,
162+
check=False)
163+
node_seen = f"/{self.NODE_NAME}" in node_result.stdout
164+
topics_seen = "/parameters" in topic_result.stdout and "/result" in topic_result.stdout
165+
if node_seen and topics_seen:
166+
break
167+
time.sleep(1)
168+
169+
if not (node_seen and topics_seen):
170+
process_output = ""
171+
if node_process.poll() is None:
172+
node_process.terminate()
173+
try:
174+
node_process.wait(timeout=10)
175+
except subprocess.TimeoutExpired:
176+
node_process.kill()
177+
node_process.wait(timeout=10)
178+
if node_process.stdout is not None:
179+
process_output = node_process.stdout.read()
180+
self.fail(
181+
"Generated ROS2 node did not become discoverable.\n"
182+
f"ros2 node list output:\n{node_result.stdout}\n"
183+
f"ros2 topic list output:\n{topic_result.stdout}\n"
184+
f"node process output:\n{process_output}")
185+
186+
echo_process = subprocess.Popen(
187+
[
188+
"/bin/bash",
189+
"-lc",
190+
"source install/setup.bash && "
191+
"ros2 topic echo /result --once"
192+
],
193+
cwd=ros2_dir,
194+
env=env,
195+
text=True,
196+
stdout=subprocess.PIPE,
197+
stderr=subprocess.STDOUT)
198+
199+
try:
200+
time.sleep(1)
201+
self._bash(
202+
"source install/setup.bash && "
203+
"ros2 topic pub --once /parameters "
204+
f"{self.PACKAGE_NAME}/msg/OptimizationParameters "
205+
"'{parameter: [1.0, 2.0], initial_guess: [0.0, 0.0, 0.0, 0.0, 0.0], initial_y: [], initial_penalty: 15.0}'",
206+
cwd=ros2_dir,
207+
env=env,
208+
timeout=60)
209+
echo_stdout, _ = echo_process.communicate(timeout=60)
210+
finally:
211+
if echo_process.poll() is None:
212+
echo_process.terminate()
213+
echo_process.wait(timeout=10)
214+
215+
self.assertIn("solution", echo_stdout)
216+
self.assertIn("solve_time_ms", echo_stdout)
217+
finally:
218+
if node_process.poll() is None:
219+
node_process.terminate()
220+
try:
221+
node_process.wait(timeout=10)
222+
except subprocess.TimeoutExpired:
223+
node_process.kill()
224+
node_process.wait(timeout=10)
225+
226+
227+
if __name__ == '__main__':
228+
logging.getLogger('retry').setLevel(logging.ERROR)
229+
unittest.main()

0 commit comments

Comments
 (0)