diff --git a/src/hangar_sim/CMakeLists.txt b/src/hangar_sim/CMakeLists.txt index 694e4a4dc..cac8f97ce 100644 --- a/src/hangar_sim/CMakeLists.txt +++ b/src/hangar_sim/CMakeLists.txt @@ -35,6 +35,9 @@ if(BUILD_TESTING) find_package(ament_cmake_pytest REQUIRED) find_package(ament_lint_auto REQUIRED) ament_lint_auto_find_test_dependencies() + ament_add_pytest_test( + nav2_params_distro_parity_test test/nav2_params_distro_parity_test.py + TIMEOUT 60) # Redirect ROS node logs into the test_results tree so the test-results CI # artifact ships them back too. Default would be ~/.ros/log/, which lives # on the doomed container filesystem and never gets uploaded -- making diff --git a/src/hangar_sim/launch/sim/robot_drivers_to_persist_sim.launch.py b/src/hangar_sim/launch/sim/robot_drivers_to_persist_sim.launch.py index 5073d6fe8..20321dd43 100644 --- a/src/hangar_sim/launch/sim/robot_drivers_to_persist_sim.launch.py +++ b/src/hangar_sim/launch/sim/robot_drivers_to_persist_sim.launch.py @@ -35,6 +35,7 @@ DeclareLaunchArgument, GroupAction, IncludeLaunchDescription, + LogInfo, SetEnvironmentVariable, ) from launch.conditions import IfCondition @@ -142,9 +143,39 @@ def generate_launch_description(): description="Use simulation clock if true", ) + # Nav2's pluginlib lookup names differ by distro: Humble exports `pkg/Class`, + # Jazzy exports `pkg::Class`, and the two are mutually exclusive. Jazzy's + # bt_navigator also loads its default BT plugin libraries implicitly, so + # listing them again double-registers every node. Each distro therefore gets + # its own parameter file. This is the only place the choice is made: every + # nav2 consumer below -- the slam, localization and navigation includes, and + # the nav2_container node -- takes it from the `params_file` argument. + # Anything that is not Jazzy gets the Humble file, which is correct for the + # two distros the 9.4 line publishes; a future distro must be added here + # explicitly rather than inheriting the Humble form by default. + ros_distro = os.environ.get("ROS_DISTRO", "") + nav2_params_file = ( + "nav2_params_jazzy.yaml" if ros_distro == "jazzy" else "nav2_params.yaml" + ) + # The wrong branch reproduces the bug this selection exists to avoid, and the + # only symptom is nav2_container aborting, so name the choice in the log. + nav2_params_log = LogInfo( + msg=( + [f"nav2 params (ROS_DISTRO={ros_distro}): ", params_file] + if ros_distro in ("humble", "jazzy") + else [ + f"WARNING: ROS_DISTRO={ros_distro or 'unset'} is not a distro " + f"hangar_sim ships nav2 parameters for; defaulting to " + f"{nav2_params_file}, which is only correct for Humble. " + "nav2 params: ", + params_file, + ] + ) + ) + declare_params_file_cmd = DeclareLaunchArgument( "params_file", - default_value=os.path.join(config_dir, "params", "nav2_params.yaml"), + default_value=os.path.join(config_dir, "params", nav2_params_file), description="Full path to the ROS2 parameters file to use for all launched nodes", ) @@ -371,6 +402,7 @@ def generate_launch_description(): ld.add_action(declare_map_yaml_cmd) ld.add_action(declare_use_sim_time_cmd) ld.add_action(declare_params_file_cmd) + ld.add_action(nav2_params_log) ld.add_action(declare_autostart_cmd) ld.add_action(declare_use_composition_cmd) ld.add_action(declare_use_respawn_cmd) diff --git a/src/hangar_sim/objectives/navigate_to_clicked_point_with_replanning.xml b/src/hangar_sim/objectives/navigate_to_clicked_point_with_replanning.xml index bc2a45169..05a0632ca 100644 --- a/src/hangar_sim/objectives/navigate_to_clicked_point_with_replanning.xml +++ b/src/hangar_sim/objectives/navigate_to_clicked_point_with_replanning.xml @@ -27,10 +27,15 @@ user_pose="{goal}" view_name="Visualization" /> + moveit_pro_sam2 ament_lint_auto + ament_cmake_pytest + python3-pytest + python3-yaml ament_clang_format ament_clang_tidy diff --git a/src/hangar_sim/params/nav2_params_jazzy.yaml b/src/hangar_sim/params/nav2_params_jazzy.yaml new file mode 100644 index 000000000..66c8944dd --- /dev/null +++ b/src/hangar_sim/params/nav2_params_jazzy.yaml @@ -0,0 +1,480 @@ +# Nav2 parameters for hangar_sim on ROS 2 Jazzy. +# +# Jazzy-specific counterpart to nav2_params.yaml, which serves Humble. The two +# files must stay in sync; nav2_params_distro_parity_test.py enforces that only +# the differences below may exist between them. +# +# 1. pluginlib lookup names use the `pkg::Class` form. Jazzy's nav2 exports +# these plugins with no `name` attribute, so the lookup name is the type; +# Humble's exports declare `name="pkg/Class"`. The two forms are mutually +# exclusive, which is why this file exists rather than a shared one. +# 2. `bt_navigator` carries no `plugin_lib_names` list -- see the comment on +# that parameter below. +# +# robot_drivers_to_persist_sim.launch.py selects between the two by $ROS_DISTRO. +# amcl is not used because odom is received directly from MuJoCo +# amcl: +# ros__parameters: +# use_sim_time: True +# alpha1: 0.2 +# alpha2: 0.2 +# alpha3: 0.2 +# alpha4: 0.2 +# alpha5: 0.2 +# base_frame_id: "base_footprint" +# beam_skip_distance: 0.5 +# beam_skip_error_threshold: 0.9 +# beam_skip_threshold: 0.3 +# do_beamskip: false +# global_frame_id: "map" +# lambda_short: 0.1 +# laser_likelihood_max_dist: 2.0 +# laser_max_range: 100.0 +# laser_min_range: -1.0 +# laser_model_type: "likelihood_field" +# max_beams: 60 +# max_particles: 2000 +# min_particles: 500 +# odom_frame_id: "odom" +# pf_err: 0.05 +# pf_z: 0.99 +# recovery_alpha_fast: 0.0 +# recovery_alpha_slow: 0.0 +# resample_interval: 1 +# robot_model_type: "nav2_amcl::DifferentialMotionModel" +# save_pose_rate: 0.5 +# sigma_hit: 0.2 +# tf_broadcast: true +# transform_tolerance: 1.0 +# update_min_a: 0.2 +# update_min_d: 0.25 +# z_hit: 0.5 +# z_max: 0.05 +# z_rand: 0.5 +# z_short: 0.05 +# scan_topic: scan + +bt_navigator: + ros__parameters: + use_sim_time: True + global_frame: map + robot_base_frame: ridgeback_base_link + odom_topic: /odom + bt_loop_duration: 10 + default_server_timeout: 20 + wait_for_service_timeout: 1000 + # 'default_nav_through_poses_bt_xml' and 'default_nav_to_pose_bt_xml' are use defaults: + # nav2_bt_navigator/navigate_to_pose_w_replanning_and_recovery.xml + # nav2_bt_navigator/navigate_through_poses_w_replanning_and_recovery.xml + # They can be set here or via a RewrittenYaml remap from a parent launch file to Nav2. + # + # Do NOT list the stock nav2 BT plugin libraries in `plugin_lib_names` here. Since + # Jazzy, bt_navigator always loads the default nav2 set implicitly and the parameter + # is additive, so re-listing them double-registers every node and configure fails + # with "ID [ComputePathToPose] already registered". Only custom user libraries + # belong in `plugin_lib_names`. + +bt_navigator_navigate_through_poses_rclcpp_node: + ros__parameters: + use_sim_time: True + +bt_navigator_navigate_to_pose_rclcpp_node: + ros__parameters: + use_sim_time: True + +controller_server: + ros__parameters: + use_sim_time: True + controller_frequency: 20.0 + min_x_velocity_threshold: 0.001 + min_y_velocity_threshold: 0.5 + min_theta_velocity_threshold: 0.001 + failure_tolerance: 0.3 + # Jazzy takes a list here; the singular Humble key is undeclared and would be + # silently ignored, leaving nav2 on its own default. + progress_checker_plugins: ["progress_checker"] + goal_checker_plugins: ["general_goal_checker"] # "precise_goal_checker" + controller_plugins: ["FollowPath"] + + # Progress checker parameters + progress_checker: + plugin: "nav2_controller::SimpleProgressChecker" + required_movement_radius: 0.1 + movement_time_allowance: 20.0 + # Goal checker parameters + #precise_goal_checker: + # plugin: "nav2_controller::SimpleGoalChecker" + # xy_goal_tolerance: 0.25 + # yaw_goal_tolerance: 0.25 + # stateful: True + general_goal_checker: + stateful: True + plugin: "nav2_controller::SimpleGoalChecker" + xy_goal_tolerance: 0.4 + yaw_goal_tolerance: 0.25 + FollowPath: + plugin: "nav2_mppi_controller::MPPIController" + time_steps: 56 + model_dt: 0.05 + batch_size: 500 + vx_std: 0.2 + vy_std: 0.2 + wz_std: 0.4 + vx_max: 0.5 + vx_min: -0.35 + vy_max: 0.5 + wz_max: 1.9 + iteration_count: 1 + prune_distance: 1.7 + transform_tolerance: 0.1 + temperature: 0.3 + gamma: 0.015 + motion_model: "DiffDrive" + visualize: false + TrajectoryVisualizer: + trajectory_step: 5 + time_step: 3 + AckermannConstraints: + min_turning_r: 0.2 + critics: ["ConstraintCritic", "CostCritic", "GoalCritic", "GoalAngleCritic", "PathAlignCritic", "PathFollowCritic", "PathAngleCritic", "PreferForwardCritic"] + ConstraintCritic: + enabled: true + cost_power: 1 + cost_weight: 4.0 + GoalCritic: + enabled: true + cost_power: 1 + cost_weight: 5.0 + threshold_to_consider: 1.4 + GoalAngleCritic: + enabled: true + cost_power: 1 + cost_weight: 3.0 + threshold_to_consider: 0.5 + PreferForwardCritic: + enabled: true + cost_power: 1 + cost_weight: 5.0 + threshold_to_consider: 0.5 + # ObstaclesCritic: + # enabled: true + # cost_power: 1 + # repulsion_weight: 1.5 + # critical_weight: 20.0 + # consider_footprint: false + # collision_cost: 10000.0 + # collision_margin_distance: 0.1 + # near_goal_distance: 0.5 + CostCritic: + enabled: true + cost_power: 1 + cost_weight: 3.81 + critical_cost: 300.0 + consider_footprint: true + collision_cost: 1000000.0 + near_goal_distance: 1.0 + PathAlignCritic: + enabled: true + cost_power: 1 + cost_weight: 14.0 + max_path_occupancy_ratio: 0.05 + trajectory_point_step: 3 + threshold_to_consider: 0.5 + offset_from_furthest: 20 + use_path_orientations: false + PathFollowCritic: + enabled: true + cost_power: 1 + cost_weight: 5.0 + offset_from_furthest: 5 + threshold_to_consider: 1.4 + PathAngleCritic: + enabled: true + cost_power: 1 + cost_weight: 2.0 + offset_from_furthest: 4 + threshold_to_consider: 0.5 + max_angle_to_furthest: 1.0 + forward_preference: true + # VelocityDeadbandCritic: + # enabled: true + # cost_power: 1 + # cost_weight: 35.0 + # deadband_velocities: [0.05, 0.05, 0.05] + # TwirlingCritic: + # enabled: true + # twirling_cost_power: 1 + # twirling_cost_weight: 10.0 + +local_costmap: + local_costmap: + ros__parameters: + update_frequency: 10.0 + publish_frequency: 5.0 + global_frame: odom + robot_base_frame: ridgeback_base_link + use_sim_time: True + rolling_window: true + width: 5 + height: 5 + resolution: 0.05 + footprint: "[[0.45, 0.375], [0.45, -0.375], [-0.45, -0.375], [-0.45, 0.375]]" + footprint_padding: 0.025 + plugins: ["obstacle_layer", "inflation_layer"] + inflation_layer: + plugin: "nav2_costmap_2d::InflationLayer" + cost_scaling_factor: 3.0 + inflation_radius: 0.75 + obstacle_layer: + plugin: "nav2_costmap_2d::ObstacleLayer" + enabled: True + observation_sources: scan_front scan_rear + scan_front: + topic: /scan_front_filtered + max_obstacle_height: 2.0 + clearing: True + marking: True + data_type: "LaserScan" + raytrace_max_range: 4.5 + raytrace_min_range: 0.0 + obstacle_max_range: 3.5 + obstacle_min_range: 0.0 + transform_tolerance: 0.3 + inf_is_valid: false + qos_overriding_options: + policy_kinds: [reliability] + reliability: best_effort + scan_rear: + topic: /scan_rear_filtered + max_obstacle_height: 2.0 + clearing: True + marking: True + data_type: "LaserScan" + raytrace_max_range: 4.5 + raytrace_min_range: 0.0 + obstacle_max_range: 3.5 + obstacle_min_range: 0.0 + transform_tolerance: 0.3 + inf_is_valid: false + qos_overriding_options: + policy_kinds: [reliability] + reliability: best_effort + always_send_full_costmap: True + transform_tolerance: 0.3 + + +global_costmap: + global_costmap: + ros__parameters: + update_frequency: 1.0 + publish_frequency: 1.0 + global_frame: map + robot_base_frame: ridgeback_base_link + use_sim_time: True + footprint: "[[0.45, 0.375], [0.45, -0.375], [-0.45, -0.375], [-0.45, 0.375]]" + footprint_padding: 0.025 + resolution: 0.05 + track_unknown_space: true + plugins: ["static_layer", "obstacle_layer", "inflation_layer"] + static_layer: + plugin: "nav2_costmap_2d::StaticLayer" + map_subscribe_transient_local: True + obstacle_layer: + plugin: "nav2_costmap_2d::ObstacleLayer" + enabled: True + observation_sources: scan_front scan_rear + scan_front: + topic: /scan_front_filtered + max_obstacle_height: 2.0 + clearing: True + marking: True + data_type: "LaserScan" + raytrace_max_range: 10.0 + raytrace_min_range: 0.0 + obstacle_max_range: 8.0 + obstacle_min_range: 0.0 + transform_tolerance: 0.3 + inf_is_valid: false + qos_overriding_options: + policy_kinds: [reliability] + reliability: best_effort + scan_rear: + topic: /scan_rear_filtered + max_obstacle_height: 2.0 + clearing: True + marking: True + data_type: "LaserScan" + raytrace_max_range: 10.0 + raytrace_min_range: 0.0 + obstacle_max_range: 8.0 + obstacle_min_range: 0.0 + transform_tolerance: 0.3 + inf_is_valid: false + qos_overriding_options: + policy_kinds: [reliability] + reliability: best_effort + inflation_layer: + plugin: "nav2_costmap_2d::InflationLayer" + cost_scaling_factor: 4.0 + inflation_radius: 0.7 + always_send_full_costmap: True + transform_tolerance: 0.3 + +map_server: + ros__parameters: + use_sim_time: True + # Overridden in launch by the "map" launch configuration or provided default value. + # To use in yaml, remove the default "map" value in the tb3_simulation_launch.py file & provide full path to map below. + yaml_filename: "" + +map_saver: + ros__parameters: + use_sim_time: True + save_map_timeout: 5.0 + free_thresh_default: 0.25 + occupied_thresh_default: 0.65 + map_subscribe_transient_local: True + +planner_server: + ros__parameters: + expected_planner_frequency: 20.0 + use_sim_time: True + planner_plugins: ["GridBased"] + GridBased: + plugin: "nav2_navfn_planner::NavfnPlanner" + tolerance: 0.5 + use_astar: false + allow_unknown: true + +smoother_server: + ros__parameters: + use_sim_time: True + smoother_plugins: ["simple_smoother"] + simple_smoother: + plugin: "nav2_smoother::SimpleSmoother" + tolerance: 1.0e-10 + max_its: 1000 + do_refinement: True + +behavior_server: + ros__parameters: + # Jazzy split these into local_/global_ pairs; the unprefixed Humble keys are + # undeclared here. This topic feeds the collision check guarding Spin/BackUp. + local_costmap_topic: local_costmap/costmap_raw + local_footprint_topic: local_costmap/published_footprint + cycle_frequency: 10.0 + behavior_plugins: ["spin", "backup", "drive_on_heading", "assisted_teleop", "wait"] + spin: + plugin: "nav2_behaviors::Spin" + backup: + plugin: "nav2_behaviors::BackUp" + drive_on_heading: + plugin: "nav2_behaviors::DriveOnHeading" + wait: + plugin: "nav2_behaviors::Wait" + assisted_teleop: + plugin: "nav2_behaviors::AssistedTeleop" + global_frame: odom + robot_base_frame: ridgeback_base_link + transform_tolerance: 0.1 + use_sim_time: true + simulate_ahead_time: 2.0 + max_rotational_vel: 3.6 + min_rotational_vel: 0.4 + rotational_acc_lim: 3.2 + +robot_state_publisher: + ros__parameters: + use_sim_time: True + +waypoint_follower: + ros__parameters: + use_sim_time: True + loop_rate: 20 + stop_on_failure: false + waypoint_task_executor_plugin: "wait_at_waypoint" + wait_at_waypoint: + plugin: "nav2_waypoint_follower::WaitAtWaypoint" + enabled: True + waypoint_pause_duration: 200 + +velocity_smoother: + ros__parameters: + use_sim_time: True + smoothing_frequency: 20.0 + scale_velocities: False + feedback: "OPEN_LOOP" + max_velocity: [1.0, 1.0, 1.0] + min_velocity: [-1.0, -1.0, -1.0] + max_accel: [2.5, 2.5, 3.2] + max_decel: [-2.5, -2.5, -3.2] + odom_topic: "odom" + odom_duration: 0.1 + deadband_velocity: [0.0, 0.0, 0.0] + velocity_timeout: 1.0 + +slam_toolbox: + ros__parameters: + use_sim_time: True + + # Plugin params + solver_plugin: solver_plugins::CeresSolver + ceres_linear_solver: SPARSE_NORMAL_CHOLESKY + ceres_preconditioner: SCHUR_JACOBI + ceres_trust_strategy: LEVENBERG_MARQUARDT + ceres_dogleg_type: TRADITIONAL_DOGLEG + ceres_loss_function: None + + # ROS Parameters + odom_frame: odom + map_frame: map + base_frame: ridgeback_base_link + scan_topic: /scan_front_filtered + mode: mapping + + debug_logging: false + throttle_scans: 1 + transform_publish_period: 0.02 + map_update_interval: 5.0 + resolution: 0.05 + max_laser_range: 24.9 + minimum_time_interval: 0.5 + tf_buffer_duration: 30.0 + stack_size_to_use: 40000000 + enable_interactive_mode: true + + # General Parameters + use_scan_matching: false + use_scan_barycenter: true + minimum_travel_distance: 0.3 + minimum_travel_heading: 0.3 + scan_buffer_size: 10 + scan_buffer_maximum_scan_distance: 10.0 + link_match_minimum_response_fine: 0.1 + link_scan_maximum_distance: 1.5 + loop_search_maximum_distance: 3.0 + do_loop_closing: false + loop_match_minimum_chain_size: 10 + loop_match_maximum_variance_coarse: 3.0 + loop_match_minimum_response_coarse: 0.35 + loop_match_minimum_response_fine: 0.45 + + # Correlation Parameters + correlation_search_space_dimension: 0.5 + correlation_search_space_resolution: 0.01 + correlation_search_space_smear_deviation: 0.1 + + # Loop Closure Parameters + loop_search_space_dimension: 8.0 + loop_search_space_resolution: 0.05 + loop_search_space_smear_deviation: 0.03 + + # Scan Matcher Parameters + distance_variance_penalty: 0.5 + angle_variance_penalty: 1.0 + fine_search_angle_offset: 0.00349 + coarse_search_angle_offset: 0.349 + coarse_angle_resolution: 0.0349 + minimum_angle_penalty: 0.9 + minimum_distance_penalty: 0.5 + use_response_expansion: true diff --git a/src/hangar_sim/test/nav2_params_distro_parity_test.py b/src/hangar_sim/test/nav2_params_distro_parity_test.py new file mode 100644 index 000000000..6e561d655 --- /dev/null +++ b/src/hangar_sim/test/nav2_params_distro_parity_test.py @@ -0,0 +1,153 @@ +# Copyright 2026 PickNik Inc. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# * Neither the name of the PickNik Inc. nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +"""hangar_sim ships one nav2 parameter file per ROS distro. + +`nav2_params.yaml` serves Humble and `nav2_params_jazzy.yaml` serves Jazzy, +because nav2's pluginlib lookup names differ between the two and the forms are +mutually exclusive. Everything else in the two files must stay identical -- any +tuning applied to one and forgotten on the other is a silent, distro-specific +config bug that only shows up on a running robot. + +This test pins the complete set of allowed differences. +""" + +import re +from pathlib import Path + +import pytest +import yaml + +PARAMS_DIR = Path(__file__).resolve().parent.parent / "params" +HUMBLE_PARAMS = PARAMS_DIR / "nav2_params.yaml" +JAZZY_PARAMS = PARAMS_DIR / "nav2_params_jazzy.yaml" + +# Jazzy exports these plugins with no `name` attribute, so the pluginlib lookup +# name is the `pkg::Class` type. Humble's exports declare `name="pkg/Class"`. +EXPECTED_PLUGIN_RENAMES = { + "planner_server.ros__parameters.GridBased.plugin": ( + "nav2_navfn_planner/NavfnPlanner", + "nav2_navfn_planner::NavfnPlanner", + ), + "behavior_server.ros__parameters.spin.plugin": ( + "nav2_behaviors/Spin", + "nav2_behaviors::Spin", + ), + "behavior_server.ros__parameters.backup.plugin": ( + "nav2_behaviors/BackUp", + "nav2_behaviors::BackUp", + ), + "behavior_server.ros__parameters.drive_on_heading.plugin": ( + "nav2_behaviors/DriveOnHeading", + "nav2_behaviors::DriveOnHeading", + ), + "behavior_server.ros__parameters.wait.plugin": ( + "nav2_behaviors/Wait", + "nav2_behaviors::Wait", + ), + "behavior_server.ros__parameters.assisted_teleop.plugin": ( + "nav2_behaviors/AssistedTeleop", + "nav2_behaviors::AssistedTeleop", + ), +} + +# Jazzy's bt_navigator always loads the default nav2 BT plugin libraries and the +# parameter is additive, so re-listing them double-registers every node. +# +# The rest are parameters Jazzy's nav2 renamed. Their Humble spellings are +# undeclared on Jazzy, so leaving them would be silently ignored rather than +# rejected -- the values happen to match Jazzy's defaults today, which is exactly +# what would make a future divergence invisible. +HUMBLE_ONLY_KEYS = { + "bt_navigator.ros__parameters.plugin_lib_names", + "controller_server.ros__parameters.progress_checker_plugin", + "behavior_server.ros__parameters.costmap_topic", + "behavior_server.ros__parameters.footprint_topic", +} + +JAZZY_ONLY_KEYS = { + "controller_server.ros__parameters.progress_checker_plugins", + "behavior_server.ros__parameters.local_costmap_topic", + "behavior_server.ros__parameters.local_footprint_topic", +} + + +def _flatten(node, prefix=""): + if isinstance(node, dict) and node: + for key, value in node.items(): + yield from _flatten(value, f"{prefix}.{key}" if prefix else str(key)) + else: + yield prefix, node + + +@pytest.fixture(scope="module") +def flattened(): + humble = dict(_flatten(yaml.safe_load(HUMBLE_PARAMS.read_text()))) + jazzy = dict(_flatten(yaml.safe_load(JAZZY_PARAMS.read_text()))) + return humble, jazzy + + +def test_only_expected_keys_are_distro_specific(flattened): + humble, jazzy = flattened + assert set(humble) - set(jazzy) == HUMBLE_ONLY_KEYS + assert set(jazzy) - set(humble) == JAZZY_ONLY_KEYS + + +def test_shared_keys_hold_identical_values(flattened): + humble, jazzy = flattened + mismatches = { + key: (humble[key], jazzy[key]) + for key in set(humble) & set(jazzy) + if humble[key] != jazzy[key] + } + assert mismatches == EXPECTED_PLUGIN_RENAMES + + +def test_jazzy_uses_no_slash_form_plugin_names(flattened): + """Reject the pre-Jazzy `pkg/Class` form generally, not just today's six -- + a plugin added later to both files would otherwise slip through.""" + _, jazzy = flattened + slash_form = { + key: value + for key, value in jazzy.items() + if key.endswith(".plugin") + and isinstance(value, str) + and re.fullmatch(r"\w+/\w+", value) + } + assert slash_form == {} + + +def test_launch_file_references_both_params_files(): + """A typo in either filename would only surface as a bringup failure, and the + integration suite does not observe nav2 bringup -- so pin it here.""" + launch_source = ( + PARAMS_DIR.parent / "launch" / "sim" / "robot_drivers_to_persist_sim.launch.py" + ).read_text() + for params in (HUMBLE_PARAMS, JAZZY_PARAMS): + assert params.name in launch_source + assert params.is_file()