From e87c3cdaeeb99002ab6be1c5c5eb956279f7a249 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 12:32:45 +0000 Subject: [PATCH 01/33] ROS2 support - first implementation of ROS2 pkg generation - basic unit tests (it works) --- .../opengen/builder/optimizer_builder.py | 9 +- open-codegen/opengen/builder/ros_builder.py | 321 ++++++++++-------- open-codegen/opengen/config/build_config.py | 26 ++ open-codegen/opengen/config/ros_config.py | 11 +- .../opengen/templates/ros/open_optimizer.cpp | 3 +- .../opengen/templates/ros2/CMakeLists.txt | 55 +++ .../templates/ros2/OptimizationParameters.msg | 4 + .../templates/ros2/OptimizationResult.msg | 18 + open-codegen/opengen/templates/ros2/README.md | 126 +++++++ .../opengen/templates/ros2/open_optimizer.cpp | 181 ++++++++++ .../opengen/templates/ros2/open_optimizer.hpp | 40 +++ .../templates/ros2/open_optimizer.launch.py | 20 ++ .../opengen/templates/ros2/open_params.yaml | 5 + .../opengen/templates/ros2/package.xml | 24 ++ open-codegen/test/test.py | 41 +++ 15 files changed, 727 insertions(+), 157 deletions(-) create mode 100644 open-codegen/opengen/templates/ros2/CMakeLists.txt create mode 100644 open-codegen/opengen/templates/ros2/OptimizationParameters.msg create mode 100644 open-codegen/opengen/templates/ros2/OptimizationResult.msg create mode 100644 open-codegen/opengen/templates/ros2/README.md create mode 100644 open-codegen/opengen/templates/ros2/open_optimizer.cpp create mode 100644 open-codegen/opengen/templates/ros2/open_optimizer.hpp create mode 100644 open-codegen/opengen/templates/ros2/open_optimizer.launch.py create mode 100644 open-codegen/opengen/templates/ros2/open_params.yaml create mode 100644 open-codegen/opengen/templates/ros2/package.xml diff --git a/open-codegen/opengen/builder/optimizer_builder.py b/open-codegen/opengen/builder/optimizer_builder.py index 316f5422..51500422 100644 --- a/open-codegen/opengen/builder/optimizer_builder.py +++ b/open-codegen/opengen/builder/optimizer_builder.py @@ -12,7 +12,7 @@ import sys from importlib.metadata import version -from .ros_builder import RosBuilder +from .ros_builder import ROS2Builder, RosBuilder _AUTOGEN_COST_FNAME = 'auto_casadi_cost.c' _AUTOGEN_GRAD_FNAME = 'auto_casadi_grad.c' @@ -920,4 +920,11 @@ def build(self): self.__solver_config) ros_builder.build() + if self.__build_config.ros2_config is not None: + ros2_builder = ROS2Builder( + self.__meta, + self.__build_config, + self.__solver_config) + ros2_builder.build() + return self.__info() diff --git a/open-codegen/opengen/builder/ros_builder.py b/open-codegen/opengen/builder/ros_builder.py index 0108a8a5..0b6c537c 100644 --- a/open-codegen/opengen/builder/ros_builder.py +++ b/open-codegen/opengen/builder/ros_builder.py @@ -1,12 +1,11 @@ import opengen.definitions as og_dfn -import os +import datetime import logging -import jinja2 +import os import shutil -import datetime -_ROS_PREFIX = 'ros_node_' +import jinja2 def make_dir_if_not_exists(directory): @@ -14,209 +13,231 @@ def make_dir_if_not_exists(directory): os.makedirs(directory) -def get_template(name): - file_loader = jinja2.FileSystemLoader(og_dfn.templates_dir()) - env = jinja2.Environment(loader=file_loader, autoescape=True) - return env.get_template(name) - - -def get_ros_template(name): - file_loader = jinja2.FileSystemLoader(og_dfn.templates_subdir('ros')) +def get_ros_template(template_subdir, name): + file_loader = jinja2.FileSystemLoader(og_dfn.templates_subdir(template_subdir)) env = jinja2.Environment(loader=file_loader, autoescape=True) return env.get_template(name) -class RosBuilder: +class _BaseRosBuilder: """ - Code generation for ROS-related files + Shared code generation for ROS-related packages For internal use """ + _template_subdir = None + _logger_name = None + _logger_tag = None + _launch_file_name = None + def __init__(self, meta, build_config, solver_config): - self.__meta = meta - self.__build_config = build_config - self.__solver_config = solver_config - self.__logger = logging.getLogger('opengen.builder.RosBuilder') + self._meta = meta + self._build_config = build_config + self._solver_config = solver_config + self._logger = logging.getLogger(self._logger_name) stream_handler = logging.StreamHandler() stream_handler.setLevel(1) - c_format = logging.Formatter('[%(levelname)s] <> %(message)s') + c_format = logging.Formatter( + f'[%(levelname)s] <<{self._logger_tag}>> %(message)s') stream_handler.setFormatter(c_format) - self.__logger.setLevel(1) - self.__logger.addHandler(stream_handler) + self._logger.setLevel(1) + self._logger.handlers.clear() + self._logger.addHandler(stream_handler) + self._logger.propagate = False + + @property + def _ros_config(self): + raise NotImplementedError - def __target_dir(self): + def _template(self, name): + return get_ros_template(self._template_subdir, name) + + def _target_dir(self): return os.path.abspath( os.path.join( - self.__build_config.build_dir, - self.__meta.optimizer_name)) + self._build_config.build_dir, + self._meta.optimizer_name)) - def __ros_target_dir(self): - ros_config = self.__build_config.ros_config - ros_target_dir_name = ros_config.package_name + def _ros_target_dir(self): return os.path.abspath( os.path.join( - self.__build_config.build_dir, - self.__meta.optimizer_name, ros_target_dir_name)) + self._build_config.build_dir, + self._meta.optimizer_name, + self._ros_config.package_name)) - def __generate_ros_dir_structure(self): - self.__logger.info("Generating directory structure") - target_ros_dir = self.__ros_target_dir() + def _generate_ros_dir_structure(self): + self._logger.info("Generating directory structure") + target_ros_dir = self._ros_target_dir() make_dir_if_not_exists(target_ros_dir) - make_dir_if_not_exists(os.path.abspath( - os.path.join(target_ros_dir, 'include'))) - make_dir_if_not_exists(os.path.abspath( - os.path.join(target_ros_dir, 'extern_lib'))) - make_dir_if_not_exists(os.path.abspath( - os.path.join(target_ros_dir, 'src'))) - make_dir_if_not_exists(os.path.abspath( - os.path.join(target_ros_dir, 'msg'))) - make_dir_if_not_exists(os.path.abspath( - os.path.join(target_ros_dir, 'config'))) - make_dir_if_not_exists(os.path.abspath( - os.path.join(target_ros_dir, 'launch'))) - - def __generate_ros_package_xml(self): - self.__logger.info("Generating package.xml") - target_ros_dir = self.__ros_target_dir() - template = get_ros_template('package.xml') - output_template = template.render( - meta=self.__meta, ros=self.__build_config.ros_config) + for directory_name in ('include', 'extern_lib', 'src', 'msg', 'config', 'launch'): + make_dir_if_not_exists(os.path.abspath( + os.path.join(target_ros_dir, directory_name))) + + def _generate_ros_package_xml(self): + self._logger.info("Generating package.xml") + target_ros_dir = self._ros_target_dir() + template = self._template('package.xml') + output_template = template.render(meta=self._meta, ros=self._ros_config) target_rospkg_path = os.path.join(target_ros_dir, "package.xml") with open(target_rospkg_path, "w") as fh: fh.write(output_template) - def __generate_ros_cmakelists(self): - self.__logger.info("Generating CMakeLists") - target_ros_dir = self.__ros_target_dir() - template = get_ros_template('CMakeLists.txt') - output_template = template.render(meta=self.__meta, - ros=self.__build_config.ros_config) + def _generate_ros_cmakelists(self): + self._logger.info("Generating CMakeLists") + target_ros_dir = self._ros_target_dir() + template = self._template('CMakeLists.txt') + output_template = template.render(meta=self._meta, ros=self._ros_config) target_rospkg_path = os.path.join(target_ros_dir, "CMakeLists.txt") with open(target_rospkg_path, "w") as fh: fh.write(output_template) - def __copy__ros_files(self): - self.__logger.info("Copying external dependencies") - # 1. --- copy header file - target_ros_dir = self.__ros_target_dir() - header_file_name = self.__meta.optimizer_name + '_bindings.hpp' + def _copy_ros_files(self): + self._logger.info("Copying external dependencies") + target_ros_dir = self._ros_target_dir() + + header_file_name = self._meta.optimizer_name + '_bindings.hpp' target_include_filename = os.path.abspath( - os.path.join( - target_ros_dir, 'include', header_file_name)) + os.path.join(target_ros_dir, 'include', header_file_name)) original_include_file = os.path.abspath( - os.path.join(self.__target_dir(), header_file_name)) + os.path.join(self._target_dir(), header_file_name)) shutil.copyfile(original_include_file, target_include_filename) - # 2. --- copy library file - lib_file_name = 'lib' + self.__meta.optimizer_name + '.a' - target_lib_file_name = \ - os.path.abspath( - os.path.join( - target_ros_dir, 'extern_lib', lib_file_name)) + lib_file_name = 'lib' + self._meta.optimizer_name + '.a' + target_lib_file_name = os.path.abspath( + os.path.join(target_ros_dir, 'extern_lib', lib_file_name)) original_lib_file = os.path.abspath( os.path.join( - self.__target_dir(), + self._target_dir(), 'target', - self.__build_config.build_mode, + self._build_config.build_mode, lib_file_name)) shutil.copyfile(original_lib_file, target_lib_file_name) - # 3. --- copy msg file OptimizationParameters.msg - original_params_msg = os.path.abspath( - os.path.join( - og_dfn.templates_dir(), 'ros', 'OptimizationParameters.msg')) - target_params_msg = \ - os.path.abspath( - os.path.join( - target_ros_dir, 'msg', 'OptimizationParameters.msg')) - shutil.copyfile(original_params_msg, target_params_msg) - - # 4. --- copy msg file OptimizationResult.msg - original_result_msg = os.path.abspath( - os.path.join( - og_dfn.templates_dir(), 'ros', 'OptimizationResult.msg')) - target_result_msg = \ - os.path.abspath( + for message_name in ('OptimizationParameters.msg', 'OptimizationResult.msg'): + original_message = os.path.abspath( os.path.join( - target_ros_dir, 'msg', 'OptimizationResult.msg')) - shutil.copyfile(original_result_msg, target_result_msg) - - def __generate_ros_params_file(self): - self.__logger.info("Generating open_params.yaml") - target_ros_dir = self.__ros_target_dir() - template = get_ros_template('open_params.yaml') - output_template = template.render(meta=self.__meta, - ros=self.__build_config.ros_config) - target_yaml_fname \ - = os.path.join(target_ros_dir, "config", "open_params.yaml") + og_dfn.templates_dir(), + self._template_subdir, + message_name)) + target_message = os.path.abspath( + os.path.join(target_ros_dir, 'msg', message_name)) + shutil.copyfile(original_message, target_message) + + def _generate_ros_params_file(self): + self._logger.info("Generating open_params.yaml") + target_ros_dir = self._ros_target_dir() + template = self._template('open_params.yaml') + output_template = template.render(meta=self._meta, ros=self._ros_config) + target_yaml_fname = os.path.join(target_ros_dir, "config", "open_params.yaml") with open(target_yaml_fname, "w") as fh: fh.write(output_template) - def __generate_ros_node_header(self): - self.__logger.info("Generating open_optimizer.hpp") - target_ros_dir = self.__ros_target_dir() - template = get_ros_template('open_optimizer.hpp') - output_template = template.render(meta=self.__meta, - ros=self.__build_config.ros_config, - solver_config=self.__solver_config) - target_rosnode_header_path \ - = os.path.join(target_ros_dir, "include", "open_optimizer.hpp") + def _generate_ros_node_header(self): + self._logger.info("Generating open_optimizer.hpp") + target_ros_dir = self._ros_target_dir() + template = self._template('open_optimizer.hpp') + output_template = template.render( + meta=self._meta, + ros=self._ros_config, + solver_config=self._solver_config) + target_rosnode_header_path = os.path.join( + target_ros_dir, "include", "open_optimizer.hpp") with open(target_rosnode_header_path, "w") as fh: fh.write(output_template) - def __generate_ros_node_cpp(self): - self.__logger.info("Generating open_optimizer.cpp") - target_ros_dir = self.__ros_target_dir() - template = get_ros_template('open_optimizer.cpp') - output_template = template.render(meta=self.__meta, - ros=self.__build_config.ros_config, - timestamp_created=datetime.datetime.now()) - target_rosnode_cpp_path \ - = os.path.join(target_ros_dir, "src", "open_optimizer.cpp") + def _generate_ros_node_cpp(self): + self._logger.info("Generating open_optimizer.cpp") + target_ros_dir = self._ros_target_dir() + template = self._template('open_optimizer.cpp') + output_template = template.render( + meta=self._meta, + ros=self._ros_config, + timestamp_created=datetime.datetime.now()) + target_rosnode_cpp_path = os.path.join(target_ros_dir, "src", "open_optimizer.cpp") with open(target_rosnode_cpp_path, "w") as fh: fh.write(output_template) - def __generate_ros_launch_file(self): - self.__logger.info("Generating open_optimizer.launch") - target_ros_dir = self.__ros_target_dir() - template = get_ros_template('open_optimizer.launch') - output_template = template.render(meta=self.__meta, - ros=self.__build_config.ros_config) - target_rosnode_launch_path \ - = os.path.join(target_ros_dir, "launch", "open_optimizer.launch") + def _generate_ros_launch_file(self): + self._logger.info("Generating %s", self._launch_file_name) + target_ros_dir = self._ros_target_dir() + template = self._template(self._launch_file_name) + output_template = template.render(meta=self._meta, ros=self._ros_config) + target_rosnode_launch_path = os.path.join( + target_ros_dir, "launch", self._launch_file_name) with open(target_rosnode_launch_path, "w") as fh: fh.write(output_template) - def __generate_ros_readme_file(self): - self.__logger.info("Generating README.md") - target_ros_dir = self.__ros_target_dir() - template = get_ros_template('README.md') - output_template = template.render( - ros=self.__build_config.ros_config) - target_readme_path \ - = os.path.join(target_ros_dir, "README.md") + def _generate_ros_readme_file(self): + self._logger.info("Generating README.md") + target_ros_dir = self._ros_target_dir() + template = self._template('README.md') + output_template = template.render(ros=self._ros_config) + target_readme_path = os.path.join(target_ros_dir, "README.md") with open(target_readme_path, "w") as fh: fh.write(output_template) - def __symbolic_link_info_message(self): - target_ros_dir = self.__ros_target_dir() - self.__logger.info("ROS package was built successfully. Now run:") - self.__logger.info("ln -s %s ~/catkin_ws/src/", target_ros_dir) - self.__logger.info("cd ~/catkin_ws/; catkin_make") + def _symbolic_link_info_message(self): + raise NotImplementedError def build(self): """ Build ROS-related files """ - self.__generate_ros_dir_structure() # generate necessary folders - self.__generate_ros_package_xml() # generate package.xml - self.__generate_ros_cmakelists() # generate CMakeLists.txt - self.__copy__ros_files() # Copy certain files - # # - C++ bindings, library, msg - self.__generate_ros_params_file() # generate params file - self.__generate_ros_node_header() # generate node .hpp file - self.__generate_ros_node_cpp() # generate main node .cpp file - self.__generate_ros_launch_file() # generate launch file - self.__generate_ros_readme_file() # final touch: create README.md - self.__symbolic_link_info_message() # Info: create symbolic link + self._generate_ros_dir_structure() + self._generate_ros_package_xml() + self._generate_ros_cmakelists() + self._copy_ros_files() + self._generate_ros_params_file() + self._generate_ros_node_header() + self._generate_ros_node_cpp() + self._generate_ros_launch_file() + self._generate_ros_readme_file() + self._symbolic_link_info_message() + + +class RosBuilder(_BaseRosBuilder): + """ + Code generation for ROS-related files + + For internal use + """ + + _template_subdir = 'ros' + _logger_name = 'opengen.builder.RosBuilder' + _logger_tag = 'ROS' + _launch_file_name = 'open_optimizer.launch' + + @property + def _ros_config(self): + return self._build_config.ros_config + + def _symbolic_link_info_message(self): + target_ros_dir = self._ros_target_dir() + self._logger.info("ROS package was built successfully. Now run:") + self._logger.info("ln -s %s ~/catkin_ws/src/", target_ros_dir) + self._logger.info("cd ~/catkin_ws/; catkin_make") + + +class ROS2Builder(_BaseRosBuilder): + """ + Code generation for ROS2-related files + + For internal use + """ + + _template_subdir = 'ros2' + _logger_name = 'opengen.builder.ROS2Builder' + _logger_tag = 'ROS2' + _launch_file_name = 'open_optimizer.launch.py' + + @property + def _ros_config(self): + return self._build_config.ros2_config + + def _symbolic_link_info_message(self): + target_ros_dir = self._ros_target_dir() + self._logger.info("ROS2 package was built successfully. Now run:") + self._logger.info("ln -s %s ~/ros2_ws/src/", target_ros_dir) + self._logger.info("cd ~/ros2_ws/; colcon build --packages-select %s", + self._ros_config.package_name) diff --git a/open-codegen/opengen/config/build_config.py b/open-codegen/opengen/config/build_config.py index 939150d1..41d6f64f 100644 --- a/open-codegen/opengen/config/build_config.py +++ b/open-codegen/opengen/config/build_config.py @@ -57,6 +57,7 @@ def __init__(self, build_dir="."): self.__build_c_bindings = False self.__build_python_bindings = False self.__ros_config = None + self.__ros2_config = None self.__tcp_interface_config = None self.__local_path = None self.__allocator = RustAllocator.DefaultAllocator @@ -135,6 +136,14 @@ def ros_config(self) -> RosConfiguration: """ return self.__ros_config + @property + def ros2_config(self) -> RosConfiguration: + """ROS2 package configuration + + :return: instance of RosConfiguration + """ + return self.__ros2_config + @property def allocator(self) -> RustAllocator: """ @@ -257,6 +266,21 @@ def with_ros(self, ros_config: RosConfiguration): """ self.__build_c_bindings = True # no C++ bindings, no ROS package mate self.__ros_config = ros_config + self.__ros2_config = None + return self + + def with_ros2(self, ros_config: RosConfiguration): + """ + Activates the generation of a ROS2 package. The caller must provide an + instance of RosConfiguration + + :param ros_config: Configuration of ROS2 package + + :return: current instance of BuildConfiguration + """ + self.__build_c_bindings = True # no C++ bindings, no ROS package + self.__ros2_config = ros_config + self.__ros_config = None return self def with_tcp_interface_config(self, tcp_interface_config=TcpServerConfiguration()): @@ -300,4 +324,6 @@ def to_dict(self): build_dict["tcp_interface_config"] = self.__tcp_interface_config.to_dict() if self.__ros_config is not None: build_dict["ros_config"] = self.__ros_config.to_dict() + if self.__ros2_config is not None: + build_dict["ros2_config"] = self.__ros2_config.to_dict() return build_dict diff --git a/open-codegen/opengen/config/ros_config.py b/open-codegen/opengen/config/ros_config.py index 206051c1..4e0a6c91 100644 --- a/open-codegen/opengen/config/ros_config.py +++ b/open-codegen/opengen/config/ros_config.py @@ -3,7 +3,7 @@ class RosConfiguration: """ - Configuration of auto-generated ROS package + Configuration of an auto-generated ROS or ROS2 package """ def __init__(self): @@ -61,7 +61,7 @@ def description(self): @property def rate(self): - """ROS node rate in Hz + """ROS/ROS2 node rate in Hz :return: rate, defaults to `10.0` """ @@ -87,7 +87,7 @@ def params_topic_queue_size(self): def with_package_name(self, pkg_name): """ Set the package name, which is the same as the name - of the folder that will store the auto-generated ROS node. + of the folder that will store the auto-generated ROS/ROS2 node. The node name can contain lowercase and uppercase characters and underscores, but not spaces or other symbols @@ -124,6 +124,7 @@ def with_node_name(self, node_name): def with_rate(self, rate): """ Set the rate of the ROS node + or ROS2 node :param rate: rate in Hz :type rate: float @@ -135,7 +136,7 @@ def with_rate(self, rate): def with_description(self, description): """ - Set the description of the ROS package + Set the description of the ROS or ROS2 package :param description: description, defaults to "parametric optimization with OpEn" :type description: string @@ -149,7 +150,7 @@ def with_queue_sizes(self, result_topic_queue_size=100, parameter_topic_queue_size=100): """ - Set queue sizes for ROS node + Set queue sizes for ROS or ROS2 node :param result_topic_queue_size: queue size of results, defaults to 100 :type result_topic_queue_size: int, optional diff --git a/open-codegen/opengen/templates/ros/open_optimizer.cpp b/open-codegen/opengen/templates/ros/open_optimizer.cpp index ad9b4f1b..542fb3c9 100644 --- a/open-codegen/opengen/templates/ros/open_optimizer.cpp +++ b/open-codegen/opengen/templates/ros/open_optimizer.cpp @@ -1,6 +1,7 @@ /** * This is an auto-generated file by Optimization Engine (OpEn) - * OpEn is a free open-source software - see doc.optimization-engine.xyz + * OpEn is a free open-source software - + * see https://alphaville.github.io/optimization-engine * dually licensed under the MIT and Apache v2 licences. * */ diff --git a/open-codegen/opengen/templates/ros2/CMakeLists.txt b/open-codegen/opengen/templates/ros2/CMakeLists.txt new file mode 100644 index 00000000..398512b2 --- /dev/null +++ b/open-codegen/opengen/templates/ros2/CMakeLists.txt @@ -0,0 +1,55 @@ +cmake_minimum_required(VERSION 3.8) +project({{ros.package_name}}) + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +find_package(ament_cmake REQUIRED) +find_package(rclcpp REQUIRED) +find_package(Python3 REQUIRED COMPONENTS Interpreter Development NumPy) +set(Python_EXECUTABLE ${Python3_EXECUTABLE}) +set(Python_INCLUDE_DIRS ${Python3_INCLUDE_DIRS}) +set(Python_LIBRARIES ${Python3_LIBRARIES}) +set(Python_NumPy_INCLUDE_DIRS ${Python3_NumPy_INCLUDE_DIRS}) +find_package(rosidl_default_generators REQUIRED) + +set(msg_files + "msg/OptimizationResult.msg" + "msg/OptimizationParameters.msg" +) + +rosidl_generate_interfaces(${PROJECT_NAME} + ${msg_files} +) + +ament_export_dependencies(rosidl_default_runtime) + +include_directories( + ${PROJECT_SOURCE_DIR}/include +) + +set(NODE_NAME {{ros.node_name}}) +add_executable(${NODE_NAME} src/open_optimizer.cpp) +ament_target_dependencies(${NODE_NAME} rclcpp) +target_link_libraries( + ${NODE_NAME} + ${PROJECT_SOURCE_DIR}/extern_lib/lib{{meta.optimizer_name}}.a + m + dl +) +rosidl_get_typesupport_target(cpp_typesupport_target ${PROJECT_NAME} "rosidl_typesupport_cpp") +target_link_libraries(${NODE_NAME} "${cpp_typesupport_target}") + +install(TARGETS + ${NODE_NAME} + DESTINATION lib/${PROJECT_NAME} +) + +install(DIRECTORY + config + launch + DESTINATION share/${PROJECT_NAME} +) + +ament_package() diff --git a/open-codegen/opengen/templates/ros2/OptimizationParameters.msg b/open-codegen/opengen/templates/ros2/OptimizationParameters.msg new file mode 100644 index 00000000..870d2981 --- /dev/null +++ b/open-codegen/opengen/templates/ros2/OptimizationParameters.msg @@ -0,0 +1,4 @@ +float64[] parameter # parameter p (mandatory) +float64[] initial_guess # u0 (optional/recommended) +float64[] initial_y # y0 (optional) +float64 initial_penalty # initial penalty (optional) diff --git a/open-codegen/opengen/templates/ros2/OptimizationResult.msg b/open-codegen/opengen/templates/ros2/OptimizationResult.msg new file mode 100644 index 00000000..890e0c23 --- /dev/null +++ b/open-codegen/opengen/templates/ros2/OptimizationResult.msg @@ -0,0 +1,18 @@ +# Constants match the enumeration of status codes +uint8 STATUS_CONVERGED=0 +uint8 STATUS_NOT_CONVERGED_ITERATIONS=1 +uint8 STATUS_NOT_CONVERGED_OUT_OF_TIME=2 +uint8 STATUS_NOT_CONVERGED_COST=3 +uint8 STATUS_NOT_CONVERGED_FINITE_COMPUTATION=4 + +float64[] solution # optimizer (solution) +uint8 inner_iterations # number of inner iterations +uint16 outer_iterations # number of outer iterations +uint8 status # status code +float64 cost # cost at solution +float64 norm_fpr # norm of FPR of last inner problem +float64 penalty # penalty value +float64[] lagrange_multipliers # vector of Lagrange multipliers +float64 infeasibility_f1 # infeasibility wrt F1 +float64 infeasibility_f2 # infeasibility wrt F2 +float64 solve_time_ms # solution time in ms diff --git a/open-codegen/opengen/templates/ros2/README.md b/open-codegen/opengen/templates/ros2/README.md new file mode 100644 index 00000000..cb9963a1 --- /dev/null +++ b/open-codegen/opengen/templates/ros2/README.md @@ -0,0 +1,126 @@ +# ROS2 Package {{ros.package_name}} + + +## Installation and Setup + +Move or link the auto-generated ROS2 package (folder `{{ros.package_name}}`) to your workspace source tree (typically `~/ros2_ws/src/`). + +Compile with: + +```console +cd ~/ros2_ws/ +colcon build --packages-select {{ros.package_name}} +source install/setup.bash +``` + +If you build the package in-place from its own directory instead of a larger +workspace, source the generated setup script from `install/`: + +```console +# bash +source install/setup.bash + +# zsh +source install/setup.zsh +``` + +On macOS, ROS2 logging may need an explicit writable directory: + +```console +mkdir -p .ros_log +export ROS_LOG_DIR="$PWD/.ros_log" +``` + + +## Launch and Use + +Start the optimizer in one terminal. The process stays in the foreground while +the node is running. + +```console +# terminal 1 +source install/setup.bash # or: source install/setup.zsh +ros2 run {{ros.package_name}} {{ros.node_name}} +``` + +In a second terminal, source the same environment and verify discovery: + +```console +# terminal 2 +source install/setup.bash # or: source install/setup.zsh +ros2 node list --no-daemon --spin-time 5 +ros2 topic list --no-daemon --spin-time 5 +``` + +You should see the node `/{{ros.node_name}}`, the input topic +`/{{ros.subscriber_subtopic}}`, and the output topic +`/{{ros.publisher_subtopic}}`. + +Then publish a request to the configured parameters topic +(default: `/{{ros.subscriber_subtopic}}`): + +```console +ros2 topic pub --once /{{ros.subscriber_subtopic}} {{ros.package_name}}/msg/OptimizationParameters "{parameter: [YOUR_PARAMETER_VECTOR], initial_guess: [INITIAL_GUESS_OPTIONAL], initial_y: [], initial_penalty: 15.0}" +``` + +The result will be announced on the configured result topic +(default: `/{{ros.publisher_subtopic}}`): + +```console +ros2 topic echo /{{ros.publisher_subtopic}} +``` + +To get the optimal solution you can do: + +```console +ros2 topic echo /{{ros.publisher_subtopic}} --field solution +``` + + +## Messages + +This package involves two messages: `OptimizationParameters` +and `OptimizationResult`, which are used to define the input +and output values to the node. `OptimizationParameters` specifies +the parameter vector, the initial guess (optional), the initial +guess for the vector of Lagrange multipliers and the initial value +of the penalty value. `OptimizationResult` is a message containing +all information related to the solution of the optimization +problem, including the optimal solution, the solver status, +solution time, Lagrange multiplier vector and more. + +The message structures are defined in the following msg files: + +- [`OptimizationParameters.msg`](msg/OptimizationParameters.msg) +- [`OptimizationResult.msg`](msg/OptimizationResult.msg) + + +## Configure + +You can configure the rate and topic names by editing +[`config/open_params.yaml`](config/open_params.yaml). + + +## Directory structure and contents + +The following auto-generated files are included in your ROS2 package: + +```txt +├── CMakeLists.txt +├── config +│   └── open_params.yaml +├── extern_lib +│   └── librosenbrock.a +├── include +│   ├── open_optimizer.hpp +│   └── rosenbrock_bindings.hpp +├── launch +│   └── open_optimizer.launch.py +├── msg +│   ├── OptimizationParameters.msg +│   └── OptimizationResult.msg +├── package.xml +├── README.md +└── src + └── open_optimizer.cpp +``` diff --git a/open-codegen/opengen/templates/ros2/open_optimizer.cpp b/open-codegen/opengen/templates/ros2/open_optimizer.cpp new file mode 100644 index 00000000..e7c718f5 --- /dev/null +++ b/open-codegen/opengen/templates/ros2/open_optimizer.cpp @@ -0,0 +1,181 @@ +/** + * This is an auto-generated file by Optimization Engine (OpEn) + * OpEn is a free open-source software - see doc.optimization-engine.xyz + * dually licensed under the MIT and Apache v2 licences. + * + */ +#include +#include +#include +#include +#include +#include + +#include "rclcpp/rclcpp.hpp" +#include "{{ros.package_name}}/msg/optimization_parameters.hpp" +#include "{{ros.package_name}}/msg/optimization_result.hpp" +#include "{{meta.optimizer_name}}_bindings.hpp" +#include "open_optimizer.hpp" + +namespace {{ros.package_name}} { +class OptimizationEngineNode : public rclcpp::Node { +private: + using OptimizationParametersMsg = {{ros.package_name}}::msg::OptimizationParameters; + using OptimizationResultMsg = {{ros.package_name}}::msg::OptimizationResult; + + OptimizationParametersMsg params_; + OptimizationResultMsg results_; + bool has_received_request_ = false; + double p_[{{meta.optimizer_name|upper}}_NUM_PARAMETERS] = { 0 }; + double u_[{{meta.optimizer_name|upper}}_NUM_DECISION_VARIABLES] = { 0 }; + double* y_ = nullptr; + {{meta.optimizer_name}}Cache* cache_ = nullptr; + double init_penalty_ = ROS2_NODE_{{meta.optimizer_name|upper}}_DEFAULT_INITIAL_PENALTY; + + rclcpp::Publisher::SharedPtr publisher_; + rclcpp::Subscription::SharedPtr subscriber_; + rclcpp::TimerBase::SharedPtr timer_; + + static std::chrono::milliseconds rateToPeriod(double rate) + { + if (rate <= 0.0) { + return std::chrono::milliseconds(100); + } + int period_ms = static_cast(1000.0 / rate); + if (period_ms < 1) { + period_ms = 1; + } + return std::chrono::milliseconds(period_ms); + } + + void updateInputData() + { + init_penalty_ = (params_.initial_penalty > 1.0) + ? params_.initial_penalty + : ROS2_NODE_{{meta.optimizer_name|upper}}_DEFAULT_INITIAL_PENALTY; + + if (params_.parameter.size() == {{meta.optimizer_name|upper}}_NUM_PARAMETERS) { + for (size_t i = 0; i < {{meta.optimizer_name|upper}}_NUM_PARAMETERS; ++i) { + p_[i] = params_.parameter[i]; + } + } + + if (params_.initial_guess.size() == {{meta.optimizer_name|upper}}_NUM_DECISION_VARIABLES) { + for (size_t i = 0; i < {{meta.optimizer_name|upper}}_NUM_DECISION_VARIABLES; ++i) { + u_[i] = params_.initial_guess[i]; + } + } + + if (params_.initial_y.size() == {{meta.optimizer_name|upper}}_N1) { + for (size_t i = 0; i < {{meta.optimizer_name|upper}}_N1; ++i) { + y_[i] = params_.initial_y[i]; + } + } + } + + {{meta.optimizer_name}}SolverStatus solve() + { + return {{meta.optimizer_name}}_solve(cache_, u_, p_, y_, &init_penalty_); + } + + void initializeSolverIfNeeded() + { + if (y_ == nullptr) { + y_ = new double[{{meta.optimizer_name|upper}}_N1](); + } + if (cache_ == nullptr) { + cache_ = {{meta.optimizer_name}}_new(); + } + } + + void updateResults({{meta.optimizer_name}}SolverStatus& status) + { + results_.solution.clear(); + for (size_t i = 0; i < {{meta.optimizer_name|upper}}_NUM_DECISION_VARIABLES; ++i) { + results_.solution.push_back(u_[i]); + } + + results_.lagrange_multipliers.clear(); + for (size_t i = 0; i < {{meta.optimizer_name|upper}}_N1; ++i) { + results_.lagrange_multipliers.push_back(status.lagrange[i]); + } + + results_.inner_iterations = status.num_inner_iterations; + results_.outer_iterations = status.num_outer_iterations; + results_.norm_fpr = status.last_problem_norm_fpr; + results_.cost = status.cost; + results_.penalty = status.penalty; + results_.status = static_cast(status.exit_status); + results_.solve_time_ms = static_cast(status.solve_time_ns) / 1000000.0; + results_.infeasibility_f2 = status.f2_norm; + results_.infeasibility_f1 = status.delta_y_norm_over_c; + } + + void receiveRequestCallback(const OptimizationParametersMsg::ConstSharedPtr msg) + { + params_ = *msg; + has_received_request_ = true; + } + + void solveAndPublish() + { + if (!has_received_request_) { + return; + } + initializeSolverIfNeeded(); + updateInputData(); + {{meta.optimizer_name}}SolverStatus status = solve(); + updateResults(status); + publisher_->publish(results_); + } + +public: + OptimizationEngineNode() + : Node(ROS2_NODE_{{meta.optimizer_name|upper}}_NODE_NAME) + { + this->declare_parameter( + "result_topic", + std::string(ROS2_NODE_{{meta.optimizer_name|upper}}_RESULT_TOPIC)); + this->declare_parameter( + "params_topic", + std::string(ROS2_NODE_{{meta.optimizer_name|upper}}_PARAMS_TOPIC)); + this->declare_parameter( + "rate", + double(ROS2_NODE_{{meta.optimizer_name|upper}}_RATE)); + + std::string result_topic = this->get_parameter("result_topic").as_string(); + std::string params_topic = this->get_parameter("params_topic").as_string(); + double rate = this->get_parameter("rate").as_double(); + + publisher_ = this->create_publisher( + result_topic, + ROS2_NODE_{{meta.optimizer_name|upper}}_RESULT_TOPIC_QUEUE_SIZE); + subscriber_ = this->create_subscription( + params_topic, + ROS2_NODE_{{meta.optimizer_name|upper}}_PARAMS_TOPIC_QUEUE_SIZE, + std::bind(&OptimizationEngineNode::receiveRequestCallback, this, std::placeholders::_1)); + timer_ = this->create_wall_timer( + rateToPeriod(rate), + std::bind(&OptimizationEngineNode::solveAndPublish, this)); + } + + ~OptimizationEngineNode() override + { + if (y_ != nullptr) { + delete[] y_; + } + if (cache_ != nullptr) { + {{meta.optimizer_name}}_free(cache_); + } + } +}; +} /* end of namespace {{ros.package_name}} */ + +int main(int argc, char** argv) +{ + rclcpp::init(argc, argv); + auto node = std::make_shared<{{ros.package_name}}::OptimizationEngineNode>(); + rclcpp::spin(node); + rclcpp::shutdown(); + return 0; +} diff --git a/open-codegen/opengen/templates/ros2/open_optimizer.hpp b/open-codegen/opengen/templates/ros2/open_optimizer.hpp new file mode 100644 index 00000000..a8482fd2 --- /dev/null +++ b/open-codegen/opengen/templates/ros2/open_optimizer.hpp @@ -0,0 +1,40 @@ +#ifndef ROS2_NODE_{{meta.optimizer_name|upper}}_H +#define ROS2_NODE_{{meta.optimizer_name|upper}}_H + +/** + * Default node name + */ +#define ROS2_NODE_{{meta.optimizer_name|upper}}_NODE_NAME "{{ros.node_name}}" + +/** + * Default result (publisher) topic name + */ +#define ROS2_NODE_{{meta.optimizer_name|upper}}_RESULT_TOPIC "{{ros.publisher_subtopic}}" + +/** + * Default parameters (subscriber) topic name + */ +#define ROS2_NODE_{{meta.optimizer_name|upper}}_PARAMS_TOPIC "{{ros.subscriber_subtopic}}" + +/** + * Default execution rate (in Hz) + */ +#define ROS2_NODE_{{meta.optimizer_name|upper}}_RATE {{ros.rate}} + +/** + * Default result topic queue size + */ +#define ROS2_NODE_{{meta.optimizer_name|upper}}_RESULT_TOPIC_QUEUE_SIZE {{ros.result_topic_queue_size}} + +/** + * Default parameters topic queue size + */ +#define ROS2_NODE_{{meta.optimizer_name|upper}}_PARAMS_TOPIC_QUEUE_SIZE {{ros.params_topic_queue_size}} + +/** + * Default initial penalty + */ +#define ROS2_NODE_{{meta.optimizer_name|upper}}_DEFAULT_INITIAL_PENALTY {{solver_config.initial_penalty}} + + +#endif /* Header Sentinel: ROS2_NODE_{{meta.optimizer_name|upper}}_H */ diff --git a/open-codegen/opengen/templates/ros2/open_optimizer.launch.py b/open-codegen/opengen/templates/ros2/open_optimizer.launch.py new file mode 100644 index 00000000..45d7aa60 --- /dev/null +++ b/open-codegen/opengen/templates/ros2/open_optimizer.launch.py @@ -0,0 +1,20 @@ +from launch import LaunchDescription +from launch.substitutions import PathJoinSubstitution +from launch_ros.actions import Node +from launch_ros.substitutions import FindPackageShare + + +def generate_launch_description(): + return LaunchDescription([ + Node( + package="{{ros.package_name}}", + executable="{{ros.node_name}}", + name="{{ros.node_name}}", + output="screen", + parameters=[PathJoinSubstitution([ + FindPackageShare("{{ros.package_name}}"), + "config", + "open_params.yaml", + ])], + ) + ]) diff --git a/open-codegen/opengen/templates/ros2/open_params.yaml b/open-codegen/opengen/templates/ros2/open_params.yaml new file mode 100644 index 00000000..b1ae266e --- /dev/null +++ b/open-codegen/opengen/templates/ros2/open_params.yaml @@ -0,0 +1,5 @@ +/**: + ros__parameters: + result_topic: "{{ros.publisher_subtopic}}" + params_topic: "{{ros.subscriber_subtopic}}" + rate: {{ros.rate}} diff --git a/open-codegen/opengen/templates/ros2/package.xml b/open-codegen/opengen/templates/ros2/package.xml new file mode 100644 index 00000000..c183538d --- /dev/null +++ b/open-codegen/opengen/templates/ros2/package.xml @@ -0,0 +1,24 @@ + + + {{ros.package_name}} + {{meta.version}} + {{ros.description}} + chung + {{meta.licence}} + + ament_cmake + + rosidl_default_generators + + launch + launch_ros + rclcpp + + rosidl_default_runtime + + rosidl_interface_packages + + + ament_cmake + + diff --git a/open-codegen/test/test.py b/open-codegen/test/test.py index d56f3075..a4a1579f 100644 --- a/open-codegen/test/test.py +++ b/open-codegen/test/test.py @@ -172,6 +172,36 @@ def setUpRosPackageGeneration(cls): solver_configuration=cls.solverConfig()) \ .build() + @classmethod + def setUpRos2PackageGeneration(cls): + u = cs.MX.sym("u", 5) # decision variable (nu = 5) + p = cs.MX.sym("p", 2) # parameter (np = 2) + phi = og.functions.rosenbrock(u, p) + c = cs.vertcat(1.5 * u[0] - u[1], + cs.fmax(0.0, u[2] - u[3] + 0.1)) + bounds = og.constraints.Ball2(None, 1.5) + meta = og.config.OptimizerMeta() \ + .with_optimizer_name("rosenbrock_ros2") + problem = og.builder.Problem(u, p, phi) \ + .with_constraints(bounds) \ + .with_penalty_constraints(c) + ros_config = og.config.RosConfiguration() \ + .with_package_name("parametric_optimizer_ros2") \ + .with_node_name("open_node_ros2") \ + .with_rate(35) \ + .with_description("really cool ROS2 node") + build_config = og.config.BuildConfiguration() \ + .with_open_version(local_path=RustBuildTestCase.get_open_local_absolute_path()) \ + .with_build_directory(RustBuildTestCase.TEST_DIR) \ + .with_build_mode(og.config.BuildConfiguration.DEBUG_MODE) \ + .with_build_c_bindings() \ + .with_ros2(ros_config) + og.builder.OpEnOptimizerBuilder(problem, + metadata=meta, + build_configuration=build_config, + solver_configuration=cls.solverConfig()) \ + .build() + @classmethod def setUpOnlyParametricF2(cls): u = cs.MX.sym("u", 5) # decision variable (nu = 5) @@ -231,6 +261,7 @@ def setUpHalfspace(cls): def setUpClass(cls): cls.setUpPythonBindings() cls.setUpRosPackageGeneration() + cls.setUpRos2PackageGeneration() cls.setUpOnlyF1() cls.setUpOnlyF2() cls.setUpOnlyF2(is_preconditioned=True) @@ -238,6 +269,16 @@ def setUpClass(cls): cls.setUpOnlyParametricF2() cls.setUpHalfspace() + def test_ros2_package_generation(self): + ros2_dir = os.path.join( + RustBuildTestCase.TEST_DIR, + "rosenbrock_ros2", + "parametric_optimizer_ros2") + self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "package.xml"))) + self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "CMakeLists.txt"))) + self.assertTrue(os.path.isfile( + os.path.join(ros2_dir, "launch", "open_optimizer.launch.py"))) + def test_python_bindings(self): import sys import os From 7bebcd590b3a0b4ab459b526bdb2c527986ab942 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 13:02:41 +0000 Subject: [PATCH 02/33] update ROS2 auto-generated README --- open-codegen/opengen/templates/ros2/README.md | 45 ++++++++----------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/open-codegen/opengen/templates/ros2/README.md b/open-codegen/opengen/templates/ros2/README.md index cb9963a1..b8e768cc 100644 --- a/open-codegen/opengen/templates/ros2/README.md +++ b/open-codegen/opengen/templates/ros2/README.md @@ -1,32 +1,21 @@ -# ROS2 Package {{ros.package_name}} +# ROS2 Package: {{ros.package_name}} ## Installation and Setup Move or link the auto-generated ROS2 package (folder `{{ros.package_name}}`) to your workspace source tree (typically `~/ros2_ws/src/`). -Compile with: +From within the folder `{{ros.package_name}}`, compile with: -```console -cd ~/ros2_ws/ +```bash colcon build --packages-select {{ros.package_name}} -source install/setup.bash +source install/setup.bash +# or source install/setup.zsh on MacOS ``` -If you build the package in-place from its own directory instead of a larger -workspace, source the generated setup script from `install/`: +If you want to activate logging (recommended), do -```console -# bash -source install/setup.bash - -# zsh -source install/setup.zsh -``` - -On macOS, ROS2 logging may need an explicit writable directory: - -```console +```bash mkdir -p .ros_log export ROS_LOG_DIR="$PWD/.ros_log" ``` @@ -37,17 +26,19 @@ export ROS_LOG_DIR="$PWD/.ros_log" Start the optimizer in one terminal. The process stays in the foreground while the node is running. -```console -# terminal 1 -source install/setup.bash # or: source install/setup.zsh +```bash +# Terminal 1 +source install/setup.bash +# or: source install/setup.zsh ros2 run {{ros.package_name}} {{ros.node_name}} ``` In a second terminal, source the same environment and verify discovery: -```console -# terminal 2 -source install/setup.bash # or: source install/setup.zsh +```bash +# Terminal 2 +source install/setup.bash +# or: source install/setup.zsh ros2 node list --no-daemon --spin-time 5 ros2 topic list --no-daemon --spin-time 5 ``` @@ -59,20 +50,20 @@ You should see the node `/{{ros.node_name}}`, the input topic Then publish a request to the configured parameters topic (default: `/{{ros.subscriber_subtopic}}`): -```console +```bash ros2 topic pub --once /{{ros.subscriber_subtopic}} {{ros.package_name}}/msg/OptimizationParameters "{parameter: [YOUR_PARAMETER_VECTOR], initial_guess: [INITIAL_GUESS_OPTIONAL], initial_y: [], initial_penalty: 15.0}" ``` The result will be announced on the configured result topic (default: `/{{ros.publisher_subtopic}}`): -```console +```bash ros2 topic echo /{{ros.publisher_subtopic}} ``` To get the optimal solution you can do: -```console +```bash ros2 topic echo /{{ros.publisher_subtopic}} --field solution ``` From 31a3b40070a7e431d1e089f99284c13c67f99ea2 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 13:07:35 +0000 Subject: [PATCH 03/33] api docs --- open-codegen/opengen/builder/ros_builder.py | 98 +++++++++++++++++++-- 1 file changed, 91 insertions(+), 7 deletions(-) diff --git a/open-codegen/opengen/builder/ros_builder.py b/open-codegen/opengen/builder/ros_builder.py index 0b6c537c..7db3aeb2 100644 --- a/open-codegen/opengen/builder/ros_builder.py +++ b/open-codegen/opengen/builder/ros_builder.py @@ -1,3 +1,5 @@ +"""Builders for auto-generated ROS1 and ROS2 package wrappers.""" + import opengen.definitions as og_dfn import datetime @@ -9,11 +11,27 @@ def make_dir_if_not_exists(directory): + """Create ``directory`` if it does not already exist. + + :param directory: Path to the directory to create. + :type directory: str + """ if not os.path.exists(directory): os.makedirs(directory) def get_ros_template(template_subdir, name): + """Load a Jinja template from a ROS-specific template subdirectory. + + :param template_subdir: Template subdirectory name, e.g. ``"ros"`` or + ``"ros2"``. + :type template_subdir: str + :param name: Template file name. + :type name: str + + :return: Loaded Jinja template. + :rtype: jinja2.Template + """ file_loader = jinja2.FileSystemLoader(og_dfn.templates_subdir(template_subdir)) env = jinja2.Environment(loader=file_loader, autoescape=True) return env.get_template(name) @@ -21,17 +39,35 @@ def get_ros_template(template_subdir, name): class _BaseRosBuilder: """ - Shared code generation for ROS-related packages + Shared code generation logic for ROS-related packages. - For internal use + This base class contains the common file-generation pipeline used by both + :class:`RosBuilder` and :class:`ROS2Builder`. Subclasses specialize the + process by providing the package configuration object, template + subdirectory, launch file name, and final user-facing instructions. + + :ivar _meta: Optimizer metadata used to render the package templates. + :ivar _build_config: Global build configuration for the generated solver. + :ivar _solver_config: Solver configuration used when rendering node code. + :ivar _logger: Logger dedicated to the concrete builder implementation. """ + #: Template subdirectory under ``opengen/templates`` used by the builder. _template_subdir = None + #: Fully-qualified logger name for the concrete builder. _logger_name = None + #: Short logger tag shown in log messages. _logger_tag = None + #: Launch file generated by the concrete builder. _launch_file_name = None def __init__(self, meta, build_config, solver_config): + """Initialise a shared ROS package builder. + + :param meta: Optimizer metadata. + :param build_config: Build configuration object. + :param solver_config: Solver configuration object. + """ self._meta = meta self._build_config = build_config self._solver_config = solver_config @@ -48,18 +84,41 @@ def __init__(self, meta, build_config, solver_config): @property def _ros_config(self): + """Return the ROS/ROS2 package configuration for the subclass. + + :return: ROS configuration object used by the concrete builder. + :raises NotImplementedError: If a subclass does not provide this hook. + """ raise NotImplementedError def _template(self, name): + """Return a template from the builder's template subdirectory. + + :param name: Template file name. + :type name: str + + :return: Loaded Jinja template. + :rtype: jinja2.Template + """ return get_ros_template(self._template_subdir, name) def _target_dir(self): + """Return the root directory of the generated optimizer project. + + :return: Absolute path to the generated optimizer directory. + :rtype: str + """ return os.path.abspath( os.path.join( self._build_config.build_dir, self._meta.optimizer_name)) def _ros_target_dir(self): + """Return the root directory of the generated ROS package. + + :return: Absolute path to the generated ROS/ROS2 package directory. + :rtype: str + """ return os.path.abspath( os.path.join( self._build_config.build_dir, @@ -67,6 +126,7 @@ def _ros_target_dir(self): self._ros_config.package_name)) def _generate_ros_dir_structure(self): + """Create the directory structure for the generated ROS package.""" self._logger.info("Generating directory structure") target_ros_dir = self._ros_target_dir() make_dir_if_not_exists(target_ros_dir) @@ -75,6 +135,7 @@ def _generate_ros_dir_structure(self): os.path.join(target_ros_dir, directory_name))) def _generate_ros_package_xml(self): + """Render and write ``package.xml`` for the generated package.""" self._logger.info("Generating package.xml") target_ros_dir = self._ros_target_dir() template = self._template('package.xml') @@ -84,6 +145,7 @@ def _generate_ros_package_xml(self): fh.write(output_template) def _generate_ros_cmakelists(self): + """Render and write the package ``CMakeLists.txt`` file.""" self._logger.info("Generating CMakeLists") target_ros_dir = self._ros_target_dir() template = self._template('CMakeLists.txt') @@ -93,6 +155,7 @@ def _generate_ros_cmakelists(self): fh.write(output_template) def _copy_ros_files(self): + """Copy generated bindings, static library, and message files.""" self._logger.info("Copying external dependencies") target_ros_dir = self._ros_target_dir() @@ -125,6 +188,7 @@ def _copy_ros_files(self): shutil.copyfile(original_message, target_message) def _generate_ros_params_file(self): + """Render and write the runtime parameter YAML file.""" self._logger.info("Generating open_params.yaml") target_ros_dir = self._ros_target_dir() template = self._template('open_params.yaml') @@ -134,6 +198,7 @@ def _generate_ros_params_file(self): fh.write(output_template) def _generate_ros_node_header(self): + """Render and write the generated node header file.""" self._logger.info("Generating open_optimizer.hpp") target_ros_dir = self._ros_target_dir() template = self._template('open_optimizer.hpp') @@ -147,6 +212,7 @@ def _generate_ros_node_header(self): fh.write(output_template) def _generate_ros_node_cpp(self): + """Render and write the generated node implementation file.""" self._logger.info("Generating open_optimizer.cpp") target_ros_dir = self._ros_target_dir() template = self._template('open_optimizer.cpp') @@ -159,6 +225,7 @@ def _generate_ros_node_cpp(self): fh.write(output_template) def _generate_ros_launch_file(self): + """Render and write the package launch file.""" self._logger.info("Generating %s", self._launch_file_name) target_ros_dir = self._ros_target_dir() template = self._template(self._launch_file_name) @@ -169,6 +236,7 @@ def _generate_ros_launch_file(self): fh.write(output_template) def _generate_ros_readme_file(self): + """Render and write the generated package README.""" self._logger.info("Generating README.md") target_ros_dir = self._ros_target_dir() template = self._template('README.md') @@ -178,11 +246,19 @@ def _generate_ros_readme_file(self): fh.write(output_template) def _symbolic_link_info_message(self): + """Emit final user-facing setup instructions for the generated package. + + :raises NotImplementedError: If a subclass does not provide this hook. + """ raise NotImplementedError def build(self): """ - Build ROS-related files + Generate all ROS/ROS2 wrapper files for the current optimizer. + + This method creates the package directory structure, copies the + generated solver artefacts, renders all templates, and logs final setup + instructions for the user. """ self._generate_ros_dir_structure() self._generate_ros_package_xml() @@ -198,9 +274,11 @@ def build(self): class RosBuilder(_BaseRosBuilder): """ - Code generation for ROS-related files + Builder for ROS1 package generation. - For internal use + This specialization uses the ``templates/ros`` template set and the + ROS1-specific configuration stored in + :attr:`opengen.config.build_config.BuildConfiguration.ros_config`. """ _template_subdir = 'ros' @@ -210,9 +288,11 @@ class RosBuilder(_BaseRosBuilder): @property def _ros_config(self): + """Return the ROS1 package configuration.""" return self._build_config.ros_config def _symbolic_link_info_message(self): + """Log the final ROS1 workspace integration instructions.""" target_ros_dir = self._ros_target_dir() self._logger.info("ROS package was built successfully. Now run:") self._logger.info("ln -s %s ~/catkin_ws/src/", target_ros_dir) @@ -221,9 +301,11 @@ def _symbolic_link_info_message(self): class ROS2Builder(_BaseRosBuilder): """ - Code generation for ROS2-related files + Builder for ROS2 package generation. - For internal use + This specialization uses the ``templates/ros2`` template set and the + ROS2-specific configuration stored in + :attr:`opengen.config.build_config.BuildConfiguration.ros2_config`. """ _template_subdir = 'ros2' @@ -233,9 +315,11 @@ class ROS2Builder(_BaseRosBuilder): @property def _ros_config(self): + """Return the ROS2 package configuration.""" return self._build_config.ros2_config def _symbolic_link_info_message(self): + """Log the final ROS2 workspace integration instructions.""" target_ros_dir = self._ros_target_dir() self._logger.info("ROS2 package was built successfully. Now run:") self._logger.info("ln -s %s ~/ros2_ws/src/", target_ros_dir) From 933157dbd75aa5205762e5835ecf154efd59d5de Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 13:39:21 +0000 Subject: [PATCH 04/33] ROS2 tests in CI --- .github/workflows/ci.yml | 35 ++++- ci/script.sh | 10 ++ open-codegen/test/README.md | 3 +- open-codegen/test/test.py | 41 ------ open-codegen/test/test_ros2.py | 229 +++++++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+), 43 deletions(-) create mode 100644 open-codegen/test/test_ros2.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 982b2960..46176b15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,9 @@ jobs: python_tests: name: Python tests (${{ matrix.name }}) - needs: rust_tests + needs: + - rust_tests + - ros2_tests runs-on: ${{ matrix.os }} timeout-minutes: 45 strategy: @@ -102,6 +104,37 @@ jobs: if: runner.os == 'macOS' run: bash ./ci/script.sh python-tests + ros2_tests: + name: ROS2 tests + needs: rust_tests + runs-on: ubuntu-latest + timeout-minutes: 45 + container: + image: ubuntu:noble + env: + DO_DOCKER: 0 + steps: + - uses: actions/checkout@v5 + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + toolchain: stable + rustflags: "" + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + cache: "pip" + cache-dependency-path: open-codegen/setup.py + + - name: Setup ROS 2 + uses: ros-tooling/setup-ros@v0.7 + with: + required-ros-distributions: jazzy + + - name: Run ROS2 Python tests + run: bash ./ci/script.sh ros2-tests + ocp_tests: name: OCP tests (${{ matrix.name }}) needs: python_tests diff --git a/ci/script.sh b/ci/script.sh index 18f1f2b7..dc8bdcef 100755 --- a/ci/script.sh +++ b/ci/script.sh @@ -60,6 +60,11 @@ run_python_core_tests() { generated_clippy_tests } +run_python_ros2_tests() { + export PYTHONPATH=. + python -W ignore test/test_ros2.py -v +} + run_python_ocp_tests() { export PYTHONPATH=. python -W ignore test/test_ocp.py -v @@ -84,6 +89,11 @@ main() { setup_python_test_env run_python_core_tests ;; + ros2-tests) + echo "Running ROS2 Python tests" + setup_python_test_env + run_python_ros2_tests + ;; ocp-tests) echo "Running OCP Python tests" setup_python_test_env diff --git a/open-codegen/test/README.md b/open-codegen/test/README.md index 75cd8379..e946a477 100644 --- a/open-codegen/test/README.md +++ b/open-codegen/test/README.md @@ -47,5 +47,6 @@ The generated benchmark looks like this: Run ``` python -W ignore test/test_constraints.py -v +python -W ignore test/test_ros2.py -v python -W ignore test/test.py -v -``` \ No newline at end of file +``` diff --git a/open-codegen/test/test.py b/open-codegen/test/test.py index a4a1579f..d56f3075 100644 --- a/open-codegen/test/test.py +++ b/open-codegen/test/test.py @@ -172,36 +172,6 @@ def setUpRosPackageGeneration(cls): solver_configuration=cls.solverConfig()) \ .build() - @classmethod - def setUpRos2PackageGeneration(cls): - u = cs.MX.sym("u", 5) # decision variable (nu = 5) - p = cs.MX.sym("p", 2) # parameter (np = 2) - phi = og.functions.rosenbrock(u, p) - c = cs.vertcat(1.5 * u[0] - u[1], - cs.fmax(0.0, u[2] - u[3] + 0.1)) - bounds = og.constraints.Ball2(None, 1.5) - meta = og.config.OptimizerMeta() \ - .with_optimizer_name("rosenbrock_ros2") - problem = og.builder.Problem(u, p, phi) \ - .with_constraints(bounds) \ - .with_penalty_constraints(c) - ros_config = og.config.RosConfiguration() \ - .with_package_name("parametric_optimizer_ros2") \ - .with_node_name("open_node_ros2") \ - .with_rate(35) \ - .with_description("really cool ROS2 node") - build_config = og.config.BuildConfiguration() \ - .with_open_version(local_path=RustBuildTestCase.get_open_local_absolute_path()) \ - .with_build_directory(RustBuildTestCase.TEST_DIR) \ - .with_build_mode(og.config.BuildConfiguration.DEBUG_MODE) \ - .with_build_c_bindings() \ - .with_ros2(ros_config) - og.builder.OpEnOptimizerBuilder(problem, - metadata=meta, - build_configuration=build_config, - solver_configuration=cls.solverConfig()) \ - .build() - @classmethod def setUpOnlyParametricF2(cls): u = cs.MX.sym("u", 5) # decision variable (nu = 5) @@ -261,7 +231,6 @@ def setUpHalfspace(cls): def setUpClass(cls): cls.setUpPythonBindings() cls.setUpRosPackageGeneration() - cls.setUpRos2PackageGeneration() cls.setUpOnlyF1() cls.setUpOnlyF2() cls.setUpOnlyF2(is_preconditioned=True) @@ -269,16 +238,6 @@ def setUpClass(cls): cls.setUpOnlyParametricF2() cls.setUpHalfspace() - def test_ros2_package_generation(self): - ros2_dir = os.path.join( - RustBuildTestCase.TEST_DIR, - "rosenbrock_ros2", - "parametric_optimizer_ros2") - self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "package.xml"))) - self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "CMakeLists.txt"))) - self.assertTrue(os.path.isfile( - os.path.join(ros2_dir, "launch", "open_optimizer.launch.py"))) - def test_python_bindings(self): import sys import os diff --git a/open-codegen/test/test_ros2.py b/open-codegen/test/test_ros2.py new file mode 100644 index 00000000..5609ec48 --- /dev/null +++ b/open-codegen/test/test_ros2.py @@ -0,0 +1,229 @@ +import logging +import os +import shutil +import subprocess +import time +import unittest + +import casadi.casadi as cs +import opengen as og + + +class Ros2BuildTestCase(unittest.TestCase): + """Integration tests for auto-generated ROS2 packages.""" + + TEST_DIR = ".python_test_build" + OPTIMIZER_NAME = "rosenbrock_ros2" + PACKAGE_NAME = "parametric_optimizer_ros2" + NODE_NAME = "open_node_ros2" + + @staticmethod + def get_open_local_absolute_path(): + """Return the absolute path to the local OpEn repository root.""" + cwd = os.getcwd() + return cwd.split('open-codegen')[0] + + @classmethod + def solverConfig(cls): + """Return a solver configuration shared by the ROS2 tests.""" + return og.config.SolverConfiguration() \ + .with_lbfgs_memory(15) \ + .with_tolerance(1e-4) \ + .with_initial_tolerance(1e-4) \ + .with_delta_tolerance(1e-4) \ + .with_initial_penalty(15.0) \ + .with_penalty_weight_update_factor(10.0) \ + .with_max_inner_iterations(155) \ + .with_max_duration_micros(1e8) \ + .with_max_outer_iterations(50) \ + .with_sufficient_decrease_coefficient(0.05) \ + .with_cbfgs_parameters(1.5, 1e-10, 1e-12) \ + .with_preconditioning(False) + + @classmethod + def setUpRos2PackageGeneration(cls): + """Generate the ROS2 package used by the ROS2 integration tests.""" + u = cs.MX.sym("u", 5) + p = cs.MX.sym("p", 2) + phi = og.functions.rosenbrock(u, p) + c = cs.vertcat(1.5 * u[0] - u[1], + cs.fmax(0.0, u[2] - u[3] + 0.1)) + bounds = og.constraints.Ball2(None, 1.5) + meta = og.config.OptimizerMeta() \ + .with_optimizer_name(cls.OPTIMIZER_NAME) + problem = og.builder.Problem(u, p, phi) \ + .with_constraints(bounds) \ + .with_penalty_constraints(c) + ros_config = og.config.RosConfiguration() \ + .with_package_name(cls.PACKAGE_NAME) \ + .with_node_name(cls.NODE_NAME) \ + .with_rate(35) \ + .with_description("really cool ROS2 node") + build_config = og.config.BuildConfiguration() \ + .with_open_version(local_path=cls.get_open_local_absolute_path()) \ + .with_build_directory(cls.TEST_DIR) \ + .with_build_mode(og.config.BuildConfiguration.DEBUG_MODE) \ + .with_build_c_bindings() \ + .with_ros2(ros_config) + og.builder.OpEnOptimizerBuilder(problem, + metadata=meta, + build_configuration=build_config, + solver_configuration=cls.solverConfig()) \ + .build() + + @classmethod + def setUpClass(cls): + """Generate the ROS2 package once before all tests run.""" + if shutil.which("ros2") is None or shutil.which("colcon") is None: + raise unittest.SkipTest("ROS2 CLI tools are not available in PATH") + cls.setUpRos2PackageGeneration() + + @classmethod + def ros2_package_dir(cls): + """Return the filesystem path to the generated ROS2 package.""" + return os.path.join( + cls.TEST_DIR, + cls.OPTIMIZER_NAME, + cls.PACKAGE_NAME) + + @classmethod + def ros2_test_env(cls): + """Return the subprocess environment used by ROS2 integration tests.""" + env = os.environ.copy() + ros2_dir = cls.ros2_package_dir() + os.makedirs(os.path.join(ros2_dir, ".ros_log"), exist_ok=True) + env["ROS_LOG_DIR"] = os.path.join(ros2_dir, ".ros_log") + env.setdefault("RMW_IMPLEMENTATION", "rmw_fastrtps_cpp") + env.pop("ROS_LOCALHOST_ONLY", None) + return env + + @staticmethod + def _bash(command, cwd, env=None, timeout=180, check=True): + """Run a bash command and return the completed process.""" + return subprocess.run( + ["/bin/bash", "-lc", command], + cwd=cwd, + env=env, + text=True, + capture_output=True, + timeout=timeout, + check=check) + + def test_ros2_package_generation(self): + """Verify the ROS2 package files are generated.""" + ros2_dir = self.ros2_package_dir() + self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "package.xml"))) + self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "CMakeLists.txt"))) + self.assertTrue(os.path.isfile( + os.path.join(ros2_dir, "launch", "open_optimizer.launch.py"))) + + def test_generated_ros2_package_works(self): + """Build, run, and call the generated ROS2 package.""" + ros2_dir = self.ros2_package_dir() + env = self.ros2_test_env() + + self._bash( + f"source install/setup.bash >/dev/null 2>&1 || true; " + f"colcon build --packages-select {self.PACKAGE_NAME}", + cwd=ros2_dir, + env=env, + timeout=600) + + node_process = subprocess.Popen( + [ + "/bin/bash", + "-lc", + f"source install/setup.bash && " + f"ros2 run {self.PACKAGE_NAME} {self.NODE_NAME}" + ], + cwd=ros2_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + + try: + node_seen = False + topics_seen = False + for _ in range(6): + node_result = self._bash( + "source install/setup.bash && " + "ros2 node list --no-daemon --spin-time 5", + cwd=ros2_dir, + env=env, + timeout=30, + check=False) + topic_result = self._bash( + "source install/setup.bash && " + "ros2 topic list --no-daemon --spin-time 5", + cwd=ros2_dir, + env=env, + timeout=30, + check=False) + node_seen = f"/{self.NODE_NAME}" in node_result.stdout + topics_seen = "/parameters" in topic_result.stdout and "/result" in topic_result.stdout + if node_seen and topics_seen: + break + time.sleep(1) + + if not (node_seen and topics_seen): + process_output = "" + if node_process.poll() is None: + node_process.terminate() + try: + node_process.wait(timeout=10) + except subprocess.TimeoutExpired: + node_process.kill() + node_process.wait(timeout=10) + if node_process.stdout is not None: + process_output = node_process.stdout.read() + self.fail( + "Generated ROS2 node did not become discoverable.\n" + f"ros2 node list output:\n{node_result.stdout}\n" + f"ros2 topic list output:\n{topic_result.stdout}\n" + f"node process output:\n{process_output}") + + echo_process = subprocess.Popen( + [ + "/bin/bash", + "-lc", + "source install/setup.bash && " + "ros2 topic echo /result --once" + ], + cwd=ros2_dir, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + + try: + time.sleep(1) + self._bash( + "source install/setup.bash && " + "ros2 topic pub --once /parameters " + f"{self.PACKAGE_NAME}/msg/OptimizationParameters " + "'{parameter: [1.0, 2.0], initial_guess: [0.0, 0.0, 0.0, 0.0, 0.0], initial_y: [], initial_penalty: 15.0}'", + cwd=ros2_dir, + env=env, + timeout=60) + echo_stdout, _ = echo_process.communicate(timeout=60) + finally: + if echo_process.poll() is None: + echo_process.terminate() + echo_process.wait(timeout=10) + + self.assertIn("solution", echo_stdout) + self.assertIn("solve_time_ms", echo_stdout) + finally: + if node_process.poll() is None: + node_process.terminate() + try: + node_process.wait(timeout=10) + except subprocess.TimeoutExpired: + node_process.kill() + node_process.wait(timeout=10) + + +if __name__ == '__main__': + logging.getLogger('retry').setLevel(logging.ERROR) + unittest.main() From cc6fce9a6091ffd28185dc6542b5c42974189fad Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 13:42:44 +0000 Subject: [PATCH 05/33] fix GA dependencies (ROS2 tests) --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46176b15..c02690a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,17 @@ jobs: steps: - uses: actions/checkout@v5 + - name: Install container bootstrap dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + curl \ + ca-certificates \ + git \ + gnupg2 \ + locales \ + lsb-release + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable From 620f9a7e28ae1336476fe0f0416610b99458bdc2 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 13:50:20 +0000 Subject: [PATCH 06/33] fix issues in ci.yml --- .github/workflows/ci.yml | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c02690a4..63ee634b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,17 +28,6 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Install container bootstrap dependencies - run: | - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y \ - curl \ - ca-certificates \ - git \ - gnupg2 \ - locales \ - lsb-release - - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable @@ -122,11 +111,23 @@ jobs: timeout-minutes: 45 container: image: ubuntu:noble + options: --user 0 env: DO_DOCKER: 0 steps: - uses: actions/checkout@v5 + - name: Install container bootstrap dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + curl \ + ca-certificates \ + git \ + gnupg2 \ + locales \ + lsb-release + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable From 3e92b92555c2b6424bcb14c4b305b882ef8d3613 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 13:56:02 +0000 Subject: [PATCH 07/33] wip: working on GA issues --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63ee634b..2d9b0785 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,8 +136,6 @@ jobs: - uses: actions/setup-python@v6 with: python-version: "3.12" - cache: "pip" - cache-dependency-path: open-codegen/setup.py - name: Setup ROS 2 uses: ros-tooling/setup-ros@v0.7 From 9d3781c7c4fdeeef82720f6884a85531e9a679bc Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 14:02:25 +0000 Subject: [PATCH 08/33] GA actions configuration --- ci/script.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ci/script.sh b/ci/script.sh index dc8bdcef..12fc8418 100755 --- a/ci/script.sh +++ b/ci/script.sh @@ -62,6 +62,18 @@ run_python_core_tests() { run_python_ros2_tests() { export PYTHONPATH=. + if [ -n "${ROS_DISTRO:-}" ] && [ -f "/opt/ros/${ROS_DISTRO}/setup.bash" ]; then + # setup-ros installs the ROS underlay but does not source it for our shell + source "/opt/ros/${ROS_DISTRO}/setup.bash" + elif [ -f "/opt/ros/jazzy/setup.bash" ]; then + source "/opt/ros/jazzy/setup.bash" + else + echo "ROS2 environment setup script not found" + exit 1 + fi + + command -v ros2 >/dev/null + command -v colcon >/dev/null python -W ignore test/test_ros2.py -v } From 8b8068d25f7626c27eca92e1b3783dcfa6770322 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 14:10:56 +0000 Subject: [PATCH 09/33] fix issue in script.sh (set -u/+u) --- ci/script.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ci/script.sh b/ci/script.sh index 12fc8418..f188eb95 100755 --- a/ci/script.sh +++ b/ci/script.sh @@ -62,15 +62,18 @@ run_python_core_tests() { run_python_ros2_tests() { export PYTHONPATH=. + set +u if [ -n "${ROS_DISTRO:-}" ] && [ -f "/opt/ros/${ROS_DISTRO}/setup.bash" ]; then # setup-ros installs the ROS underlay but does not source it for our shell source "/opt/ros/${ROS_DISTRO}/setup.bash" elif [ -f "/opt/ros/jazzy/setup.bash" ]; then source "/opt/ros/jazzy/setup.bash" else + set -u echo "ROS2 environment setup script not found" exit 1 fi + set -u command -v ros2 >/dev/null command -v colcon >/dev/null From f7c5799b71ecdc03cb2460fa37127f3cfe122418 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 14:18:07 +0000 Subject: [PATCH 10/33] GA: let's try again --- .github/workflows/ci.yml | 2 ++ open-codegen/test/test_ros2.py | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d9b0785..75a7237b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,8 @@ jobs: run: | apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y \ + build-essential \ + cmake \ curl \ ca-certificates \ git \ diff --git a/open-codegen/test/test_ros2.py b/open-codegen/test/test_ros2.py index 5609ec48..606f0006 100644 --- a/open-codegen/test/test_ros2.py +++ b/open-codegen/test/test_ros2.py @@ -100,14 +100,22 @@ def ros2_test_env(cls): @staticmethod def _bash(command, cwd, env=None, timeout=180, check=True): """Run a bash command and return the completed process.""" - return subprocess.run( + result = subprocess.run( ["/bin/bash", "-lc", command], cwd=cwd, env=env, text=True, capture_output=True, timeout=timeout, - check=check) + check=False) + if check and result.returncode != 0: + raise AssertionError( + "Command failed with exit code " + f"{result.returncode}: {command}\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + return result def test_ros2_package_generation(self): """Verify the ROS2 package files are generated.""" From 255a15210f33a80f0b6768089da4ee290fb9716f Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 14:42:10 +0000 Subject: [PATCH 11/33] ROS2 tests work OK locally --- open-codegen/test/test_ros2.py | 96 +++++++++++++++++++++------------- 1 file changed, 60 insertions(+), 36 deletions(-) diff --git a/open-codegen/test/test_ros2.py b/open-codegen/test/test_ros2.py index 606f0006..573777dc 100644 --- a/open-codegen/test/test_ros2.py +++ b/open-codegen/test/test_ros2.py @@ -1,5 +1,6 @@ import logging import os +import signal import shutil import subprocess import time @@ -97,11 +98,24 @@ def ros2_test_env(cls): env.pop("ROS_LOCALHOST_ONLY", None) return env - @staticmethod - def _bash(command, cwd, env=None, timeout=180, check=True): - """Run a bash command and return the completed process.""" + @classmethod + def ros2_shell(cls): + """Return the preferred shell executable and setup script for ROS2 commands.""" + shell_path = "/bin/bash" + setup_script = "install/setup.bash" + preferred_shell = os.path.basename(os.environ.get("SHELL", "")) + zsh_setup = os.path.join(cls.ros2_package_dir(), "install", "setup.zsh") + if preferred_shell == "zsh" and os.path.isfile(zsh_setup): + shell_path = "/bin/zsh" + setup_script = "install/setup.zsh" + return shell_path, setup_script + + @classmethod + def _run_shell(cls, command, cwd, env=None, timeout=180, check=True): + """Run a command in the preferred shell and return the completed process.""" + shell_path, _ = cls.ros2_shell() result = subprocess.run( - ["/bin/bash", "-lc", command], + [shell_path, "-lc", command], cwd=cwd, env=env, text=True, @@ -117,6 +131,28 @@ def _bash(command, cwd, env=None, timeout=180, check=True): ) return result + @staticmethod + def _terminate_process(process, timeout=10): + """Terminate a spawned shell process and its children, then collect output.""" + if process.poll() is None: + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=timeout) + try: + stdout, _ = process.communicate(timeout=1) + except subprocess.TimeoutExpired: + stdout = "" + return stdout or "" + def test_ros2_package_generation(self): """Verify the ROS2 package files are generated.""" ros2_dir = self.ros2_package_dir() @@ -129,9 +165,10 @@ def test_generated_ros2_package_works(self): """Build, run, and call the generated ROS2 package.""" ros2_dir = self.ros2_package_dir() env = self.ros2_test_env() + shell_path, setup_script = self.ros2_shell() - self._bash( - f"source install/setup.bash >/dev/null 2>&1 || true; " + self._run_shell( + f"source {setup_script} >/dev/null 2>&1 || true; " f"colcon build --packages-select {self.PACKAGE_NAME}", cwd=ros2_dir, env=env, @@ -139,30 +176,31 @@ def test_generated_ros2_package_works(self): node_process = subprocess.Popen( [ - "/bin/bash", + shell_path, "-lc", - f"source install/setup.bash && " + f"source {setup_script} && " f"ros2 run {self.PACKAGE_NAME} {self.NODE_NAME}" ], cwd=ros2_dir, env=env, text=True, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) + stderr=subprocess.STDOUT, + start_new_session=True) try: node_seen = False topics_seen = False for _ in range(6): - node_result = self._bash( - "source install/setup.bash && " + node_result = self._run_shell( + f"source {setup_script} && " "ros2 node list --no-daemon --spin-time 5", cwd=ros2_dir, env=env, timeout=30, check=False) - topic_result = self._bash( - "source install/setup.bash && " + topic_result = self._run_shell( + f"source {setup_script} && " "ros2 topic list --no-daemon --spin-time 5", cwd=ros2_dir, env=env, @@ -175,16 +213,7 @@ def test_generated_ros2_package_works(self): time.sleep(1) if not (node_seen and topics_seen): - process_output = "" - if node_process.poll() is None: - node_process.terminate() - try: - node_process.wait(timeout=10) - except subprocess.TimeoutExpired: - node_process.kill() - node_process.wait(timeout=10) - if node_process.stdout is not None: - process_output = node_process.stdout.read() + process_output = self._terminate_process(node_process) self.fail( "Generated ROS2 node did not become discoverable.\n" f"ros2 node list output:\n{node_result.stdout}\n" @@ -193,21 +222,22 @@ def test_generated_ros2_package_works(self): echo_process = subprocess.Popen( [ - "/bin/bash", + shell_path, "-lc", - "source install/setup.bash && " + f"source {setup_script} && " "ros2 topic echo /result --once" ], cwd=ros2_dir, env=env, text=True, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT) + stderr=subprocess.STDOUT, + start_new_session=True) try: time.sleep(1) - self._bash( - "source install/setup.bash && " + self._run_shell( + f"source {setup_script} && " "ros2 topic pub --once /parameters " f"{self.PACKAGE_NAME}/msg/OptimizationParameters " "'{parameter: [1.0, 2.0], initial_guess: [0.0, 0.0, 0.0, 0.0, 0.0], initial_y: [], initial_penalty: 15.0}'", @@ -217,19 +247,13 @@ def test_generated_ros2_package_works(self): echo_stdout, _ = echo_process.communicate(timeout=60) finally: if echo_process.poll() is None: - echo_process.terminate() - echo_process.wait(timeout=10) + self._terminate_process(echo_process) self.assertIn("solution", echo_stdout) self.assertIn("solve_time_ms", echo_stdout) finally: if node_process.poll() is None: - node_process.terminate() - try: - node_process.wait(timeout=10) - except subprocess.TimeoutExpired: - node_process.kill() - node_process.wait(timeout=10) + self._terminate_process(node_process) if __name__ == '__main__': From ea7a2737e4436c85567ef03171af6892a43d1030 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 14:44:55 +0000 Subject: [PATCH 12/33] fix GA issues (hopefully) --- open-codegen/opengen/templates/ros2/CMakeLists.txt | 7 +++++++ open-codegen/test/test_ros2.py | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/open-codegen/opengen/templates/ros2/CMakeLists.txt b/open-codegen/opengen/templates/ros2/CMakeLists.txt index 398512b2..5e786b5e 100644 --- a/open-codegen/opengen/templates/ros2/CMakeLists.txt +++ b/open-codegen/opengen/templates/ros2/CMakeLists.txt @@ -7,6 +7,13 @@ endif() find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) +set(Python3_FIND_VIRTUALENV FIRST) +if(NOT Python3_EXECUTABLE AND DEFINED ENV{VIRTUAL_ENV}) + set(_open_python3_executable "$ENV{VIRTUAL_ENV}/bin/python") + if(EXISTS "${_open_python3_executable}") + set(Python3_EXECUTABLE "${_open_python3_executable}") + endif() +endif() find_package(Python3 REQUIRED COMPONENTS Interpreter Development NumPy) set(Python_EXECUTABLE ${Python3_EXECUTABLE}) set(Python_INCLUDE_DIRS ${Python3_INCLUDE_DIRS}) diff --git a/open-codegen/test/test_ros2.py b/open-codegen/test/test_ros2.py index 573777dc..3ae53b81 100644 --- a/open-codegen/test/test_ros2.py +++ b/open-codegen/test/test_ros2.py @@ -1,8 +1,10 @@ import logging import os +import shlex import signal import shutil import subprocess +import sys import time import unittest @@ -166,10 +168,12 @@ def test_generated_ros2_package_works(self): ros2_dir = self.ros2_package_dir() env = self.ros2_test_env() shell_path, setup_script = self.ros2_shell() + python_executable = shlex.quote(sys.executable) self._run_shell( f"source {setup_script} >/dev/null 2>&1 || true; " - f"colcon build --packages-select {self.PACKAGE_NAME}", + f"colcon build --packages-select {self.PACKAGE_NAME} " + f"--cmake-args -DPython3_EXECUTABLE={python_executable}", cwd=ros2_dir, env=env, timeout=600) From 918ba05a388a33a9f422dfb2b3e9a97de4a397c4 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 14:53:28 +0000 Subject: [PATCH 13/33] CI issue: import empy --- ci/script.sh | 5 +++++ open-codegen/opengen/templates/ros2/CMakeLists.txt | 3 +++ 2 files changed, 8 insertions(+) diff --git a/ci/script.sh b/ci/script.sh index f188eb95..85a1a0b1 100755 --- a/ci/script.sh +++ b/ci/script.sh @@ -75,6 +75,11 @@ run_python_ros2_tests() { fi set -u + if ! python -c "import em" >/dev/null 2>&1; then + # rosidl_adapter imports the `em` module from Empy during message generation + python -m pip install empy + fi + command -v ros2 >/dev/null command -v colcon >/dev/null python -W ignore test/test_ros2.py -v diff --git a/open-codegen/opengen/templates/ros2/CMakeLists.txt b/open-codegen/opengen/templates/ros2/CMakeLists.txt index 5e786b5e..10d54220 100644 --- a/open-codegen/opengen/templates/ros2/CMakeLists.txt +++ b/open-codegen/opengen/templates/ros2/CMakeLists.txt @@ -7,6 +7,9 @@ endif() find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) + +# tells CMake's FindPython3 to prefer a venv if one is active +# (instead of the system-wide python) set(Python3_FIND_VIRTUALENV FIRST) if(NOT Python3_EXECUTABLE AND DEFINED ENV{VIRTUAL_ENV}) set(_open_python3_executable "$ENV{VIRTUAL_ENV}/bin/python") From e66d975104f24062e6c7f536384279c92dd3b221 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 15:00:40 +0000 Subject: [PATCH 14/33] trying to fix GA issues --- ci/script.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ci/script.sh b/ci/script.sh index 85a1a0b1..b8d988b7 100755 --- a/ci/script.sh +++ b/ci/script.sh @@ -75,9 +75,13 @@ run_python_ros2_tests() { fi set -u - if ! python -c "import em" >/dev/null 2>&1; then - # rosidl_adapter imports the `em` module from Empy during message generation - python -m pip install empy + if ! python -c "import em, lark, catkin_pkg" >/dev/null 2>&1; then + # ROS2 build helpers run under the active Python interpreter. The test venv + # already has NumPy from `pip install .`, but we also need the ROS-side + # Python packages used during interface and package metadata generation. + # Empy 4 has broken older ROS message generators in the past, so keep it + # on the 3.x API here. + python -m pip install "empy<4" lark catkin_pkg fi command -v ros2 >/dev/null From ec34d392f4d5385514c3d0fb9d7c1e4191cac200 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 15:24:44 +0000 Subject: [PATCH 15/33] rearrange GA jobs --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75a7237b..44e55725 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,7 +45,6 @@ jobs: name: Python tests (${{ matrix.name }}) needs: - rust_tests - - ros2_tests runs-on: ${{ matrix.os }} timeout-minutes: 45 strategy: @@ -106,7 +105,7 @@ jobs: ros2_tests: name: ROS2 tests - needs: rust_tests + needs: python_tests runs-on: ubuntu-latest timeout-minutes: 45 container: From 27a7a12957626c79730a35214fd16631dbc7099f Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 15:54:50 +0000 Subject: [PATCH 16/33] further testing of ROS2 --- open-codegen/test/test_ros2.py | 148 +++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/open-codegen/test/test_ros2.py b/open-codegen/test/test_ros2.py index 3ae53b81..6856b610 100644 --- a/open-codegen/test/test_ros2.py +++ b/open-codegen/test/test_ros2.py @@ -12,6 +12,154 @@ import opengen as og +class BuildConfigurationRos2TestCase(unittest.TestCase): + """Unit tests for ROS2-specific build configuration behavior.""" + + def test_with_ros2_sets_ros2_config_and_enables_c_bindings(self): + """`with_ros2` should store the ROS2 config and enable C bindings.""" + ros2_config = og.config.RosConfiguration().with_package_name("unit_test_ros2_pkg") + build_config = og.config.BuildConfiguration().with_ros2(ros2_config) + + self.assertIs(build_config.ros2_config, ros2_config) + self.assertIsNone(build_config.ros_config) + self.assertTrue(build_config.build_c_bindings) + + build_dict = build_config.to_dict() + self.assertIn("ros2_config", build_dict) + self.assertNotIn("ros_config", build_dict) + self.assertEqual("unit_test_ros2_pkg", build_dict["ros2_config"]["package_name"]) + + def test_ros_and_ros2_configs_clear_each_other(self): + """Selecting ROS1 or ROS2 should clear the other package configuration.""" + ros1_config = og.config.RosConfiguration().with_package_name("unit_test_ros_pkg") + ros2_config = og.config.RosConfiguration().with_package_name("unit_test_ros2_pkg") + build_config = og.config.BuildConfiguration() + + build_config.with_ros2(ros2_config) + self.assertIs(build_config.ros2_config, ros2_config) + self.assertIsNone(build_config.ros_config) + + build_config.with_ros(ros1_config) + self.assertIs(build_config.ros_config, ros1_config) + self.assertIsNone(build_config.ros2_config) + + build_config.with_ros2(ros2_config) + self.assertIs(build_config.ros2_config, ros2_config) + self.assertIsNone(build_config.ros_config) + + +class Ros2TemplateCustomizationTestCase(unittest.TestCase): + """Generation tests for custom ROS2 configuration values.""" + + TEST_DIR = ".python_test_build" + OPTIMIZER_NAME = "rosenbrock_ros2_custom" + PACKAGE_NAME = "custom_parametric_optimizer_ros2" + NODE_NAME = "custom_open_node_ros2" + DESCRIPTION = "custom ROS2 package for generation tests" + RESULT_TOPIC = "custom_result_topic" + PARAMS_TOPIC = "custom_params_topic" + RATE = 17.5 + RESULT_QUEUE_SIZE = 11 + PARAMS_QUEUE_SIZE = 13 + + @staticmethod + def get_open_local_absolute_path(): + """Return the absolute path to the local OpEn repository root.""" + cwd = os.getcwd() + return cwd.split('open-codegen')[0] + + @classmethod + def solverConfig(cls): + """Return a solver configuration shared by the ROS2 generation tests.""" + return Ros2BuildTestCase.solverConfig() + + @classmethod + def setUpCustomRos2PackageGeneration(cls): + """Generate a ROS2 package with non-default configuration values.""" + u = cs.MX.sym("u", 5) + p = cs.MX.sym("p", 2) + phi = og.functions.rosenbrock(u, p) + c = cs.vertcat(1.5 * u[0] - u[1], + cs.fmax(0.0, u[2] - u[3] + 0.1)) + bounds = og.constraints.Ball2(None, 1.5) + meta = og.config.OptimizerMeta() \ + .with_optimizer_name(cls.OPTIMIZER_NAME) + problem = og.builder.Problem(u, p, phi) \ + .with_constraints(bounds) \ + .with_penalty_constraints(c) + ros_config = og.config.RosConfiguration() \ + .with_package_name(cls.PACKAGE_NAME) \ + .with_node_name(cls.NODE_NAME) \ + .with_description(cls.DESCRIPTION) \ + .with_rate(cls.RATE) \ + .with_queue_sizes(cls.RESULT_QUEUE_SIZE, cls.PARAMS_QUEUE_SIZE) \ + .with_publisher_subtopic(cls.RESULT_TOPIC) \ + .with_subscriber_subtopic(cls.PARAMS_TOPIC) + build_config = og.config.BuildConfiguration() \ + .with_open_version(local_path=cls.get_open_local_absolute_path()) \ + .with_build_directory(cls.TEST_DIR) \ + .with_build_mode(og.config.BuildConfiguration.DEBUG_MODE) \ + .with_build_c_bindings() \ + .with_ros2(ros_config) + og.builder.OpEnOptimizerBuilder(problem, + metadata=meta, + build_configuration=build_config, + solver_configuration=cls.solverConfig()) \ + .build() + + @classmethod + def setUpClass(cls): + """Generate the custom ROS2 package once before running tests.""" + cls.setUpCustomRos2PackageGeneration() + + @classmethod + def ros2_package_dir(cls): + """Return the filesystem path to the generated custom ROS2 package.""" + return os.path.join( + cls.TEST_DIR, + cls.OPTIMIZER_NAME, + cls.PACKAGE_NAME) + + def test_custom_ros2_configuration_is_rendered_into_generated_files(self): + """Custom ROS2 config values should appear in the generated package files.""" + ros2_dir = self.ros2_package_dir() + + with open(os.path.join(ros2_dir, "package.xml"), encoding="utf-8") as f: + package_xml = f.read() + self.assertIn(f"{self.PACKAGE_NAME}", package_xml) + self.assertIn(f"{self.DESCRIPTION}", package_xml) + + with open(os.path.join(ros2_dir, "include", "open_optimizer.hpp"), encoding="utf-8") as f: + optimizer_header = f.read() + self.assertIn(f'#define ROS2_NODE_{self.OPTIMIZER_NAME.upper()}_NODE_NAME "{self.NODE_NAME}"', + optimizer_header) + self.assertIn(f'#define ROS2_NODE_{self.OPTIMIZER_NAME.upper()}_RESULT_TOPIC "{self.RESULT_TOPIC}"', + optimizer_header) + self.assertIn(f'#define ROS2_NODE_{self.OPTIMIZER_NAME.upper()}_PARAMS_TOPIC "{self.PARAMS_TOPIC}"', + optimizer_header) + self.assertIn(f"#define ROS2_NODE_{self.OPTIMIZER_NAME.upper()}_RATE {self.RATE}", + optimizer_header) + self.assertIn( + f"#define ROS2_NODE_{self.OPTIMIZER_NAME.upper()}_RESULT_TOPIC_QUEUE_SIZE {self.RESULT_QUEUE_SIZE}", + optimizer_header) + self.assertIn( + f"#define ROS2_NODE_{self.OPTIMIZER_NAME.upper()}_PARAMS_TOPIC_QUEUE_SIZE {self.PARAMS_QUEUE_SIZE}", + optimizer_header) + + with open(os.path.join(ros2_dir, "config", "open_params.yaml"), encoding="utf-8") as f: + params_yaml = f.read() + self.assertIn(f'result_topic: "{self.RESULT_TOPIC}"', params_yaml) + self.assertIn(f'params_topic: "{self.PARAMS_TOPIC}"', params_yaml) + self.assertIn(f"rate: {self.RATE}", params_yaml) + + with open(os.path.join(ros2_dir, "launch", "open_optimizer.launch.py"), encoding="utf-8") as f: + launch_file = f.read() + self.assertIn(f'package="{self.PACKAGE_NAME}"', launch_file) + self.assertIn(f'executable="{self.NODE_NAME}"', launch_file) + self.assertIn(f'name="{self.NODE_NAME}"', launch_file) + self.assertIn(f'FindPackageShare("{self.PACKAGE_NAME}")', launch_file) + + class Ros2BuildTestCase(unittest.TestCase): """Integration tests for auto-generated ROS2 packages.""" From 48d3bdfba752385d47621e1ba7268a85d46d614e Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 16:07:52 +0000 Subject: [PATCH 17/33] update changelog - towards opengen v0.11.0 --- open-codegen/CHANGELOG.md | 12 ++++++++++++ open-codegen/publish-pypi.sh | 0 2 files changed, 12 insertions(+) mode change 100644 => 100755 open-codegen/publish-pypi.sh diff --git a/open-codegen/CHANGELOG.md b/open-codegen/CHANGELOG.md index 75b7a86d..eb715853 100644 --- a/open-codegen/CHANGELOG.md +++ b/open-codegen/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Note: This is the Changelog file of `opengen` - the Python interface of OpEn +## [0.11.0] - 2026-03-25 + +### Added + +- ROS2 package generation support via `BuildConfiguration.with_ros2(...)`, including auto-generated ROS2 templates, launcher, messages, and package wrapper code +- Dedicated ROS2 tests covering package generation, build configuration behavior, rendered custom package settings, and end-to-end execution of a generated ROS2 node + +### Changed + +- Extended `RosConfiguration` so it can be used for both ROS and ROS2 package generation + + ## [0.10.1] - 2026-03-25 diff --git a/open-codegen/publish-pypi.sh b/open-codegen/publish-pypi.sh old mode 100644 new mode 100755 From b828f2436fb68432b19c85f79b142de7768702b8 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 16:08:05 +0000 Subject: [PATCH 18/33] minor --- open-codegen/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-codegen/CHANGELOG.md b/open-codegen/CHANGELOG.md index eb715853..e6940a39 100644 --- a/open-codegen/CHANGELOG.md +++ b/open-codegen/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Note: This is the Changelog file of `opengen` - the Python interface of OpEn -## [0.11.0] - 2026-03-25 +## [0.11.0] - Unreleased ### Added From 29f65f96ee019572ecdaf670d1b5d7cef50ecce8 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 16:08:29 +0000 Subject: [PATCH 19/33] bump version --- open-codegen/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-codegen/VERSION b/open-codegen/VERSION index 71172b43..05b845f9 100644 --- a/open-codegen/VERSION +++ b/open-codegen/VERSION @@ -1 +1 @@ -0.10.1 \ No newline at end of file +0.11.0a1 \ No newline at end of file From 7af46bad0a370fc70c0bc848044e01ac7fcd25c7 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 16:14:46 +0000 Subject: [PATCH 20/33] update publish-pypi.sh - warn if not on master and version not alpha - print version and branch --- open-codegen/publish-pypi.sh | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/open-codegen/publish-pypi.sh b/open-codegen/publish-pypi.sh index 1feca85b..ddaf594d 100755 --- a/open-codegen/publish-pypi.sh +++ b/open-codegen/publish-pypi.sh @@ -4,9 +4,30 @@ set -eu # This script facilitates releasing a new version of opengen to PyPI. # It expects a local virtual environment at ./venv with publishing tools. -echo "[OpEnGen] Checking out master" -git checkout master -git pull origin master +version=$(cat VERSION) +current_branch=$(git rev-parse --abbrev-ref HEAD) + +is_alpha_version=false +case "$version" in + *a[0-9]*) + is_alpha_version=true + ;; +esac + +if [ "$current_branch" != "master" ] && [ "$is_alpha_version" = false ]; then + echo "[OpEnGen] Warning: version $version is not an alpha release and the current branch is '$current_branch' (not 'master')." + printf "Proceed anyway? [y/N] " + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + echo "[OpEnGen] Proceeding from branch '$current_branch'" + ;; + *) + echo "[OpEnGen] Publish cancelled" + exit 0 + ;; + esac +fi echo "[OpEnGen] Cleaning previous build artifacts" rm -rf ./build ./dist ./opengen.egg-info @@ -23,7 +44,7 @@ python -m build echo "[OpEnGen] Checking distributions with twine" python -m twine check dist/* -echo "[OpEnGen] Uploading to PyPI..." +echo "[OpEnGen] You are about to publish version $version from branch '$current_branch'." printf "Are you sure? [y/N] " read -r response case "$response" in @@ -37,6 +58,5 @@ case "$response" in esac echo "[OpEnGen] Don't forget to create a tag; run:" -version=$(cat VERSION) echo "\$ git tag -a opengen-$version -m 'opengen-$version'" echo "\$ git push --tags" From 874d2bbaa391a82fa3a6c76c077a0aba4812887e Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 18:36:05 +0000 Subject: [PATCH 21/33] [ci skip] update website docs --- docs/python-ros.md | 2 +- docs/python-ros2.mdx | 186 +++++++++++++++++++++++++++++++++++++++++++ website/sidebars.js | 1 + 3 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 docs/python-ros2.mdx diff --git a/docs/python-ros.md b/docs/python-ros.md index f06fa078..43a7f4d8 100644 --- a/docs/python-ros.md +++ b/docs/python-ros.md @@ -2,7 +2,7 @@ id: python-ros title: Generation of ROS packages sidebar_label: ROS packages -description: Code generation for ROS packages using OpEn in Python +description: Code generation for ROS packages using opengen --- ## What is ROS diff --git a/docs/python-ros2.mdx b/docs/python-ros2.mdx new file mode 100644 index 00000000..e875716c --- /dev/null +++ b/docs/python-ros2.mdx @@ -0,0 +1,186 @@ +--- +id: python-ros2 +title: Generation of ROS2 packages +sidebar_label: ROS2 packages +description: Code generation for ROS2 packages using opengen +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +:::note Info +The functionality presented here was introduced in `opengen` version [`0.11.0a1`](https://pypi.org/project/opengen/#history). +::: + +## What is ROS2 + +[ROS2](https://docs.ros.org/en/jazzy/index.html) is the successor of the Robot Operating System (ROS). It provides tools, libraries, and communication mechanisms that make it easier to build distributed robotic applications. + +In ROS2, functionality is organised in **nodes** which exchange data by publishing and subscribing to **topics** using typed **messages**. This makes ROS2 a natural fit for connecting optimizers, controllers, estimators, and sensors in robotics systems. + +## ROS2 + OpEn + +OpEn can generate ready-to-use ROS2 packages directly from a parametric optimizer. The generated package exposes the optimizer as a ROS2 node, includes the required message definitions, and provides the files needed to build, configure, and launch it inside a ROS2 workspace. + +The input and output messages are the same as in the [ROS1 package documentation](./python-ros#messages). + +## Configuration Parameters + +The configuration parameters are the same as in the [ROS1 package documentation](./python-ros#configuration-parameters): you can configure the node rate, the input topic name, and the output topic name. + +In ROS2, these settings are stored using the ROS2 parameter-file format in `config/open_params.yaml`: + +```yaml +/**: + ros__parameters: + result_topic: "result" + params_topic: "parameters" + rate: 10 +``` + +## Code generation + +To generate a ROS2 package from Python, create a `RosConfiguration` object and attach it to the build configuration using `.with_ros2(...)`. + +### Example + +```py +import opengen as og +import casadi.casadi as cs + +u = cs.SX.sym("u", 5) +p = cs.SX.sym("p", 2) +phi = og.functions.rosenbrock(u, p) + +problem = og.builder.Problem(u, p, phi) \ + .with_constraints(og.constraints.Ball2(None, 1.5)) + +meta = og.config.OptimizerMeta() \ + .with_optimizer_name("rosenbrock_ros2") + +ros2_config = og.config.RosConfiguration() \ + .with_package_name("parametric_optimizer_ros2") \ + .with_node_name("open_node_ros2") \ + .with_rate(10) + +build_config = og.config.BuildConfiguration() \ + .with_build_directory("my_optimizers") \ + .with_ros2(ros2_config) + +builder = og.builder.OpEnOptimizerBuilder(problem, meta, build_config) +builder.build() +``` + +Note the use of `with_ros2` and note that `RosConfiguration` is the same config +class as in [ROS1](./python-ros). +This generates the optimizer in `my_optimizers/rosenbrock_ros2`, and the ROS2 +package is created inside that directory as `parametric_optimizer_ros2`. + + +## Use the auto-generated ROS2 package + +OpEn generates a `README.md` file inside the generated ROS2 package with detailed instructions. In brief, the workflow is: + +1. Build the package with `colcon build` +2. Source the generated workspace setup script +3. Run the node with `ros2 run` +4. Publish optimization requests on the input topic and read results from the output topic + +For example, from inside the generated package directory: + + + + +```bash +colcon build --packages-select parametric_optimizer_ros2 +source install/setup.bash +ros2 run parametric_optimizer_ros2 open_node_ros2 +``` + + + + +```bash +colcon build --packages-select parametric_optimizer_ros2 +source install/setup.zsh +ros2 run parametric_optimizer_ros2 open_node_ros2 +``` + + + + +In a second terminal: + + + + +```bash +source install/setup.bash +ros2 topic pub --once /parameters parametric_optimizer_ros2/msg/OptimizationParameters \ + "{parameter: [1.0, 2.0], initial_guess: [0.0, 0.0, 0.0, 0.0, 0.0], initial_y: [], initial_penalty: 15.0}" +ros2 topic echo /result +``` + + + + +```bash +source install/setup.zsh +ros2 topic pub --once /parameters parametric_optimizer_ros2/msg/OptimizationParameters \ + "{parameter: [1.0, 2.0], initial_guess: [0.0, 0.0, 0.0, 0.0, 0.0], initial_y: [], initial_penalty: 15.0}" +ros2 topic echo /result +``` + + + + +Instead of starting the node with `ros2 run`, you can also use the generated launch file: + +```bash +ros2 launch parametric_optimizer_ros2 open_optimizer.launch.py +``` + +
+ See the launch file + +

The launch file is as follows

+ + ```python + # file open_optimizer.launch.py + from launch import LaunchDescription + from launch.substitutions import PathJoinSubstitution + from launch_ros.actions import Node + from launch_ros.substitutions import FindPackageShare + + + def generate_launch_description(): + return LaunchDescription([ + Node( + package="custom_parametric_optimizer_ros2", + executable="custom_open_node_ros2", + name="custom_open_node_ros2", + output="screen", + parameters=[PathJoinSubstitution([ + FindPackageShare("custom_parametric_optimizer_ros2"), + "config", + "open_params.yaml", + ])], + ) + ]) + ``` +
+ +The launch file starts the auto-generated node and loads its parameters from `config/open_params.yaml`, where you can adjust settings such as the input topic, output topic, and node rate. + + +## Inside the ROS2 package + +The auto-generated ROS2 package contains everything needed to build and run the optimizer as a ROS2 node. + +- `msg/` contains the auto-generated message definitions, including `OptimizationParameters.msg` and `OptimizationResult.msg` +- `src/` contains the C++ node implementation that wraps the optimizer +- `include/` contains the corresponding C++ headers +- `config/open_params.yaml` stores runtime parameters such as the input topic, output topic, and node rate +- `launch/open_optimizer.launch.py` provides a ready-to-use ROS2 launch file +- `CMakeLists.txt` and `package.xml` define the ROS2 package and its build dependencies +- `README.md` contains package-specific build and usage instructions diff --git a/website/sidebars.js b/website/sidebars.js index 94729cf6..3b4c7d02 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -14,6 +14,7 @@ module.exports = { 'python-c', 'python-bindings', 'python-tcp-ip', + 'python-ros2', 'python-ros', 'python-examples', ], From 9d056552623f862b514fcd8d15b36fb238dff220 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 18:44:22 +0000 Subject: [PATCH 22/33] [ci skip] update ROS2 documentation --- docs/python-ros2.mdx | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/python-ros2.mdx b/docs/python-ros2.mdx index e875716c..82ce1778 100644 --- a/docs/python-ros2.mdx +++ b/docs/python-ros2.mdx @@ -134,6 +134,41 @@ ros2 topic echo /result +If ROS2 cannot write to its default log directory, set an explicit writable log path before running the node: + +```bash +mkdir -p .ros_log +export ROS_LOG_DIR="$PWD/.ros_log" +``` + +:::note Troubleshooting +On some systems, the generated node may start but not appear in the ROS2 graph. If `ros2 topic pub` keeps printing `Waiting for at least 1 matching subscription(s)...`, set +`RMW_IMPLEMENTATION=rmw_fastrtps_cpp` in both terminals before sourcing the generated workspace and running any `ros2` commands: + +```bash +export RMW_IMPLEMENTATION=rmw_fastrtps_cpp +``` + +This should only be needed if ROS2 discovery is not working correctly with your default middleware. +::: + +To verify that the node is visible, you can run: + +```bash +ros2 node list --no-daemon --spin-time 5 +ros2 topic list --no-daemon --spin-time 5 +``` + +The first command should list the running node, for example `/open_node_ros2`. The second should list the available topics, including `/parameters` and `/result`. + +To read a single optimizer response, you can use: + +```bash +ros2 topic echo /result --once +``` + +This subscribes to the result topic, prints one `OptimizationResult` message, and then exits. + Instead of starting the node with `ros2 run`, you can also use the generated launch file: ```bash From 501a4e8cd00ff87c8490a6c36721afae439858f0 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 18:48:40 +0000 Subject: [PATCH 23/33] ros2: tighter testing - unit test for correctness of result --- open-codegen/test/test_ros2.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/open-codegen/test/test_ros2.py b/open-codegen/test/test_ros2.py index 6856b610..a415bb96 100644 --- a/open-codegen/test/test_ros2.py +++ b/open-codegen/test/test_ros2.py @@ -1,5 +1,6 @@ import logging import os +import re import shlex import signal import shutil @@ -402,6 +403,25 @@ def test_generated_ros2_package_works(self): self._terminate_process(echo_process) self.assertIn("solution", echo_stdout) + # A bit of integration testing: check whether the solver was able to + # solve the problem successfully + self.assertRegex( + echo_stdout, + r"solution:\s*\n(?:- .+\n)+", + msg=f"Expected a non-empty solution vector in result output:\n{echo_stdout}") + self.assertIn("status: 0", echo_stdout) + self.assertRegex( + echo_stdout, + r"inner_iterations:\s*[1-9]\d*", + msg=f"Expected a positive inner iteration count in result output:\n{echo_stdout}") + self.assertRegex( + echo_stdout, + r"outer_iterations:\s*[1-9]\d*", + msg=f"Expected a positive outer iteration count in result output:\n{echo_stdout}") + self.assertRegex( + echo_stdout, + r"cost:\s*-?\d+(?:\.\d+)?(?:e[+-]?\d+)?", + msg=f"Expected a numeric cost in result output:\n{echo_stdout}") self.assertIn("solve_time_ms", echo_stdout) finally: if node_process.poll() is None: From 3215c0ffadbccd5a0cc107f00dd852977f11de12 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 18:55:01 +0000 Subject: [PATCH 24/33] fix issue with ros2 rate - ros2 rate must be double, not int - update jinja2 template --- .../opengen/templates/ros2/open_params.yaml | 2 +- open-codegen/test/test_ros2.py | 217 ++++++++++-------- 2 files changed, 122 insertions(+), 97 deletions(-) diff --git a/open-codegen/opengen/templates/ros2/open_params.yaml b/open-codegen/opengen/templates/ros2/open_params.yaml index b1ae266e..adde7b45 100644 --- a/open-codegen/opengen/templates/ros2/open_params.yaml +++ b/open-codegen/opengen/templates/ros2/open_params.yaml @@ -2,4 +2,4 @@ ros__parameters: result_topic: "{{ros.publisher_subtopic}}" params_topic: "{{ros.subscriber_subtopic}}" - rate: {{ros.rate}} + rate: {{ "%.1f"|format(ros.rate) if ros.rate == (ros.rate|int) else ros.rate }} diff --git a/open-codegen/test/test_ros2.py b/open-codegen/test/test_ros2.py index a415bb96..af5617aa 100644 --- a/open-codegen/test/test_ros2.py +++ b/open-codegen/test/test_ros2.py @@ -304,35 +304,25 @@ def _terminate_process(process, timeout=10): stdout = "" return stdout or "" - def test_ros2_package_generation(self): - """Verify the ROS2 package files are generated.""" - ros2_dir = self.ros2_package_dir() - self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "package.xml"))) - self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "CMakeLists.txt"))) - self.assertTrue(os.path.isfile( - os.path.join(ros2_dir, "launch", "open_optimizer.launch.py"))) - - def test_generated_ros2_package_works(self): - """Build, run, and call the generated ROS2 package.""" - ros2_dir = self.ros2_package_dir() - env = self.ros2_test_env() - shell_path, setup_script = self.ros2_shell() + def _build_generated_package(self, ros2_dir, env): + """Build the generated ROS2 package with the active Python executable.""" python_executable = shlex.quote(sys.executable) - self._run_shell( - f"source {setup_script} >/dev/null 2>&1 || true; " + f"source {self.ros2_shell()[1]} >/dev/null 2>&1 || true; " f"colcon build --packages-select {self.PACKAGE_NAME} " f"--cmake-args -DPython3_EXECUTABLE={python_executable}", cwd=ros2_dir, env=env, timeout=600) - node_process = subprocess.Popen( + def _spawn_ros_process(self, command, ros2_dir, env): + """Start a long-running ROS2 command in a fresh process group.""" + shell_path, setup_script = self.ros2_shell() + return subprocess.Popen( [ shell_path, "-lc", - f"source {setup_script} && " - f"ros2 run {self.PACKAGE_NAME} {self.NODE_NAME}" + f"source {setup_script} && {command}" ], cwd=ros2_dir, env=env, @@ -341,92 +331,127 @@ def test_generated_ros2_package_works(self): stderr=subprocess.STDOUT, start_new_session=True) + def _wait_for_node_and_topics(self, ros2_dir, env): + """Wait until the generated ROS2 node and its topics become discoverable.""" + _, setup_script = self.ros2_shell() + node_result = None + topic_result = None + for _ in range(6): + node_result = self._run_shell( + f"source {setup_script} && " + "ros2 node list --no-daemon --spin-time 5", + cwd=ros2_dir, + env=env, + timeout=30, + check=False) + topic_result = self._run_shell( + f"source {setup_script} && " + "ros2 topic list --no-daemon --spin-time 5", + cwd=ros2_dir, + env=env, + timeout=30, + check=False) + node_seen = f"/{self.NODE_NAME}" in node_result.stdout + topics_seen = "/parameters" in topic_result.stdout and "/result" in topic_result.stdout + if node_seen and topics_seen: + return + time.sleep(1) + + self.fail( + "Generated ROS2 node did not become discoverable.\n" + f"ros2 node list output:\n{node_result.stdout if node_result else ''}\n" + f"ros2 topic list output:\n{topic_result.stdout if topic_result else ''}") + + def _assert_result_message(self, echo_stdout): + """Assert that the echoed result message indicates a successful solve.""" + self.assertIn("solution", echo_stdout) + # A bit of integration testing: check whether the solver was able to + # solve the problem successfully. + self.assertRegex( + echo_stdout, + r"solution:\s*\n(?:- .+\n)+", + msg=f"Expected a non-empty solution vector in result output:\n{echo_stdout}") + self.assertIn("status: 0", echo_stdout) + self.assertRegex( + echo_stdout, + r"inner_iterations:\s*[1-9]\d*", + msg=f"Expected a positive inner iteration count in result output:\n{echo_stdout}") + self.assertRegex( + echo_stdout, + r"outer_iterations:\s*[1-9]\d*", + msg=f"Expected a positive outer iteration count in result output:\n{echo_stdout}") + self.assertRegex( + echo_stdout, + r"cost:\s*-?\d+(?:\.\d+)?(?:e[+-]?\d+)?", + msg=f"Expected a numeric cost in result output:\n{echo_stdout}") + self.assertIn("solve_time_ms", echo_stdout) + + def _exercise_running_optimizer(self, ros2_dir, env): + """Publish one request and verify that one valid result message is returned.""" + _, setup_script = self.ros2_shell() + echo_process = self._spawn_ros_process("ros2 topic echo /result --once", ros2_dir, env) + try: - node_seen = False - topics_seen = False - for _ in range(6): - node_result = self._run_shell( - f"source {setup_script} && " - "ros2 node list --no-daemon --spin-time 5", - cwd=ros2_dir, - env=env, - timeout=30, - check=False) - topic_result = self._run_shell( - f"source {setup_script} && " - "ros2 topic list --no-daemon --spin-time 5", - cwd=ros2_dir, - env=env, - timeout=30, - check=False) - node_seen = f"/{self.NODE_NAME}" in node_result.stdout - topics_seen = "/parameters" in topic_result.stdout and "/result" in topic_result.stdout - if node_seen and topics_seen: - break - time.sleep(1) - - if not (node_seen and topics_seen): - process_output = self._terminate_process(node_process) - self.fail( - "Generated ROS2 node did not become discoverable.\n" - f"ros2 node list output:\n{node_result.stdout}\n" - f"ros2 topic list output:\n{topic_result.stdout}\n" - f"node process output:\n{process_output}") - - echo_process = subprocess.Popen( - [ - shell_path, - "-lc", - f"source {setup_script} && " - "ros2 topic echo /result --once" - ], + time.sleep(1) + self._run_shell( + f"source {setup_script} && " + "ros2 topic pub --once /parameters " + f"{self.PACKAGE_NAME}/msg/OptimizationParameters " + "'{parameter: [1.0, 2.0], initial_guess: [0.0, 0.0, 0.0, 0.0, 0.0], initial_y: [], initial_penalty: 15.0}'", cwd=ros2_dir, env=env, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - start_new_session=True) + timeout=60) + echo_stdout, _ = echo_process.communicate(timeout=60) + finally: + if echo_process.poll() is None: + self._terminate_process(echo_process) - try: - time.sleep(1) - self._run_shell( - f"source {setup_script} && " - "ros2 topic pub --once /parameters " - f"{self.PACKAGE_NAME}/msg/OptimizationParameters " - "'{parameter: [1.0, 2.0], initial_guess: [0.0, 0.0, 0.0, 0.0, 0.0], initial_y: [], initial_penalty: 15.0}'", - cwd=ros2_dir, - env=env, - timeout=60) - echo_stdout, _ = echo_process.communicate(timeout=60) - finally: - if echo_process.poll() is None: - self._terminate_process(echo_process) - - self.assertIn("solution", echo_stdout) - # A bit of integration testing: check whether the solver was able to - # solve the problem successfully - self.assertRegex( - echo_stdout, - r"solution:\s*\n(?:- .+\n)+", - msg=f"Expected a non-empty solution vector in result output:\n{echo_stdout}") - self.assertIn("status: 0", echo_stdout) - self.assertRegex( - echo_stdout, - r"inner_iterations:\s*[1-9]\d*", - msg=f"Expected a positive inner iteration count in result output:\n{echo_stdout}") - self.assertRegex( - echo_stdout, - r"outer_iterations:\s*[1-9]\d*", - msg=f"Expected a positive outer iteration count in result output:\n{echo_stdout}") - self.assertRegex( - echo_stdout, - r"cost:\s*-?\d+(?:\.\d+)?(?:e[+-]?\d+)?", - msg=f"Expected a numeric cost in result output:\n{echo_stdout}") - self.assertIn("solve_time_ms", echo_stdout) + self._assert_result_message(echo_stdout) + + def test_ros2_package_generation(self): + """Verify the ROS2 package files are generated.""" + ros2_dir = self.ros2_package_dir() + self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "package.xml"))) + self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "CMakeLists.txt"))) + self.assertTrue(os.path.isfile( + os.path.join(ros2_dir, "launch", "open_optimizer.launch.py"))) + + def test_generated_ros2_package_works(self): + """Build, run, and call the generated ROS2 package.""" + ros2_dir = self.ros2_package_dir() + env = self.ros2_test_env() + self._build_generated_package(ros2_dir, env) + + node_process = self._spawn_ros_process( + f"ros2 run {self.PACKAGE_NAME} {self.NODE_NAME}", + ros2_dir, + env) + + try: + self._wait_for_node_and_topics(ros2_dir, env) + self._exercise_running_optimizer(ros2_dir, env) finally: if node_process.poll() is None: self._terminate_process(node_process) + def test_generated_ros2_launch_file_works(self): + """Build the package, launch the node, and verify the launch file works.""" + ros2_dir = self.ros2_package_dir() + env = self.ros2_test_env() + self._build_generated_package(ros2_dir, env) + + launch_process = self._spawn_ros_process( + f"ros2 launch {self.PACKAGE_NAME} open_optimizer.launch.py", + ros2_dir, + env) + + try: + self._wait_for_node_and_topics(ros2_dir, env) + self._exercise_running_optimizer(ros2_dir, env) + finally: + if launch_process.poll() is None: + self._terminate_process(launch_process) + if __name__ == '__main__': logging.getLogger('retry').setLevel(logging.ERROR) From 66f633792306edc85e6e2d0aa8fdd41d9ff55684 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 18:56:34 +0000 Subject: [PATCH 25/33] update ros2 docs --- docs/python-ros2.mdx | 16 +++++----- open-codegen/opengen/templates/ros2/README.md | 29 +++++++++++++++++-- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/docs/python-ros2.mdx b/docs/python-ros2.mdx index 82ce1778..a5339462 100644 --- a/docs/python-ros2.mdx +++ b/docs/python-ros2.mdx @@ -35,7 +35,7 @@ In ROS2, these settings are stored using the ROS2 parameter-file format in `conf ros__parameters: result_topic: "result" params_topic: "parameters" - rate: 10 + rate: 10.0 ``` ## Code generation @@ -118,7 +118,7 @@ In a second terminal: source install/setup.bash ros2 topic pub --once /parameters parametric_optimizer_ros2/msg/OptimizationParameters \ "{parameter: [1.0, 2.0], initial_guess: [0.0, 0.0, 0.0, 0.0, 0.0], initial_y: [], initial_penalty: 15.0}" -ros2 topic echo /result +ros2 topic echo /result --once ``` @@ -128,7 +128,7 @@ ros2 topic echo /result source install/setup.zsh ros2 topic pub --once /parameters parametric_optimizer_ros2/msg/OptimizationParameters \ "{parameter: [1.0, 2.0], initial_guess: [0.0, 0.0, 0.0, 0.0, 0.0], initial_y: [], initial_penalty: 15.0}" -ros2 topic echo /result +ros2 topic echo /result --once ``` @@ -191,18 +191,18 @@ ros2 launch parametric_optimizer_ros2 open_optimizer.launch.py def generate_launch_description(): return LaunchDescription([ Node( - package="custom_parametric_optimizer_ros2", - executable="custom_open_node_ros2", - name="custom_open_node_ros2", + package="parametric_optimizer_ros2", + executable="open_node_ros2", + name="open_node_ros2", output="screen", parameters=[PathJoinSubstitution([ - FindPackageShare("custom_parametric_optimizer_ros2"), + FindPackageShare("parametric_optimizer_ros2"), "config", "open_params.yaml", ])], ) ]) - ``` + ``` The launch file starts the auto-generated node and loads its parameters from `config/open_params.yaml`, where you can adjust settings such as the input topic, output topic, and node rate. diff --git a/open-codegen/opengen/templates/ros2/README.md b/open-codegen/opengen/templates/ros2/README.md index b8e768cc..5862a1f0 100644 --- a/open-codegen/opengen/templates/ros2/README.md +++ b/open-codegen/opengen/templates/ros2/README.md @@ -10,7 +10,7 @@ From within the folder `{{ros.package_name}}`, compile with: ```bash colcon build --packages-select {{ros.package_name}} source install/setup.bash -# or source install/setup.zsh on MacOS +# or source install/setup.zsh if you are using zsh ``` If you want to activate logging (recommended), do @@ -33,6 +33,22 @@ source install/setup.bash ros2 run {{ros.package_name}} {{ros.node_name}} ``` +If ROS2 cannot write to its default log directory, set an explicit writable log +path: + +```bash +mkdir -p .ros_log +export ROS_LOG_DIR="$PWD/.ros_log" +``` + +If the node starts but does not appear in the ROS2 graph, try forcing Fast DDS +in both terminals before sourcing the generated workspace and running any +`ros2` commands: + +```bash +export RMW_IMPLEMENTATION=rmw_fastrtps_cpp +``` + In a second terminal, source the same environment and verify discovery: ```bash @@ -58,7 +74,7 @@ The result will be announced on the configured result topic (default: `/{{ros.publisher_subtopic}}`): ```bash -ros2 topic echo /{{ros.publisher_subtopic}} +ros2 topic echo /{{ros.publisher_subtopic}} --once ``` To get the optimal solution you can do: @@ -67,6 +83,15 @@ To get the optimal solution you can do: ros2 topic echo /{{ros.publisher_subtopic}} --field solution ``` +You can also start the node using the generated launch file: + +```bash +ros2 launch {{ros.package_name}} open_optimizer.launch.py +``` + +The launch file loads its runtime parameters from +[`config/open_params.yaml`](config/open_params.yaml). + ## Messages From 25868c7d24ab14008c3978875963fc9425c982fd Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Wed, 25 Mar 2026 21:44:19 +0000 Subject: [PATCH 26/33] [docit][ci skip] update website docs - update docs on ros2 - promote ros2 on main page - include robot icon from flaticon - lang support for msg in prism --- docs/python-ros2.mdx | 76 +++++++++++-------- website/src/css/custom.css | 75 +++++++++++++++++- website/src/pages/index.js | 58 ++++++++++++++ website/src/theme/prism-include-languages.js | 38 ++++++++++ website/static/img/ros2-robot.png | Bin 0 -> 14224 bytes 5 files changed, 214 insertions(+), 33 deletions(-) create mode 100644 website/src/theme/prism-include-languages.js create mode 100644 website/static/img/ros2-robot.png diff --git a/docs/python-ros2.mdx b/docs/python-ros2.mdx index a5339462..73a7b000 100644 --- a/docs/python-ros2.mdx +++ b/docs/python-ros2.mdx @@ -167,44 +167,58 @@ To read a single optimizer response, you can use: ros2 topic echo /result --once ``` -This subscribes to the result topic, prints one `OptimizationResult` message, and then exits. +This subscribes to the result topic, prints one `OptimizationResult` message, and then exits. +The above command will return a message that looks as follows -Instead of starting the node with `ros2 run`, you can also use the generated launch file: - -```bash -ros2 launch parametric_optimizer_ros2 open_optimizer.launch.py +```yaml +solution: +- 0.5352476095477849 +- 0.8028586510585609 +- 0.6747818561706652 +- 0.7747513439588263 +- 0.5131839675113338 +inner_iterations: 41 +outer_iterations: 6 +status: 0 +cost: 1.1656771801253916 +norm_fpr: 2.1973496274068953e-05 +penalty: 150000.0 +lagrange_multipliers: [] +infeasibility_f1: 0.0 +infeasibility_f2: 3.3074097972366455e-05 +solve_time_ms: 0.2175 ```
- See the launch file - -

The launch file is as follows

- - ```python - # file open_optimizer.launch.py - from launch import LaunchDescription - from launch.substitutions import PathJoinSubstitution - from launch_ros.actions import Node - from launch_ros.substitutions import FindPackageShare - - - def generate_launch_description(): - return LaunchDescription([ - Node( - package="parametric_optimizer_ros2", - executable="open_node_ros2", - name="open_node_ros2", - output="screen", - parameters=[PathJoinSubstitution([ - FindPackageShare("parametric_optimizer_ros2"), - "config", - "open_params.yaml", - ])], - ) - ]) + See the specification of `OptimizationResult` + ```msg + # Constants match the enumeration of status codes + uint8 STATUS_CONVERGED=0 + uint8 STATUS_NOT_CONVERGED_ITERATIONS=1 + uint8 STATUS_NOT_CONVERGED_OUT_OF_TIME=2 + uint8 STATUS_NOT_CONVERGED_COST=3 + uint8 STATUS_NOT_CONVERGED_FINITE_COMPUTATION=4 + + float64[] solution # solution + uint8 inner_iterations # number of inner iterations + uint16 outer_iterations # number of outer iterations + uint8 status # status code + float64 cost # cost value at solution + float64 norm_fpr # norm of FPR of last inner problem + float64 penalty # penalty value + float64[] lagrange_multipliers # vector of Lagrange multipliers + float64 infeasibility_f1 # infeasibility wrt F1 + float64 infeasibility_f2 # infeasibility wrt F2 + float64 solve_time_ms # solution time in ms ```
+Instead of starting the node with `ros2 run`, you can also use the generated launch file: + +```bash +ros2 launch parametric_optimizer_ros2 open_optimizer.launch.py +``` + The launch file starts the auto-generated node and loads its parameters from `config/open_params.yaml`, where you can adjust settings such as the input topic, output topic, and node rate. diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 09bce1f7..6e225c5e 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -197,7 +197,7 @@ body { .homeCodeBlock { background: var(--open-page-surface); border: 1px solid var(--open-page-border); - border-radius: 24px; + border-radius: 15px; box-shadow: var(--open-page-shadow); } @@ -368,6 +368,72 @@ body { width: 100%; } +.homeRos2Promo { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); + gap: 1.5rem; + align-items: center; + width: min(1100px, calc(100% - 2rem)); + margin: 0 auto; + padding: 2rem; + background: + linear-gradient(145deg, rgba(164, 62, 53, 0.88), rgba(141, 33, 183, 0.92)), + #843129; + border: 1px solid rgba(255, 224, 204, 0.2); + border-radius: 28px; + box-shadow: var(--open-page-shadow); +} + +.homeRos2Promo__content, +.homeRos2Promo__code { + min-width: 0; +} + +.homeRos2Promo__content h2 { + margin: 0 0 0.85rem; + color: #fff8f3; + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1.08; +} + +.homeRos2Promo__content p { + color: rgba(255, 248, 243, 0.88); +} + +.homeRos2Promo__robot { + display: block; + width: 200px; + height: 200px; + margin: 0 auto 1rem; +} + +.homeRos2Promo__attribution { + margin: -0.4rem 0 0.9rem; + text-align: center; + font-size: 0.68rem; + line-height: 1.25; +} + +.homeRos2Promo__attribution a { + color: rgba(255, 248, 243, 0.82); + text-decoration: none; +} + +.homeRos2Promo__attribution a:hover { + color: #fff8f3; + text-decoration: underline; +} + +.homeRos2Promo__codeBlock { + margin-top: 0; + background: rgba(255, 248, 243, 0.96); + border-color: rgba(255, 224, 204, 0.3); +} + +.homeRos2Promo__codeBlock .theme-code-block { + margin-bottom: 0; +} + .homeSplit__copy, .homeSplit__media { min-width: 0; @@ -617,7 +683,8 @@ body { max-width: none; } - .homeOcpPromo { + .homeOcpPromo, + .homeRos2Promo { grid-template-columns: 1fr; } } @@ -640,4 +707,8 @@ body { width: 64px; height: 64px; } + + .homeRos2Promo { + padding: 1.5rem; + } } diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 8a0fa34d..4fad6ce5 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -35,6 +35,15 @@ builder = og.builder.OpEnOptimizerBuilder( ) builder.build()`; +const ros2PromoCode = String.raw`ros2_config = og.config.RosConfiguration() \ + .with_package_name("my_ros_pkg") \ + .with_node_name("open_node_ros2") \ + .with_rate(10.0) + +build_config = og.config.BuildConfiguration() \ + .with_build_directory("my_optimizers") \ + .with_ros2(ros2_config)`; + const heroStats = [ {label: 'Core language', value: 'Rust'}, {label: 'Primary uses', value: 'MPC, MHE, Robotics'}, @@ -112,6 +121,7 @@ export default function Home() { const promoGif = assetUrl('img/open-promo.gif'); const boxLogo = assetUrl('img/box.png'); const ocpStatesImage = assetUrl('img/ocp-states.png'); + const ros2RobotImage = assetUrl('img/ros2-robot.png'); const [zoomedImage, setZoomedImage] = useState(null); useEffect(() => { @@ -352,6 +362,54 @@ export default function Home() { + +
+
+
+

New in opegen 0.11

+

ROS2 packages

+

+ OpEn can now generate ROS2 packages directly from a parametric + optimizer. The generated package includes ROS2 messages, + configuration files, a launch file, and a node that exposes the + solver through topics. +

+

+ This makes it easy to connect optimization-based controllers, + estimators, and planning modules into a modern robotics stack + without writing the ROS2 wrapper code by hand. +

+
+ + Learn more + + + Legacy ROS1 + +
+
+
+ Cartoon robot icon +

+ + Bot icons created by pbig - Flaticon + +

+
+ {ros2PromoCode} +
+
+
+
{zoomedImage ? (
{ + if (lang === 'php') { + require('prismjs/components/prism-markup-templating.js'); + } + require(`prismjs/components/prism-${lang}`); + }); + + registerMsgLanguage(PrismObject); + + delete globalThis.Prism; + if (typeof PrismBefore !== 'undefined') { + globalThis.Prism = PrismObject; + } +} diff --git a/website/static/img/ros2-robot.png b/website/static/img/ros2-robot.png new file mode 100644 index 0000000000000000000000000000000000000000..f5ec4f946a84a49a2f8f0c1b9bfc5dc667d9c129 GIT binary patch literal 14224 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 dY0BVdA8i*X_F6EY4LwZ Date: Wed, 25 Mar 2026 23:48:23 +0000 Subject: [PATCH 27/33] [ci skip] update contributing guidelines --- docs/contributing.mdx | 232 ++++++++++++++++++++++++++++++++++++++++++ docs/python-ros.md | 42 +++++--- 2 files changed, 258 insertions(+), 16 deletions(-) create mode 100644 docs/contributing.mdx diff --git a/docs/contributing.mdx b/docs/contributing.mdx new file mode 100644 index 00000000..d1584896 --- /dev/null +++ b/docs/contributing.mdx @@ -0,0 +1,232 @@ +--- +id: contributing +sidebar_label: Contributing +title: Contributing to OpEn +description: How do I contribute to OpEn +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## How can I contribute to OpEn? +Thank you for considering contributing to Optimization Engine (OpEn)! + +OpEn is an open source project and welcomes contributions from the community. + +You can contribute in several ways: + +- Submit an [**issue**](https://github.com/alphaville/optimization-engine/issues): + Often bugs will go unnoticed by the core development team and certain + use cases and user needs will have evaded our attention. + Consider submitting an issue if: + - You would like to report a [bug]; please, use the provided template for reporting + bugs. It is essential to give information about your system (OS, OpEn version) + and outline a sequence of steps to reproduce the error. When possible, please + provide a [minimum working example] + - You would like to request a [new feature]; please use the provided template + - You would like to propose modifications in OpEn's documentation, such as + for some concepts to be better elucidated or a request for an additional example +- Share with us a **success story** on [**Discord**](https://discord.gg/mfYpn4V) +- Create a **pull request** (see below) + +or, show us your love: + +- Give us a [**star on gitub**](https://github.com/alphaville/optimization-engine) +- Spread the word on [**Twitter**] + +![Star](https://media.giphy.com/media/ZxblqUVrPVmcqATkC4/giphy.gif) + +## I just have a question! +The easiest and quickest way to ask a question is to reach us on [**Discord**](https://discord.gg/mfYpn4V) or [**Gitter**](https://gitter.im/alphaville/optimization-engine). + +You may also consult the [**frequently asked questions**](/optimization-engine/docs/faq). + + +## Submitting issues +You may submit an issue regarding anything related to **OpEn**, such as: + +- a bug +- insufficient/vague documentation +- request for a feature +- request for an example + +You should, however, make sure that the same - or a very similar - issue is not already open. In that case, you may write a comment in an existing issue. + + +## Contributing code or docs + +In order to contribute code or documentation, you need to [fork] our github repository, make you modifications and submit a pull request. You should follow these rules: + +- create one or more [issues on github] that will be associated with your changes +- take it from `master`: fork OpEn and create a branch on `master` + +```console +git checkout -b fix/xyz master +``` + +- read the [style guide](#coding-style-guide) below (and write unit/integration tests) +- create a pull request in which you need to explain the key changes + +## Coding style guide + +Things to keep in mind: + +- **Code**: intuitive structure and variable names, short atomic functions, +- **Comments**: help others better understand your code +- **Docs**: document all functions (even private ones) +- **Tests**: write comprehnsive, exhaustive tests + +### Rust + +*General guidelines:* Read the Rust [API guidelines] and this [API checklist] + +*Naming convention:* We follow the [standard naming convention](https://rust-lang-nursery.github.io/api-guidelines/naming.html) of Rust. + +*Documentation:* We follow [these guidelines](https://rust-lang-nursery.github.io/api-guidelines/documentation.html). Everything should be documented. + +### Python + +We follow [this style guide](https://www.python.org/dev/peps/pep-0008) and its [naming convention](https://www.python.org/dev/peps/pep-0008/#naming-conventions) + + +### Website +This documentation is generated with Docusaurus - read a detailed guide [here](https://github.com/alphaville/optimization-engine/blob/master/website/README.md). + +- All docs are in `docs/` +- Blog entries are in `website/blog/` + +To start the website locally (at [http://localhost:3000/optimization-engine](http://localhost:3000/optimization-engine)) change directory to `website` and run `yarn start`. To update the website, execute `./publish.sh` (you need to be a collaborator on github). + +## Using Git +When using Git, keep in mind the following guidelines: + +- Create simple, atomic, commits +- Write comprehensive commit messages +- Work on a forked repository +- When you're done, submit a pull request to +[`alphaville/optimization-engine`](https://github.com/alphaville/optimization-engine/); +it will be promptly delegated to a reviewer and we will contact you +as soon as possible. + +Branch `master` is protected and all pull requests need to be reviewed by a person +other than their proposer before they can be merged into `master`. + +## Versioning +This project consists of independent modules: +(i) the core Rust library, +(ii) the MATLAB interface, +(iii) the Python interface. +Each module has a different version number (`X.Y.Z`). + +We use the **SemVer** standard - we quote from [semver.org](https://semver.org/): + +Given a version number `MAJOR.MINOR.PATCH`, increment the: + +- `MAJOR` version when you make incompatible API changes, +- `MINOR` version when you add functionality in a backwards-compatible manner, and +- `PATCH` version when you make backwards-compatible bug fixes. + +Additional labels for pre-release and build metadata are available as extensions to the `MAJOR.MINOR.PATCH` format. + +We also keep a [log of changes](https://github.com/alphaville/optimization-engine/blob/master/CHANGELOG.md) where we summarize the main changes since last version. + +## Releasing + +Each time the major or minor number of the Rust library is updated, a new crate should be published on [crates.io](https://crates.io/crates/optimization_engine). + +In order to release a new version make sure that +you have done the following: + + + + +Checklist: + +
    +
  • Updated [CHANGELOG]: bump version, write summary of changes
  • +
  • Updated [Cargo.toml]: bump version
  • +
  • Resolve all associated issues on GitHub
  • +
  • Write new unit tests if necessary
  • +
  • Update the API documentation
  • +
  • Update the information on the website
  • +
  • Merge into master once your pull request has been approved
  • +
+ +Then, create a tag and push it... + + ```bash + git tag -a v0.10.0 -m "v0.10.0" + git push --tags + ``` + +Lastly, update the [docker image](https://github.com/alphaville/optimization-engine/tree/master/docker). +This will have to be a new PR. + +
+ + + + +Checklist: + +
    +
  • Updated [CHANGELOG](https://github.com/alphaville/optimization-engine/blob/master/open-codegen/CHANGELOG.md): bump version, write summary of changes
  • +
  • Updated [VERSION]: bump version
  • +
  • Review [`pyproject.toml`](https://github.com/alphaville/optimization-engine/blob/master/open-codegen/pyproject.toml)
  • +
  • Resolve all associated issues on GitHub
  • +
  • Write new unit tests if necessary
  • +
  • Update the API documentation
  • +
  • Update the information on the website
  • +
  • Merge into master once your pull request has been approved
  • +
+ +Then, create a tag and push it... + + ```bash + git tag -a opengen-v0.10.0 -m "opengen-0.10.0" + git push --tags + ``` + + + +Lastly, update the [docker image](https://github.com/alphaville/optimization-engine/tree/master/docker). +This will have to be a new PR. + +
+ + + + + Update the [Dockerfile](https://github.com/alphaville/optimization-engine/blob/master/docker/Dockerfile). + You may need to bump the versions of open and opengen: + + ```Dockerfile + ARG OPENGEN_VERSION=0.10.0 + ARG OPTIMIZATION_ENGINE_CRATE_VERSION=0.11.0 + ``` + + Update the [CHANGELOG](https://github.com/alphaville/optimization-engine/blob/master/docker/CHANGELOG.md). + Update the [README](https://github.com/alphaville/optimization-engine/blob/master/docker/README.md) file. + Build, test, and push with + + ```bash + docker push alphaville/open:0.7.0 + ``` + + +
+ + +[CHANGELOG]: https://github.com/alphaville/optimization-engine/blob/master/CHANGELOG.md +[VERSION]: https://github.com/alphaville/optimization-engine/blob/master/open-codegen/VERSION +[Cargo.toml]: https://github.com/alphaville/optimization-engine/blob/master/Cargo.toml +[setup.py]: https://github.com/alphaville/optimization-engine/blob/master/open-codegen/setup.py +[release v0.4.0]: https://github.com/alphaville/optimization-engine/releases/tag/v0.4.0 +[bug]: https://github.com/alphaville/optimization-engine/issues/new?template=bug_report.md +[issues on github]: https://github.com/alphaville/optimization-engine/issues +[**Twitter**]: https://twitter.com/intent/tweet?original_referer=https%3A%2F%2Falphaville.github.io%2Foptimization-engine&ref_src=twsrc%5Etfw&text=Fast%20and%20accurate%20embedded%20nonconvex%20optimization%20with%20%23OptimizationEngine&tw_p=tweetbutton&url=https%3A%2F%2Falphaville.github.io%2Foptimization-engine&via=isToxic +[minimum working example]: https://en.wikipedia.org/wiki/Minimal_working_example +[new feature]: https://github.com/alphaville/optimization-engine/issues/new?template=feature_request.md +[fork]: https://github.com/alphaville/optimization-engine +[API guidelines]: https://rust-lang-nursery.github.io/api-guidelines/about.html +[API checklist]: https://rust-lang-nursery.github.io/api-guidelines/checklist.html diff --git a/docs/python-ros.md b/docs/python-ros.md index 43a7f4d8..b594e9d2 100644 --- a/docs/python-ros.md +++ b/docs/python-ros.md @@ -5,6 +5,10 @@ sidebar_label: ROS packages description: Code generation for ROS packages using opengen --- +:::note Info +Opengen now supports [ROS2](./python-ros2). +::: + ## What is ROS The [Robot Operating System](https://www.ros.org/) (ROS) is a collection of tools and libraries, as well as a framework that facilitates the data exchange among them. ROS is popular in the robotics community and is used to design and operate modern robotic systems. @@ -21,7 +25,7 @@ OpEn (with opengen version `0.5.0` or newer) can generate ready-to-use ROS packa The input parameters message follows the following specification: -``` +```msg float64[] parameter # parameter p (mandatory) float64[] initial_guess # u0 (optional/recommended) float64[] initial_y # y0 (optional) @@ -40,9 +44,28 @@ initial_y: [] #### Result -A result message (`OptimizationResult`) contains the solution of the parametric optimization problem and details about the solution procedure such as the number of inner/outer iterations and the solution time. The result of an auto-generated OpEn node is a message with the following specification: +A result message (`OptimizationResult`) contains the solution of the parametric optimization problem and details about the solution procedure such as the number of inner/outer iterations and the solution time. +An example of such a message is given below: +```yaml +solution: [0.5317, 0.7975, 0.6761, 0.7760, 0.5214] +inner_iterations: 159 +outer_iterations: 5 +status: 0 +norm_fpr: 2.142283848e-06 +penalty: 111250.0 +lagrange_multipliers: [] +infeasibility_f1: 0.0 +infeasibility_f2: 2.44131958366e-05 +solve_time_ms: 2.665959 ``` + +
+Specification of OptimizationResult + +The message `OptimizationResult` is described by the following message file + +```msg # Constants match the enumeration of status codes uint8 STATUS_CONVERGED=0 uint8 STATUS_NOT_CONVERGED_ITERATIONS=1 @@ -63,20 +86,7 @@ float64 infeasibility_f2 # infeasibility wrt F2 float64 solve_time_ms # solution time in ms ``` -An example of such a message is given below: - -```yaml -solution: [0.5317, 0.7975, 0.6761, 0.7760, 0.5214] -inner_iterations: 159 -outer_iterations: 5 -status: 0 -norm_fpr: 2.142283848e-06 -penalty: 111250.0 -lagrange_multipliers: [] -infeasibility_f1: 0.0 -infeasibility_f2: 2.44131958366e-05 -solve_time_ms: 2.665959 -``` +
### Configuration Parameters From b92fda9598b8661ed63ca1883d430e4a0f256caa Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Thu, 26 Mar 2026 00:13:48 +0000 Subject: [PATCH 28/33] [ci skip] contributing.md + link to open_ros --- docs/contributing.md | 164 ------------------------------------------- docs/python-ros2.mdx | 2 +- 2 files changed, 1 insertion(+), 165 deletions(-) delete mode 100644 docs/contributing.md diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index dcae4bae..00000000 --- a/docs/contributing.md +++ /dev/null @@ -1,164 +0,0 @@ ---- -id: contributing -sidebar_label: Contributing -title: Contributing to OpEn -description: How do I contribute to OpEn ---- - -## How can I contribute to OpEn? -Thank you for considering contributing to Optimization Engine (OpEn)! - -OpEn is an open source project and welcomes contributions from the community. - -You can contribute in several ways: - -- Submit an [**issue**](https://github.com/alphaville/optimization-engine/issues): - Often bugs will go unnoticed by the core development team and certain - use cases and user needs will have evaded our attention. - Consider submitting an issue if: - - You would like to report a [bug]; please, use the provided template for reporting - bugs. It is essential to give information about your system (OS, OpEn version) - and outline a sequence of steps to reproduce the error. When possible, please - provide a [minimum working example] - - You would like to request a [new feature]; please use the provided template - - You would like to propose modifications in OpEn's documentation, such as - for some concepts to be better elucidated or a request for an additional example -- Share with us a **success story** on [**Discord**](https://discord.gg/mfYpn4V) -- Create a **pull request** (see below) - -or, show us your love: - -- Give us a [**star on gitub**](https://github.com/alphaville/optimization-engine) -- Spread the word on [**Twitter**] - -![Star](https://media.giphy.com/media/ZxblqUVrPVmcqATkC4/giphy.gif) - -## I just have a question! -The easiest and quickest way to ask a question is to reach us on [**Discord**](https://discord.gg/mfYpn4V) or [**Gitter**](https://gitter.im/alphaville/optimization-engine). - -You may also consult the [**frequently asked questions**](/optimization-engine/docs/faq). - - -## Submitting issues -You may submit an issue regarding anything related to **OpEn**, such as: - -- a bug -- insufficient/vague documentation -- request for a feature -- request for an example - -You should, however, make sure that the same - or a very similar - issue is not already open. In that case, you may write a comment in an existing issue. - - -## Contributing code or docs - -In order to contribute code or documentation, you need to [fork] our github repository, make you modifications and submit a pull request. You should follow these rules: - -- create one or more [issues on github] that will be associated with your changes -- take it from `master`: fork OpEn and create a branch on `master` - -```console -git checkout -b fix/xyz master -``` - -- read the [style guide](#coding-style-guide) below (and write unit/integration tests) -- create a pull request in which you need to explain the key changes - -## Coding style guide - -Things to keep in mind: - -- **Code**: intuitive structure and variable names, short atomic functions, -- **Comments**: help others better understand your code -- **Docs**: document all functions (even private ones) -- **Tests**: write comprehnsive, exhaustive tests - -### Rust - -*General guidelines:* Read the Rust [API guidelines] and this [API checklist] - -*Naming convention:* We follow the [standard naming convention](https://rust-lang-nursery.github.io/api-guidelines/naming.html) of Rust. - -*Documentation:* We follow [these guidelines](https://rust-lang-nursery.github.io/api-guidelines/documentation.html). Everything should be documented. - -### Python - -We follow [this style guide](https://www.python.org/dev/peps/pep-0008) and its [naming convention](https://www.python.org/dev/peps/pep-0008/#naming-conventions) - - -### Website -This documentation is generated with Docusaurus - read a detailed guide [here](https://github.com/alphaville/optimization-engine/blob/master/website/README.md). - -- All docs are in `docs/` -- Blog entries are in `website/blog/` - -To start the website locally (at [http://localhost:3000/optimization-engine](http://localhost:3000/optimization-engine)) change directory to `website` and run `yarn start`. To update the website, execute `./publish.sh` (you need to be a collaborator on github). - -## Using Git -When using Git, keep in mind the following guidelines: - -- Create simple, atomic, commits -- Write comprehensive commit messages -- Work on a forked repository -- When you're done, submit a pull request to -[`alphaville/optimization-engine`](https://github.com/alphaville/optimization-engine/); -it will be promptly delegated to a reviewer and we will contact you -as soon as possible. - -Branch `master` is protected and all pull requests need to be reviewed by a person -other than their proposer before they can be merged into `master`. - -## Versioning -This project consists of independent modules: -(i) the core Rust library, -(ii) the MATLAB interface, -(iii) the Python interface. -Each module has a different version number (`X.Y.Z`). - -We use the **SemVer** standard - we quote from [semver.org](https://semver.org/): - -Given a version number `MAJOR.MINOR.PATCH`, increment the: - -- `MAJOR` version when you make incompatible API changes, -- `MINOR` version when you add functionality in a backwards-compatible manner, and -- `PATCH` version when you make backwards-compatible bug fixes. - -Additional labels for pre-release and build metadata are available as extensions to the `MAJOR.MINOR.PATCH` format. - -We also keep a [log of changes](https://github.com/alphaville/optimization-engine/blob/master/CHANGELOG.md) where we summarize the main changes since last version. - -## Releasing - -Each time the major or minor number of the Rust library is updated, a new crate should be published on [crates.io](https://crates.io/crates/optimization_engine). - -In order to release a new version make sure that -you have done the following: - -- Updated [CHANGELOG] -- Updated the version in (SemVer): - - [CHANGELOG] - - [Cargo.toml] - - [setup.py] -- Resolved all associated issues on github (and you have created tests for these) -- Updated the documentation (Rust/Python API docs + website) -- Merged into master (your pull request has been approved) -- All tests pass on Travis CI and Appveyor -- Set `publish=true` in `Cargo.toml` (set it back to `false` for safety) -- Publish `opengen` on PyPI (if necessary) - - before doing so, make sure that the cargo.toml template - points to the correct version of OpEn -- Changed "Unreleased" into the right version in [CHANGELOG] and created - a release on github (example [release v0.4.0]) - -[CHANGELOG]: https://github.com/alphaville/optimization-engine/blob/master/CHANGELOG.md -[Cargo.toml]: https://github.com/alphaville/optimization-engine/blob/master/Cargo.toml -[setup.py]: https://github.com/alphaville/optimization-engine/blob/master/open-codegen/setup.py -[release v0.4.0]: https://github.com/alphaville/optimization-engine/releases/tag/v0.4.0 -[bug]: https://github.com/alphaville/optimization-engine/issues/new?template=bug_report.md -[issues on github]: https://github.com/alphaville/optimization-engine/issues -[**Twitter**]: https://twitter.com/intent/tweet?original_referer=https%3A%2F%2Falphaville.github.io%2Foptimization-engine&ref_src=twsrc%5Etfw&text=Fast%20and%20accurate%20embedded%20nonconvex%20optimization%20with%20%23OptimizationEngine&tw_p=tweetbutton&url=https%3A%2F%2Falphaville.github.io%2Foptimization-engine&via=isToxic -[minimum working example]: https://en.wikipedia.org/wiki/Minimal_working_example -[new feature]: https://github.com/alphaville/optimization-engine/issues/new?template=feature_request.md -[fork]: https://github.com/alphaville/optimization-engine -[API guidelines]: https://rust-lang-nursery.github.io/api-guidelines/about.html -[API checklist]: https://rust-lang-nursery.github.io/api-guidelines/checklist.html diff --git a/docs/python-ros2.mdx b/docs/python-ros2.mdx index 73a7b000..a81bff42 100644 --- a/docs/python-ros2.mdx +++ b/docs/python-ros2.mdx @@ -75,7 +75,7 @@ Note the use of `with_ros2` and note that `RosConfiguration` is the same config class as in [ROS1](./python-ros). This generates the optimizer in `my_optimizers/rosenbrock_ros2`, and the ROS2 package is created inside that directory as `parametric_optimizer_ros2`. - +You can inspect the auto-generated ROS2 package [here](https://github.com/alphaville/open_ros/tree/master/ros2). ## Use the auto-generated ROS2 package From a19f33347e83ec39c6769299eb51c513c65c2b47 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Thu, 26 Mar 2026 00:27:03 +0000 Subject: [PATCH 29/33] [ci skip] update website docs - update main page: promote docker - python-c.mdx: fix typo --- docs/python-c.mdx | 2 +- website/src/css/custom.css | 42 ++++++++++++++++++++++++++++++++++++- website/src/pages/index.js | 43 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/docs/python-c.mdx b/docs/python-c.mdx index bbe387e8..721b5bf9 100644 --- a/docs/python-c.mdx +++ b/docs/python-c.mdx @@ -270,4 +270,4 @@ iterations = 69 outer iterations = 5 solve time = 0.140401 ms ``` -``` + diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 6e225c5e..94344066 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -434,6 +434,45 @@ body { margin-bottom: 0; } +.homeDockerPromo { + display: grid; + grid-template-columns: minmax(0, 1.02fr) minmax(320px, 0.98fr); + gap: 1.5rem; + align-items: center; + width: min(1100px, calc(100% - 2rem)); + margin: 0 auto; +} + +.homeDockerPromo__content, +.homeDockerPromo__visual { + min-width: 0; +} + +.homeDockerPromo__content h2 { + margin: 0 0 0.85rem; + color: #2f1a14; + font-size: clamp(2rem, 4vw, 3rem); + line-height: 1.08; +} + +.homeDockerPromo__content p { + color: var(--open-page-muted); +} + +.homeDockerPromo__image { + display: block; + width: min(100%, 280px); + margin: 0 auto 1rem; +} + +.homeDockerPromo__codeBlock { + margin-top: 0; +} + +.homeDockerPromo__codeBlock .theme-code-block { + margin-bottom: 0; +} + .homeSplit__copy, .homeSplit__media { min-width: 0; @@ -684,7 +723,8 @@ body { } .homeOcpPromo, - .homeRos2Promo { + .homeRos2Promo, + .homeDockerPromo { grid-template-columns: 1fr; } } diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 4fad6ce5..fb458b1e 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -44,6 +44,8 @@ build_config = og.config.BuildConfiguration() \ .with_build_directory("my_optimizers") \ .with_ros2(ros2_config)`; +const dockerPromoCode = String.raw`docker pull alphaville/open:0.7.0` + const heroStats = [ {label: 'Core language', value: 'Rust'}, {label: 'Primary uses', value: 'MPC, MHE, Robotics'}, @@ -120,6 +122,7 @@ export default function Home() { const assetUrl = (path) => `${baseUrl}${path.replace(/^\//, '')}`; const promoGif = assetUrl('img/open-promo.gif'); const boxLogo = assetUrl('img/box.png'); + const dockerGif = assetUrl('img/docker.gif'); const ocpStatesImage = assetUrl('img/ocp-states.png'); const ros2RobotImage = assetUrl('img/ros2-robot.png'); const [zoomedImage, setZoomedImage] = useState(null); @@ -410,6 +413,46 @@ export default function Home() {
+ +
+
+
+

Docker image

+

Run OpEn in a ready-made container

+

+ OpEn ships with a Docker image that gets you straight into a + working environment with Jupyter, Python, and the tooling needed + to explore examples without local setup friction. +

+

+ It is a convenient way to try the Python interface, browse the + notebooks, and experiment with the OCP workflows in a clean, + reproducible environment. +

+
+ + Learn more + + + Docker Hub + +
+
+
+ OpEn running inside the Docker image with Jupyter +
+ {dockerPromoCode} +
+
+
+
{zoomedImage ? (
Date: Thu, 26 Mar 2026 00:31:58 +0000 Subject: [PATCH 30/33] [docit] build api docs From 7bc3bc9d83a10e111b5685e8a61ec72416187484 Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Thu, 26 Mar 2026 03:17:05 +0000 Subject: [PATCH 31/33] [ci skip] website: make mobile-friendly - fix issue with sidebar - modify colours --- docs/contributing.mdx | 25 ++++++++++++++++++++++++- website/src/css/custom.css | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/contributing.mdx b/docs/contributing.mdx index d1584896..25cf7cee 100644 --- a/docs/contributing.mdx +++ b/docs/contributing.mdx @@ -137,6 +137,8 @@ Each time the major or minor number of the Rust library is updated, a new crate In order to release a new version make sure that you have done the following: +--- + @@ -178,12 +180,13 @@ Checklist:
  • Update the API documentation
  • Update the information on the website
  • Merge into master once your pull request has been approved
  • +
  • Update the API docs
  • Then, create a tag and push it... ```bash - git tag -a opengen-v0.10.0 -m "opengen-0.10.0" + git tag -a opengen-0.10.0 -m "opengen-0.10.0" git push --tags ``` @@ -213,9 +216,29 @@ This will have to be a new PR. docker push alphaville/open:0.7.0 ``` + Update the [website docs](./docker) and the promo on the [main page](..) +
    +--- + +To update the website, run +```bash +GIT_USER=alphaville \ + CURRENT_BRANCH=master \ + USE_SSH=true \ + yarn deploy +``` +from within `website/`. Then, update the opengen API docs too; +just push a commit with message starting with `[docit]`. +You can also issue a commit without git-add. Run + +```bash +git commit -m '[docit] update api docs' --allow-empty +``` + + [CHANGELOG]: https://github.com/alphaville/optimization-engine/blob/master/CHANGELOG.md [VERSION]: https://github.com/alphaville/optimization-engine/blob/master/open-codegen/VERSION diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 94344066..568b4152 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -32,7 +32,6 @@ body { } .navbar { - backdrop-filter: blur(16px); box-shadow: 0 10px 30px rgba(86, 44, 28, 0.12); } @@ -729,6 +728,40 @@ body { } } +@media (min-width: 997px) { + .navbar { + backdrop-filter: blur(16px); + } +} + +@media (max-width: 996px) { + .navbar-sidebar, + .navbar-sidebar__items, + .navbar-sidebar__item.menu { + background: #f8eee7; + } + + .navbar-sidebar__brand, + .navbar-sidebar__back, + .navbar-sidebar__close, + .navbar-sidebar .menu__link, + .navbar-sidebar .menu__caret, + .navbar-sidebar .menu__link--sublist::after { + color: #221714; + } + + .navbar-sidebar .menu__link { + font-weight: 500; + } + + .navbar-sidebar .menu__link:hover, + .navbar-sidebar .menu__link--active, + .navbar-sidebar .menu__list-item-collapsible:hover { + background: rgba(122, 31, 31, 0.08); + color: #221714; + } +} + @media (max-width: 640px) { .homeHero { padding-top: 2rem; From 6807d1dbda4cfe7b5c252a19b6d98a43c012f82f Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Thu, 26 Mar 2026 03:19:04 +0000 Subject: [PATCH 32/33] [docit] update api docs From a99374365c29febf3931ac035968c64bee555c0b Mon Sep 17 00:00:00 2001 From: Pantelis Sopasakis Date: Thu, 26 Mar 2026 15:39:13 +0000 Subject: [PATCH 33/33] addressing #403 --- .github/workflows/ci.yml | 6 ++++++ open-codegen/test/test_ros2.py | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44e55725..f91661b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,12 @@ jobs: python-version: "3.12" - name: Setup ROS 2 + # `ros-tooling/setup-ros@v0.7` still runs as a Node.js 20 action. + # Force it onto Node 24 now so CI keeps working as GitHub deprecates + # Node 20, and upgrade `setup-ros` to a Node 24-compatible release + # when one becomes available. + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true uses: ros-tooling/setup-ros@v0.7 with: required-ros-distributions: jazzy diff --git a/open-codegen/test/test_ros2.py b/open-codegen/test/test_ros2.py index af5617aa..9390ac8f 100644 --- a/open-codegen/test/test_ros2.py +++ b/open-codegen/test/test_ros2.py @@ -125,11 +125,16 @@ def test_custom_ros2_configuration_is_rendered_into_generated_files(self): """Custom ROS2 config values should appear in the generated package files.""" ros2_dir = self.ros2_package_dir() + # The package metadata should reflect the user-provided ROS2 package name + # and description, not the defaults from the templates. with open(os.path.join(ros2_dir, "package.xml"), encoding="utf-8") as f: package_xml = f.read() self.assertIn(f"{self.PACKAGE_NAME}", package_xml) self.assertIn(f"{self.DESCRIPTION}", package_xml) + # `open_optimizer.hpp` is where the generated node constants are wired in. + # These assertions make sure the custom topic names, node name, rate, and + # queue sizes are propagated into the generated C++ code. with open(os.path.join(ros2_dir, "include", "open_optimizer.hpp"), encoding="utf-8") as f: optimizer_header = f.read() self.assertIn(f'#define ROS2_NODE_{self.OPTIMIZER_NAME.upper()}_NODE_NAME "{self.NODE_NAME}"', @@ -147,12 +152,16 @@ def test_custom_ros2_configuration_is_rendered_into_generated_files(self): f"#define ROS2_NODE_{self.OPTIMIZER_NAME.upper()}_PARAMS_TOPIC_QUEUE_SIZE {self.PARAMS_QUEUE_SIZE}", optimizer_header) + # The runtime YAML configuration should carry the custom topic names and + # timer rate so the launched node uses the intended ROS2 parameters. with open(os.path.join(ros2_dir, "config", "open_params.yaml"), encoding="utf-8") as f: params_yaml = f.read() self.assertIn(f'result_topic: "{self.RESULT_TOPIC}"', params_yaml) self.assertIn(f'params_topic: "{self.PARAMS_TOPIC}"', params_yaml) self.assertIn(f"rate: {self.RATE}", params_yaml) + # The generated launch file should point to the correct package and + # executable so `ros2 launch` can start the generated node. with open(os.path.join(ros2_dir, "launch", "open_optimizer.launch.py"), encoding="utf-8") as f: launch_file = f.read() self.assertIn(f'package="{self.PACKAGE_NAME}"', launch_file) @@ -244,7 +253,11 @@ def ros2_test_env(cls): env = os.environ.copy() ros2_dir = cls.ros2_package_dir() os.makedirs(os.path.join(ros2_dir, ".ros_log"), exist_ok=True) + # Keep ROS2 logs inside the generated package directory so the tests do + # not depend on a global writable log location. env["ROS_LOG_DIR"] = os.path.join(ros2_dir, ".ros_log") + # Fast DDS is the most reliable middleware choice in our CI/local test + # setup when checking node discovery from separate processes. env.setdefault("RMW_IMPLEMENTATION", "rmw_fastrtps_cpp") env.pop("ROS_LOCALHOST_ONLY", None) return env @@ -337,6 +350,9 @@ def _wait_for_node_and_topics(self, ros2_dir, env): node_result = None topic_result = None for _ in range(6): + # `ros2 node list` confirms that the process joined the ROS graph, + # while `ros2 topic list` confirms that the expected interfaces are + # actually being advertised. node_result = self._run_shell( f"source {setup_script} && " "ros2 node list --no-daemon --spin-time 5", @@ -364,13 +380,15 @@ def _wait_for_node_and_topics(self, ros2_dir, env): def _assert_result_message(self, echo_stdout): """Assert that the echoed result message indicates a successful solve.""" + # We do not compare the full numeric solution here; instead, we check + # that the generated node returned a structurally valid result and that + # the solver reported convergence. self.assertIn("solution", echo_stdout) - # A bit of integration testing: check whether the solver was able to - # solve the problem successfully. self.assertRegex( echo_stdout, r"solution:\s*\n(?:- .+\n)+", msg=f"Expected a non-empty solution vector in result output:\n{echo_stdout}") + # `status: 0` matches `STATUS_CONVERGED` in the generated result message. self.assertIn("status: 0", echo_stdout) self.assertRegex( echo_stdout, @@ -389,10 +407,12 @@ def _assert_result_message(self, echo_stdout): def _exercise_running_optimizer(self, ros2_dir, env): """Publish one request and verify that one valid result message is returned.""" _, setup_script = self.ros2_shell() + # Start listening before publishing so the single response is not missed. echo_process = self._spawn_ros_process("ros2 topic echo /result --once", ros2_dir, env) try: time.sleep(1) + # Send one concrete request through the generated ROS2 interface. self._run_shell( f"source {setup_script} && " "ros2 topic pub --once /parameters " @@ -411,6 +431,8 @@ def _exercise_running_optimizer(self, ros2_dir, env): def test_ros2_package_generation(self): """Verify the ROS2 package files are generated.""" ros2_dir = self.ros2_package_dir() + # This is a lightweight smoke test for the generator itself before we + # attempt the slower build/run integration tests below. self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "package.xml"))) self.assertTrue(os.path.isfile(os.path.join(ros2_dir, "CMakeLists.txt"))) self.assertTrue(os.path.isfile( @@ -420,6 +442,9 @@ def test_generated_ros2_package_works(self): """Build, run, and call the generated ROS2 package.""" ros2_dir = self.ros2_package_dir() env = self.ros2_test_env() + + # First validate the plain `ros2 run` path, which exercises the + # generated executable directly without going through the launch file. self._build_generated_package(ros2_dir, env) node_process = self._spawn_ros_process( @@ -438,6 +463,9 @@ def test_generated_ros2_launch_file_works(self): """Build the package, launch the node, and verify the launch file works.""" ros2_dir = self.ros2_package_dir() env = self.ros2_test_env() + + # Then validate the generated launch description, which should bring up + # the exact same node and parameters via `ros2 launch`. self._build_generated_package(ros2_dir, env) launch_process = self._spawn_ros_process(