diff --git a/beluga/include/beluga/algorithm/amcl_core.hpp b/beluga/include/beluga/algorithm/amcl_core.hpp index 63f6ac1911..8f4679e1b7 100644 --- a/beluga/include/beluga/algorithm/amcl_core.hpp +++ b/beluga/include/beluga/algorithm/amcl_core.hpp @@ -87,6 +87,7 @@ class Amcl { using measurement_type = typename SensorModel::measurement_type; using state_type = typename SensorModel::state_type; using map_type = typename SensorModel::map_type; + using control_type = TimeStamped; using spatial_hasher_type = spatial_hash; using random_state_generator_type = RandomStateGenerator; using estimation_type = std::invoke_result_t>; @@ -162,7 +163,7 @@ class Amcl { * \return An optional pair containing the estimated pose and covariance after the update, * or std::nullopt if no update was performed. */ - auto update(state_type control_action, measurement_type measurement) -> std::optional { + auto update(control_type control_action, measurement_type measurement) -> std::optional { if (particles_.empty()) { return std::nullopt; } @@ -227,7 +228,7 @@ class Amcl { random_state_generator_type random_state_generator_; - beluga::RollingWindow control_action_window_; + beluga::RollingWindow control_action_window_; bool force_update_{true}; }; diff --git a/beluga/include/beluga/motion.hpp b/beluga/include/beluga/motion.hpp index 88ff04fb0e..5317cbc50c 100644 --- a/beluga/include/beluga/motion.hpp +++ b/beluga/include/beluga/motion.hpp @@ -15,6 +15,7 @@ #ifndef BELUGA_MOTION_HPP #define BELUGA_MOTION_HPP +#include #include #include #include @@ -22,6 +23,10 @@ /** * \file * \brief Includes all Beluga motion models. + * + * Motion models in Beluga include: + * - Position-based models: Use pose differences (DifferentialDriveModel, OmnidirectionalDriveModel, StationaryModel) + * - Velocity-based models: Use timestamped poses to calculate velocities (AckermannDriveModel) */ /** @@ -61,6 +66,7 @@ * - beluga::DifferentialDriveModel * - beluga::OmnidirectionalDriveModel * - beluga::StationaryModel + * - beluga::AckermannDriveModel */ #endif diff --git a/beluga/include/beluga/motion/ackermann_drive_model.hpp b/beluga/include/beluga/motion/ackermann_drive_model.hpp new file mode 100644 index 0000000000..b91dff3162 --- /dev/null +++ b/beluga/include/beluga/motion/ackermann_drive_model.hpp @@ -0,0 +1,288 @@ +// Copyright 2022-2023 Ekumen, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef BELUGA_MOTION_ACKERMANN_DRIVE_MODEL_HPP +#define BELUGA_MOTION_ACKERMANN_DRIVE_MODEL_HPP + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +/** + * \file + * \brief Implementation of a velocity motion model. + */ + +namespace beluga { + +/// Velocity components for differential drive motion model. +struct AckermannControls { + double v; ///< Linear velocity (m/s) + double phi; ///< Steering angle (rad) +}; + +/// Parameters to construct a AckermannDriveModel instance. +/** + * See Probabilistic Robotics \cite thrun2005probabilistic Chapter 5.3, particularly table 5.3. + */ +struct AckermannDriveModelParam { + /// Steering noise from steering angle + /** + * How much steering noise is generated by the steering angle. + * Also known as `alpha1 in the Ackermann drive model param`. + */ + double steering_noise_from_steering; + /// Steering noise from linear velocity + /** + * How much steering noise is generated by the linear velocity. + * Also known as `alpha2 in the Ackermann drive model param`. + */ + double steering_noise_from_velocity; + /// Velocity noise from linear velocity + /** + * How much velocity noise is generated by the linear velocity. + * Also known as `alpha3 in the Ackermann drive model param`. + */ + double velocity_noise_from_velocity; + /// Velocity noise from steering angle + /** + * How much velocity noise is generated by the steering angle. + * Also known as `alpha4 in the Ackermann drive model param`. + */ + double velocity_noise_from_steering; + /// Additional orientation noise from linear velocity + /** + * How much extra orientation noise is generated by the linear velocity. + * Also known as `alpha6`. + */ + double orientation_noise_from_velocity; + /// Additional orientation noise from steering angle + /** + * How much extra orientation noise is generated by the steering angle. + * Also known as `alpha7`. + */ + double orientation_noise_from_steering; + + /// Distance between the rear and front wheel axles (meters). + /** + * See \cite Localization and Mapping in Local Occupancy Grid Maps: Simulation + * in Ackermann model mobile robot by Ronald A. Cardenas , Jasper W. Huanay + * and Ivan Calle + */ + double wheelbase; +}; + +/// Velocity model for a Ackermann drive. +/** + * Supports 2D and (flattened) 3D state types. + * This class satisfies \ref MotionModelPage. + * + * The model is and adaptation using the single track kinematic model + * and the noise models of Probabilistic Robotics. + * The model serves for any drive that can be simplified to a Single Track vehicle: + * ackermann, bicycle, tri-cycle, etc. + * See Probabilistic Robotics \cite thrun2005probabilistic Chapter 5.3. + * + * \tparam StateType Type for particle's state. Either Sophus::SE2d or Sophus::SE3d. + */ +template +class AckermannDriveModel { + static_assert( + std::is_same_v or std::is_same_v, + "Velocity model only supports SE2 and SE3 state types."); + + public: + /// 2D or flattened 3D pose as motion model state (to match that of the particles). + using state_type = StateType; + + /// Time point type for motion model control actions. + using timestamped_state_type = TimeStamped; + + /// Current and previous pose estimates and time points as motion model control action. + using control_type = std::tuple; + + /// Parameter type that the constructor uses to configure the motion model. + using param_type = AckermannDriveModelParam; + + /// Constructs a AckermannDriveModel instance. + /** + * \param params Parameters to configure this instance. + * See beluga::AckermannDriveModelParam for details. + */ + explicit AckermannDriveModel(const param_type& params) : params_{params} {} + + /// Computes a state sampling function conditioned on a given control action. + /** + * \tparam Control A tuple-like container matching the model's `control_type`. + * \param action Control action to condition the motion model with. + * \return a callable satisfying \ref StateSamplingFunctionPage. + */ + template > + [[nodiscard]] auto operator()(const Control& action) const { + const auto& [timestamped, previous_timestamped] = action; + const auto& pose = timestamped.value; + const auto& previous_pose = previous_timestamped.value; + + const auto time = timestamped.timestamp; + const auto previous_time = previous_timestamped.timestamp; + const auto delta_time = std::chrono::duration(time - previous_time); + return sampling_fn_2d(pose, previous_pose, delta_time); + } + + private: + using control_type_2d = std::tuple; + + [[nodiscard]] auto sampling_fn_2d( + const Sophus::SE2d& pose, + const Sophus::SE2d& previous_pose, + std::chrono::duration delta_time) const { + // Calculate velocities from poses + const auto controls = calculate_velocities(pose, previous_pose, delta_time); + + // Velocity noise parameters (following velocity motion model from Probabilistic Robotics) + // Use temporary distributions to safely extract param_type objects + const auto linear_velocity_distribution = std::normal_distribution{ + controls.v, std::sqrt( + params_.velocity_noise_from_velocity * controls.v * controls.v + + params_.velocity_noise_from_steering * controls.phi * controls.phi)}; + const auto linear_velocity_params = linear_velocity_distribution.param(); + + const auto steering_angle_distribution = std::normal_distribution{ + controls.phi, std::sqrt( + params_.steering_noise_from_velocity * controls.v * controls.v + + params_.steering_noise_from_steering * controls.phi * controls.phi)}; + const auto steering_angle_params = steering_angle_distribution.param(); + + // Additional orientation noise (gamma_hat) using rotation parameters + const auto gamma_distribution = std::normal_distribution{ + 0.0, // zero mean + std::sqrt( + params_.orientation_noise_from_velocity * controls.v * controls.v + + params_.orientation_noise_from_steering * controls.phi * controls.phi)}; + const auto gamma_params = gamma_distribution.param(); + + return [=](const auto& state, auto& gen) { + static thread_local auto distribution = std::normal_distribution{}; + + // Sample noisy velocities + const auto v_hat = distribution(gen, linear_velocity_params); + const auto phi_hat = distribution(gen, steering_angle_params); + const auto gamma_hat = distribution(gen, gamma_params); + + const auto omega_hat = v_hat * std::tan(phi_hat) / params_.wheelbase; + // Apply velocity motion model + return apply_velocity_motion(state, v_hat, omega_hat, gamma_hat, delta_time); + }; + } + + /// Calculate linear and angular velocities from two poses and delta time + AckermannControls calculate_velocities( + const Sophus::SE2d& pose, + const Sophus::SE2d& previous_pose, + std::chrono::duration delta_time) const { + const double delta_t_sec = delta_time.count(); + + // Euclidean distance (chord length between poses) + const auto translation = pose.translation() - previous_pose.translation(); + const double chord_distance = translation.norm(); + + const auto relative_transform = previous_pose.inverse() * pose; + // Angular velocity from orientation change + const auto angular_change = relative_transform.so2(); + const double angle_change = angular_change.log(); + const double angular_velocity = angle_change / delta_t_sec; + + // Determine direction sign (forward/backward motion) + const double dx = relative_transform.translation().x(); + const double sign = (dx >= 0.0) ? 1.0 : -1.0; + + // Linear velocity calculation + double linear_velocity = 0.0; + double steering_angle = 0.0; + if (std::abs(angle_change) > small_angle_threshold) { + // Circular motion: calculate radius from chord and angle + // For an arc: chord = 2r·sin(θ/2), therefore r = chord / (2·sin(θ/2)) + const double radius = chord_distance / (2.0 * std::sin(std::abs(angle_change) / 2.0)); + + // Arc length: s = r · θ + const double arc_distance = radius * std::abs(angle_change); + + // Linear velocity with direction sign + linear_velocity = sign * arc_distance / delta_t_sec; + if (std::abs(linear_velocity) > small_angle_threshold) { + const double ratio = params_.wheelbase * angular_velocity / linear_velocity; + steering_angle = std::atan(ratio); + } + } else { + // Straight line motion: v = distance / time + linear_velocity = sign * chord_distance / delta_t_sec; + } + return AckermannControls{linear_velocity, steering_angle}; + } + /// Apply velocity motion model to get new pose + Sophus::SE2d apply_velocity_motion( + const Sophus::SE2d& state, + double v_hat, + double omega_hat, + double gamma_hat, + std::chrono::duration delta_time) const { + const double delta_t_sec = delta_time.count(); + + const auto current_theta = state.so2().log(); + + Sophus::SE2d new_pose; + + if (std::abs(omega_hat) < small_angle_threshold) { + // Nearly straight line motion + const auto translation = + Eigen::Vector2d{v_hat * delta_t_sec * std::cos(current_theta), v_hat * delta_t_sec * std::sin(current_theta)}; + const auto new_theta = current_theta + gamma_hat * delta_t_sec; + new_pose = Sophus::SE2d{Sophus::SO2d{new_theta}, state.translation() + translation}; + } else { + // Circular motion (following velocity motion model equations) + const auto dx = -(v_hat / omega_hat) * std::sin(current_theta) + + (v_hat / omega_hat) * std::sin(current_theta + omega_hat * delta_t_sec); + const auto dy = (v_hat / omega_hat) * std::cos(current_theta) - + (v_hat / omega_hat) * std::cos(current_theta + omega_hat * delta_t_sec); + const auto translation = Eigen::Vector2d{dx, dy}; + const auto new_theta = current_theta + omega_hat * delta_t_sec + gamma_hat * delta_t_sec; + new_pose = Sophus::SE2d{Sophus::SO2d{new_theta}, state.translation() + translation}; + } + return new_pose; + } + + param_type params_; + + /// Threshold for distinguishing between straight-line and circular motion. + /** + * Below this threshold (~0.57 degrees), motion is treated as straight-line to avoid + * numerical instabilities in radius calculations for nearly-zero angular velocities. + */ + static constexpr double small_angle_threshold = 0.01; +}; + +/// Alias for a 2D Ackermann drive model, for convenience. +using AckermannDriveModel2d = AckermannDriveModel; +} // namespace beluga + +#endif diff --git a/beluga/include/beluga/motion/omnidirectional_drive_model.hpp b/beluga/include/beluga/motion/omnidirectional_drive_model.hpp index 70272f8541..51f145dfca 100644 --- a/beluga/include/beluga/motion/omnidirectional_drive_model.hpp +++ b/beluga/include/beluga/motion/omnidirectional_drive_model.hpp @@ -77,11 +77,12 @@ struct OmnidirectionalDriveModelParam { */ class OmnidirectionalDriveModel { public: - /// Current and previous odometry estimates as motion model control action. - using control_type = std::tuple; /// 2D pose as motion model state (to match that of the particles). using state_type = Sophus::SE2d; + /// Current and previous odometry estimates as motion model control action. + using control_type = std::tuple; + /// Parameter type that the constructor uses to configure the motion model. using param_type = OmnidirectionalDriveModelParam; @@ -100,7 +101,9 @@ class OmnidirectionalDriveModel { */ template > [[nodiscard]] auto operator()(Control&& action) const { - const auto& [pose, previous_pose] = action; + const auto& [pose_stamped, previous_pose_stamped] = action; + const state_type& pose = pose_stamped; + const state_type& previous_pose = previous_pose_stamped; const auto translation = pose.translation() - previous_pose.translation(); const double distance = translation.norm(); diff --git a/beluga/include/beluga/motion/stationary_model.hpp b/beluga/include/beluga/motion/stationary_model.hpp index 7f7013350e..0a944e3f5f 100644 --- a/beluga/include/beluga/motion/stationary_model.hpp +++ b/beluga/include/beluga/motion/stationary_model.hpp @@ -38,11 +38,10 @@ namespace beluga { */ class StationaryModel { public: - /// Current and previous odometry estimates as motion model control action. - using control_type = std::tuple; /// 2D pose as motion model state (to match that of the particles). using state_type = Sophus::SE2d; - + /// Current and previous odometry estimates as motion model control action. + using control_type = std::tuple; /// Computes a state sampling function conditioned on a given control action. /** * The updated state will be centered around `state` with some covariance. diff --git a/beluga/include/beluga/utility/time_stamped.hpp b/beluga/include/beluga/utility/time_stamped.hpp new file mode 100644 index 0000000000..7afc257259 --- /dev/null +++ b/beluga/include/beluga/utility/time_stamped.hpp @@ -0,0 +1,73 @@ +// Copyright 2023-2024 Ekumen, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef BELUGA_UTILITY_TIME_STAMPED_HPP +#define BELUGA_UTILITY_TIME_STAMPED_HPP + +#include + +/** + * \file + * \brief Implementation of a time-stamped wrapper for values. + */ + +namespace beluga { + +/// A wrapper that associates a value with a timestamp. +/** + * This structure provides a way to bundle any value with its associated timestamp, + * which is commonly needed in motion models and sensor processing where timing + * information is crucial for proper calculations. + * + * \tparam T The type of the value to be time-stamped. + * \tparam ClockT The clock type to use for timestamps. Defaults to system_clock. + */ +template +struct TimeStamped { + /// The wrapped value. + T value; + + /// The timestamp associated with the value. + std::chrono::time_point timestamp; + /// Default constructor. + /** + * Initializes value with default construction and timestamp to epoch. + */ + TimeStamped() : value{}, timestamp{} {} + + /// Constructs a TimeStamped with the current time. + /** + * \param val The value to be time-stamped. + */ + explicit TimeStamped(const T& val) : value(val), timestamp{} {} + + /// Constructs a TimeStamped with a specific timestamp. + /** + * \param val The value to be time-stamped. + * \param ts The timestamp to associate with the value. + */ + TimeStamped(const T& val, std::chrono::time_point ts) : value(val), timestamp(ts) {} + + /// Implicit conversion operator to the wrapped value. + /** + * This allows TimeStamped to be used anywhere a T is expected, + * enabling motion models that don't need timestamps to work transparently + * with both TimeStamped and T types. + */ + operator const T&() const { return value; } +}; + +} // namespace beluga + +#endif diff --git a/beluga/test/beluga/CMakeLists.txt b/beluga/test/beluga/CMakeLists.txt index b7a2103bd6..2e418dba3f 100644 --- a/beluga/test/beluga/CMakeLists.txt +++ b/beluga/test/beluga/CMakeLists.txt @@ -33,6 +33,7 @@ add_executable( algorithm/test_unscented_transform.cpp containers/test_circular_array.cpp containers/test_tuple_vector.cpp + motion/test_ackermann_drive_model.cpp motion/test_differential_drive_model.cpp motion/test_omnidirectional_drive_model.cpp policies/test_every_n.cpp diff --git a/beluga/test/beluga/algorithm/test_amcl_core.cpp b/beluga/test/beluga/algorithm/test_amcl_core.cpp index 577609421e..83e6abdd21 100644 --- a/beluga/test/beluga/algorithm/test_amcl_core.cpp +++ b/beluga/test/beluga/algorithm/test_amcl_core.cpp @@ -27,12 +27,14 @@ #include "beluga/motion/differential_drive_model.hpp" #include "beluga/sensor/beam_model.hpp" #include "beluga/sensor/likelihood_field_model.hpp" +#include "beluga/test/motion_utils.hpp" #include "beluga/test/static_occupancy_grid.hpp" +#include "beluga/utility/time_stamped.hpp" #include "beluga/views/particles.hpp" namespace { -const auto kDummyControl = Sophus::SE2d{}; +const auto kDummyControl = beluga::TimeStamped{}; const std::vector kDummyMeasurement = { std::make_pair(0.0, 0.0), std::make_pair(0.0, 0.0), diff --git a/beluga/test/beluga/include/beluga/test/motion_utils.hpp b/beluga/test/beluga/include/beluga/test/motion_utils.hpp new file mode 100644 index 0000000000..82b5fe7e55 --- /dev/null +++ b/beluga/test/beluga/include/beluga/test/motion_utils.hpp @@ -0,0 +1,42 @@ +// Copyright 2023-2024 Ekumen, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef BELUGA_TEST_MOTION_UTILS_HPP +#define BELUGA_TEST_MOTION_UTILS_HPP + +#include +#include + +#include + +/** + * \file + * \brief Test utilities for motion models. + */ + +namespace beluga::testing { +/// Helper function to create a control action tuple with TimeStamped values and explicit timestamps for testing +template +auto make_control_action( + const T& current, + const T& previous, + std::chrono::time_point current_timestamp, + std::chrono::time_point previous_timestamp) { + return std::make_tuple( + TimeStamped{current, current_timestamp}, TimeStamped{previous, previous_timestamp}); +} + +} // namespace beluga::testing + +#endif // BELUGA_TEST_MOTION_UTILS_HPP diff --git a/beluga/test/beluga/motion/test_ackermann_drive_model.cpp b/beluga/test/beluga/motion/test_ackermann_drive_model.cpp new file mode 100644 index 0000000000..e915dd5521 --- /dev/null +++ b/beluga/test/beluga/motion/test_ackermann_drive_model.cpp @@ -0,0 +1,183 @@ +// Copyright 2022-2023 Ekumen, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include "beluga/3d_embedding.hpp" +#include "beluga/motion/ackermann_drive_model.hpp" +#include "beluga/test/motion_utils.hpp" +#include "beluga/testing/sophus_matchers.hpp" + +namespace { + +using Constants = Sophus::Constants; +using Eigen::Vector2d; +using Sophus::SE2d; +using Sophus::SO2d; + +using beluga::testing::SE2Near; + +using UUT = beluga::AckermannDriveModel2d; + +class AckermannDriveModelTest : public ::testing::Test { + protected: + const UUT motion_model_{beluga::AckermannDriveModelParam{0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.5}}; + std::mt19937 generator_{std::random_device()()}; +}; + +TEST_F(AckermannDriveModelTest, OneUpdate) { + constexpr double kTolerance = 0.001; + const auto base_pose_in_odom = SE2d{SO2d{Constants::pi()}, Vector2d{1.0, -2.0}}; + const auto previous_pose_in_odom = SE2d{SO2d{Constants::pi()}, Vector2d{1.0, -2.0}}; + const auto laser_scan_stamp = std::chrono::system_clock::now(); + const auto previous_stamp = laser_scan_stamp - std::chrono::milliseconds(100); + const auto control_action = + beluga::testing::make_control_action(base_pose_in_odom, previous_pose_in_odom, laser_scan_stamp, previous_stamp); + const auto state_sampling_function = motion_model_(control_action); + const auto pose = SE2d{SO2d{Constants::pi() / 3}, Vector2d{2.0, 5.0}}; + ASSERT_THAT(state_sampling_function(pose, generator_), SE2Near(pose, kTolerance)); +} + +TEST_F(AckermannDriveModelTest, Translate) { + constexpr double kTolerance = 0.001; + const auto base_pose_in_odom = SE2d{SO2d{0.0}, Vector2d{1.0, 0.0}}; + const auto previous_pose_in_odom = SE2d{SO2d{0.0}, Vector2d{0.0, 0.0}}; + const auto laser_scan_stamp = std::chrono::system_clock::now(); + const auto previous_stamp = laser_scan_stamp - std::chrono::milliseconds(100); + const auto control_action = + beluga::testing::make_control_action(base_pose_in_odom, previous_pose_in_odom, laser_scan_stamp, previous_stamp); + const auto state_sampling_function = motion_model_(control_action); + + const auto result1 = state_sampling_function(SE2d{SO2d{0.0}, Vector2d{2.0, 0.0}}, generator_); + ASSERT_THAT(result1, SE2Near(SO2d{0.0}, Vector2d{3.0, 0.0}, kTolerance)); + const auto result2 = state_sampling_function(SE2d{SO2d{0.0}, Vector2d{0.0, 3.0}}, generator_); + ASSERT_THAT(result2, SE2Near(SO2d{0.0}, Vector2d{1.0, 3.0}, kTolerance)); +} + +TEST_F(AckermannDriveModelTest, ArcOfCircumference) { + constexpr double kTolerance = 0.001; + const auto base_pose_in_odom = SE2d{SO2d{Constants::pi() / 2}, Vector2d{0.0, 1.0}}; + const auto previous_pose_in_odom = SE2d{SO2d{0.0}, Vector2d{0.0, 0.0}}; + const auto laser_scan_stamp = std::chrono::system_clock::now(); + const auto previous_stamp = laser_scan_stamp - std::chrono::milliseconds(100); + const auto control_action = + beluga::testing::make_control_action(base_pose_in_odom, previous_pose_in_odom, laser_scan_stamp, previous_stamp); + const auto state_sampling_function = motion_model_(control_action); + const auto result1 = state_sampling_function(SE2d{SO2d{0.0}, Vector2d{0.0, 0.0}}, generator_); + ASSERT_THAT( + result1, SE2Near(SO2d{Constants::pi() / 2}, Vector2d{std::sqrt(2.0) / 2.0, std::sqrt(2.0) / 2.0}, kTolerance)); + const auto result2 = state_sampling_function(SE2d{SO2d{-Constants::pi() / 2}, Vector2d{2.0, 3.0}}, generator_); + ASSERT_THAT( + result2, SE2Near(SO2d{0.0}, Vector2d{2.0 + std::sqrt(2.0) / 2.0, 3.0 - std::sqrt(2.0) / 2.0}, kTolerance)); +} + +template +auto get_statistics(Range&& range) { + const auto size = static_cast(std::distance(std::begin(range), std::end(range))); + const double sum = std::accumulate(std::begin(range), std::end(range), 0.0); + const double mean = sum / size; + const double squared_diff_sum = + std::transform_reduce(std::begin(range), std::end(range), 0.0, std::plus<>{}, [mean](double value) { + const double diff = value - mean; + return diff * diff; + }); + const double stddev = std::sqrt(squared_diff_sum / size); + return std::pair{mean, stddev}; +} + +TEST(AckermannDriveModelSamples, Translate) { + const double tolerance = 0.015; + const double alpha = 0.2; + const double origin = 5.0; + const double distance = 3.0; + const double delta_time = 0.1; + const auto motion_model = UUT{beluga::AckermannDriveModelParam{0.0, 0.0, alpha, 0.0, 0.0, 0.0, 0.5}}; + auto generator = std::mt19937{std::random_device()()}; + const auto base_pose_in_odom = SE2d{SO2d{0.0}, Vector2d{distance, 0.0}}; + const auto previous_pose_in_odom = SE2d{SO2d{0.0}, Vector2d{0.0, 0.0}}; + const auto laser_scan_stamp = std::chrono::system_clock::now(); + const auto previous_stamp = laser_scan_stamp - std::chrono::milliseconds(100); + const auto control_action = + beluga::testing::make_control_action(base_pose_in_odom, previous_pose_in_odom, laser_scan_stamp, previous_stamp); + const auto state_sampling_function = motion_model(control_action); + auto view = ranges::views::generate([&]() { + const auto pose = SE2d{SO2d{0.0}, Vector2d{origin, 0.0}}; + return state_sampling_function(pose, generator).translation().x(); + }) | + ranges::views::take_exactly(100'000) | ranges::views::common; + const auto [mean, stddev] = get_statistics(view); + ASSERT_NEAR(mean, origin + distance, tolerance); + const double expected_velocity = distance / delta_time; + ASSERT_NEAR(stddev, std::sqrt(alpha * expected_velocity * expected_velocity) * delta_time, tolerance); +} + +TEST(AckermannDriveModelSamples, ArcOfCircumference) { + const double tolerance = 0.1; + const double alpha = 0.2; + const auto motion_model = UUT{beluga::AckermannDriveModelParam{0.0, 0.0, 0.0, alpha, 0.0, 0.0, 0.5}}; + auto generator = std::mt19937{std::random_device()()}; + + // Ackermann curve: robot turns from θ=0 to θ=π/2 while moving in an arc + const auto base_pose_in_odom = SE2d{SO2d{Constants::pi() / 2}, Vector2d{0.0, 2.0}}; + const auto previous_pose_in_odom = SE2d{SO2d{0.0}, Vector2d{2.0, 0.0}}; + const auto laser_scan_stamp = std::chrono::system_clock::now(); + const auto previous_stamp = laser_scan_stamp - std::chrono::milliseconds(100); + const auto control_action = + beluga::testing::make_control_action(base_pose_in_odom, previous_pose_in_odom, laser_scan_stamp, previous_stamp); + const auto state_sampling_function = motion_model(control_action); + + // Test 1: Mean position should follow the expected arc geometry + auto position_view = ranges::views::generate([&]() { + const auto pose = SE2d{SO2d{0.0}, Vector2d{0.0, 0.0}}; + return state_sampling_function(pose, generator).translation().norm(); + }) | + ranges::views::take_exactly(100'000) | ranges::views::common; + const auto [position_mean, position_stddev] = get_statistics(position_view); + ASSERT_NEAR(position_mean, 2.0 * std::sqrt(2.0), tolerance); + + // Test 2: Orientation noise - this is what steering angle φ primarily affects + auto orientation_view = ranges::views::generate([&]() { + const auto pose = SE2d{SO2d{0.0}, Vector2d{0.0, 0.0}}; + return state_sampling_function(pose, generator).so2().log(); + }) | + ranges::views::take_exactly(100'000) | ranges::views::common; + const auto [orientation_mean, orientation_stddev] = get_statistics(orientation_view); + + // Expected final orientation after π/2 rotation + ASSERT_NEAR(orientation_mean, Constants::pi() / 2, tolerance); + + // Noise propagation: φ̂ = φ + N(0, α₄ω²) through ω̂ = v̂ * tan(φ̂) / L affects final orientation + // The Ackermann steering angle model produces this empirically observed noise level + const double expected_orientation_stddev = 0.006; + ASSERT_NEAR(orientation_stddev, expected_orientation_stddev, tolerance); +} +} // namespace diff --git a/beluga/test/beluga/motion/test_differential_drive_model.cpp b/beluga/test/beluga/motion/test_differential_drive_model.cpp index 8a8306a7ec..ddbb94c47c 100644 --- a/beluga/test/beluga/motion/test_differential_drive_model.cpp +++ b/beluga/test/beluga/motion/test_differential_drive_model.cpp @@ -33,6 +33,7 @@ #include "beluga/3d_embedding.hpp" #include "beluga/motion/differential_drive_model.hpp" +#include "beluga/test/motion_utils.hpp" #include "beluga/testing/sophus_matchers.hpp" namespace { @@ -61,6 +62,19 @@ TEST_F(DifferentialDriveModelTest, OneUpdate) { ASSERT_THAT(state_sampling_function(pose, generator_), SE2Near(pose, kTolerance)); } +TEST_F(DifferentialDriveModelTest, OneUpdateTimeStamp) { + constexpr double kTolerance = 0.001; + const auto base_pose_in_odom = SE2d{SO2d{Constants::pi()}, Vector2d{1.0, -2.0}}; + const auto previous_pose_in_odom = SE2d{SO2d{Constants::pi()}, Vector2d{1.0, -2.0}}; + const auto laser_scan_stamp = std::chrono::system_clock::now(); + const auto previous_stamp = laser_scan_stamp - std::chrono::milliseconds(100); + const auto control_action = + beluga::testing::make_control_action(base_pose_in_odom, previous_pose_in_odom, laser_scan_stamp, previous_stamp); + const auto state_sampling_function = motion_model_(control_action); + const auto pose = SE2d{SO2d{Constants::pi() / 3}, Vector2d{2.0, 5.0}}; + ASSERT_THAT(state_sampling_function(pose, generator_), SE2Near(pose, kTolerance)); +} + TEST_F(DifferentialDriveModelTest, Translate) { constexpr double kTolerance = 0.001; const auto control_action = std::make_tuple(SE2d{SO2d{0.0}, Vector2d{1.0, 0.0}}, SE2d{SO2d{0.0}, Vector2d{0.0, 0.0}}); diff --git a/beluga/test/beluga/motion/test_omnidirectional_drive_model.cpp b/beluga/test/beluga/motion/test_omnidirectional_drive_model.cpp index b30ba2c85d..e0a6c905b8 100644 --- a/beluga/test/beluga/motion/test_omnidirectional_drive_model.cpp +++ b/beluga/test/beluga/motion/test_omnidirectional_drive_model.cpp @@ -31,6 +31,7 @@ #include #include "beluga/motion/omnidirectional_drive_model.hpp" +#include "beluga/test/motion_utils.hpp" #include "beluga/testing/sophus_matchers.hpp" namespace { @@ -58,6 +59,19 @@ TEST_F(OmnidirectionalDriveModelTest, OneUpdate) { ASSERT_THAT(state_sampling_function(pose, generator_), SE2Near(pose, kTolerance)); } +TEST_F(OmnidirectionalDriveModelTest, OneUpdateTimeStamp) { + constexpr double kTolerance = 0.001; + const auto base_pose_in_odom = SE2d{SO2d{Constants::pi()}, Vector2d{1.0, -2.0}}; + const auto previous_pose_in_odom = SE2d{SO2d{Constants::pi()}, Vector2d{1.0, -2.0}}; + const auto laser_scan_stamp = std::chrono::system_clock::now(); + const auto previous_stamp = laser_scan_stamp - std::chrono::milliseconds(100); + const auto control_action = + beluga::testing::make_control_action(base_pose_in_odom, previous_pose_in_odom, laser_scan_stamp, previous_stamp); + const auto state_sampling_function = motion_model_(control_action); + const auto pose = SE2d{SO2d{Constants::pi() / 3}, Vector2d{2.0, 5.0}}; + ASSERT_THAT(state_sampling_function(pose, generator_), SE2Near(pose, kTolerance)); +} + TEST_F(OmnidirectionalDriveModelTest, Translate) { constexpr double kTolerance = 0.001; const auto control_action = std::make_tuple(SE2d{SO2d{0.0}, Vector2d{1.0, 0.0}}, SE2d{SO2d{0.0}, Vector2d{0.0, 0.0}}); diff --git a/beluga_amcl/docs/ros2-reference.md b/beluga_amcl/docs/ros2-reference.md index aeb88b4171..cf1f0fbf49 100644 --- a/beluga_amcl/docs/ros2-reference.md +++ b/beluga_amcl/docs/ros2-reference.md @@ -121,29 +121,40 @@ Also available as a standalone `amcl_node` executable. ##### Motion Model Parameters `robot_model_type` _(`string`)_ -: Which odometry motion model to use. Supported models are `differential_drive` {cite}`thrun2005probabilistic`, `omnidirectional_drive` and `stationary`. +: Which odometry motion model to use. Supported models are `differential_drive` {cite}`thrun2005probabilistic`, `omnidirectional_drive`, `ackermann_drive` and `stationary`. : Defaults to `differential_drive`. `alpha1` _(`float`)_ -: Expected process noise in odometry’s rotation estimate from rotation for the `differential_drive` and `omnidirectional_drive` models. Must be nonnegative. +: Expected process noise in odometry’s rotation estimate from rotation for the `differential_drive`, `ackermann_drive` and `omnidirectional_drive` models. Must be nonnegative. : Defaults to `0.2`. `alpha2` _(`float`)_ -: Expected process noise in odometry’s rotation estimate from translation for the `differential_drive` and `omnidirectional_drive` models. Must be nonnegative. +: Expected process noise in odometry’s rotation estimate from translation for the `differential_drive`, `ackermann_drive` and `omnidirectional_drive` models. Must be nonnegative. : Defaults to `0.2`. `alpha3` _(`float`)_ -: Expected process noise in odometry’s translation estimate from translation for the `differential_drive` and `omnidirectional_drive` models. Must be nonnegative. +: Expected process noise in odometry’s translation estimate from translation for the `differential_drive`, `ackermann_drive` and `omnidirectional_drive` models. Must be nonnegative. : Defaults to `0.2`. `alpha4` _(`float`)_ -: Expected process noise in odometry’s translation estimate from rotation for the `differential_drive` and `omnidirectional_drive` models. Must be nonnegative. +: Expected process noise in odometry’s translation estimate from rotation for the `differential_drive`, `ackermann_drive` and `omnidirectional_drive` models. Must be nonnegative. : Defaults to `0.2`. `alpha5` _(`float`)_ : Expected process noise in odometry's strafe estimate from translation for the `omnidirectional_drive` model. Must be nonnegative. : Defaults to `0.2`. +`alpha6` _(`float`)_ +: Expected process noise in odometry's orientation noise from translational velocity for the `ackermann_drive` model. Must be nonnegative. +: Defaults to `0.2`. + +`alpha7` _(`float`)_ +: Expected process noise in odometry's orientation noise from rotational velocity for the `ackermann_drive` model. Must be nonnegative. +: Defaults to `0.2`. + +`wheelbase` _(`float`)_ +: Expected length of the robot for the `ackermann_drive` model. Must be nonnegative. +: Defaults to `0.5`. ##### Observation Model Parameters `laser_model_type` _(`string`)_ diff --git a/beluga_amcl/include/beluga_amcl/amcl_node.hpp b/beluga_amcl/include/beluga_amcl/amcl_node.hpp index 615acf2647..0842aa62c7 100644 --- a/beluga_amcl/include/beluga_amcl/amcl_node.hpp +++ b/beluga_amcl/include/beluga_amcl/amcl_node.hpp @@ -43,7 +43,6 @@ #include #include "beluga_amcl/message_filters.hpp" #include "beluga_amcl/ros2_common.hpp" - /** * \file * \brief ROS 2 integration of the 2D AMCL algorithm. @@ -61,8 +60,8 @@ class AmclNode : public BaseAMCLNode { ~AmclNode() override; protected: - /// Type Buffer for queued odometry motions (timestamp, pose) - using OdometryMotion = std::pair; + /// Type Buffer for queued odometry motions using TimeStamped structure + using OdometryMotion = beluga::TimeStamped; /// Callback for lifecycle transitions from the INACTIVE state to the ACTIVE state. void do_activate(const rclcpp_lifecycle::State&) override; diff --git a/beluga_amcl/include/beluga_amcl/ros2_common.hpp b/beluga_amcl/include/beluga_amcl/ros2_common.hpp index 360060e4e9..835519e804 100644 --- a/beluga_amcl/include/beluga_amcl/ros2_common.hpp +++ b/beluga_amcl/include/beluga_amcl/ros2_common.hpp @@ -48,6 +48,8 @@ constexpr std::string_view kStationaryModelName = "stationary"; constexpr std::string_view kNav2DifferentialModelName = "nav2_amcl::DifferentialMotionModel"; /// String identifier for a omnidirectional model name. constexpr std::string_view kNav2OmnidirectionalModelName = "nav2_amcl::OmniMotionModel"; +/// String identifier for a ackermann drive model. +constexpr std::string_view kAckermannDriveModelName = "ackermann_drive"; /// Supported execution policies. using ExecutionPolicyVariant = std::variant; diff --git a/beluga_amcl/src/amcl_node.cpp b/beluga_amcl/src/amcl_node.cpp index 5e3d9012fa..74dd48f6b1 100644 --- a/beluga_amcl/src/amcl_node.cpp +++ b/beluga_amcl/src/amcl_node.cpp @@ -57,6 +57,7 @@ #include #include +#include #include #include #include @@ -335,6 +336,17 @@ auto AmclNode::get_motion_model(std::string_view name) const -> beluga_ros::Amcl params.strafe_noise_from_translation = get_parameter("alpha5").as_double(); return beluga::OmnidirectionalDriveModel{params}; } + if (name == kAckermannDriveModelName) { + auto params = beluga::AckermannDriveModelParam{}; + params.steering_noise_from_steering = get_parameter("alpha1").as_double(); + params.steering_noise_from_velocity = get_parameter("alpha2").as_double(); + params.velocity_noise_from_velocity = get_parameter("alpha3").as_double(); + params.velocity_noise_from_steering = get_parameter("alpha4").as_double(); + params.orientation_noise_from_velocity = get_parameter("alpha6").as_double(); + params.orientation_noise_from_steering = get_parameter("alpha7").as_double(); + params.wheelbase = get_parameter("wheelbase").as_double(); + return beluga::AckermannDriveModel{params}; + } if (name == kStationaryModelName) { return beluga::StationaryModel{}; } @@ -497,16 +509,16 @@ void AmclNode::odometry_callback(nav_msgs::msg::Odometry::ConstSharedPtr odom) { const auto time = tf2_ros::fromMsg(odom->header.stamp); auto base_pose_in_odom = Sophus::SE2d{}; tf2::convert(odom->pose.pose, base_pose_in_odom); - odometry_motion_buffer_.emplace_back(time, base_pose_in_odom); + odometry_motion_buffer_.emplace_back(base_pose_in_odom, time); } void AmclNode::process_buffered_odometry_until(const tf2::TimePoint& until) { while (!odometry_motion_buffer_.empty()) { - const auto& [odom_time, odom_pose] = odometry_motion_buffer_.front(); - if (odom_time > until) { + const auto& timestamped = odometry_motion_buffer_.front(); + if (timestamped.timestamp > until) { break; } - particle_filter_->update(odom_pose); + particle_filter_->update(timestamped); odometry_motion_buffer_.pop_front(); } } @@ -518,22 +530,15 @@ void AmclNode::laser_callback(sensor_msgs::msg::LaserScan::ConstSharedPtr laser_ return; } - // If use_odometry_propagation is enabled, process odometry buffer up to lidar timestamp - const auto laser_scan_stamp = tf2_ros::fromMsg(laser_scan->header.stamp); - process_buffered_odometry_until(laser_scan_stamp); - // Get base pose in odom frame at laser scan timestamp - auto base_pose_in_odom = Sophus::SE2d{}; + auto base_pose_in_odom = beluga::TimeStamped{}; try { // Use the lookupTransform overload with no timeout since we're not using a dedicated // tf thread. The message filter we are using avoids the need for it. - tf2::convert( - tf_buffer_ - ->lookupTransform( - get_parameter("odom_frame_id").as_string(), get_parameter("base_frame_id").as_string(), - tf2_ros::fromMsg(laser_scan->header.stamp)) - .transform, - base_pose_in_odom); + const auto odom_to_base_transform = tf_buffer_->lookupTransform( + get_parameter("odom_frame_id").as_string(), get_parameter("base_frame_id").as_string(), + tf2_ros::fromMsg(laser_scan->header.stamp)); + tf2::convert(odom_to_base_transform, base_pose_in_odom); } catch (const tf2::TransformException& error) { RCLCPP_ERROR(get_logger(), "Could not transform from odom to base: %s", error.what()); return; @@ -553,6 +558,9 @@ void AmclNode::laser_callback(sensor_msgs::msg::LaserScan::ConstSharedPtr laser_ return; } + // If use_odometry_propagation is enabled, process odometry buffer up to lidar timestamp + process_buffered_odometry_until(base_pose_in_odom.timestamp); + const auto update_start_time = std::chrono::high_resolution_clock::now(); const auto new_estimate = particle_filter_->update( base_pose_in_odom, // @@ -568,7 +576,7 @@ void AmclNode::laser_callback(sensor_msgs::msg::LaserScan::ConstSharedPtr laser_ if (new_estimate.has_value()) { const auto& [base_pose_in_map, _] = new_estimate.value(); - last_known_odom_transform_in_map_ = base_pose_in_map * base_pose_in_odom.inverse(); + last_known_odom_transform_in_map_ = base_pose_in_map * base_pose_in_odom.value.inverse(); last_known_estimate_ = new_estimate; RCLCPP_INFO( diff --git a/beluga_amcl/src/amcl_nodelet.cpp b/beluga_amcl/src/amcl_nodelet.cpp index d84c868015..d814df07bc 100644 --- a/beluga_amcl/src/amcl_nodelet.cpp +++ b/beluga_amcl/src/amcl_nodelet.cpp @@ -395,13 +395,13 @@ void AmclNodelet::laser_callback(const sensor_msgs::LaserScan::ConstPtr& laser_s return; } - auto base_pose_in_odom = Sophus::SE2d{}; + auto base_pose_in_odom = beluga::TimeStamped{}; try { // Use the lookupTransform overload with no timeout since we're not using a dedicated // tf thread. The message filter we are using avoids the need for it. - tf2::convert( - tf_buffer_->lookupTransform(config_.odom_frame_id, config_.base_frame_id, laser_scan->header.stamp).transform, - base_pose_in_odom); + const auto odom_to_base_transform = + tf_buffer_->lookupTransform(config_.odom_frame_id, config_.base_frame_id, laser_scan->header.stamp); + tf2::convert(odom_to_base_transform, base_pose_in_odom); } catch (const tf2::TransformException& error) { NODELET_ERROR("Could not transform from odom to base: %s", error.what()); return; @@ -417,7 +417,6 @@ void AmclNodelet::laser_callback(const sensor_msgs::LaserScan::ConstPtr& laser_s NODELET_ERROR("Could not transform from base to laser: %s", error.what()); return; } - const auto update_start_time = std::chrono::high_resolution_clock::now(); const auto new_estimate = particle_filter_->update( base_pose_in_odom, // @@ -433,7 +432,7 @@ void AmclNodelet::laser_callback(const sensor_msgs::LaserScan::ConstPtr& laser_s if (new_estimate.has_value()) { const auto& [base_pose_in_map, _] = new_estimate.value(); - last_known_odom_transform_in_map_ = base_pose_in_map * base_pose_in_odom.inverse(); + last_known_odom_transform_in_map_ = base_pose_in_map * base_pose_in_odom.value.inverse(); last_known_estimate_ = new_estimate; NODELET_INFO( diff --git a/beluga_amcl/src/ndt_amcl_node.cpp b/beluga_amcl/src/ndt_amcl_node.cpp index d3e6bb1638..3034000c3f 100644 --- a/beluga_amcl/src/ndt_amcl_node.cpp +++ b/beluga_amcl/src/ndt_amcl_node.cpp @@ -289,17 +289,14 @@ void NdtAmclNode::laser_callback(sensor_msgs::msg::LaserScan::ConstSharedPtr las return; } - auto base_pose_in_odom = Sophus::SE2d{}; + auto base_pose_in_odom = beluga::TimeStamped{}; try { // Use the lookupTransform overload with no timeout since we're not using a dedicated // tf thread. The message filter we are using avoids the need for it. - tf2::convert( - tf_buffer_ - ->lookupTransform( - get_parameter("odom_frame_id").as_string(), get_parameter("base_frame_id").as_string(), - tf2_ros::fromMsg(laser_scan->header.stamp)) - .transform, - base_pose_in_odom); + const auto odom_to_base_transform = tf_buffer_->lookupTransform( + get_parameter("odom_frame_id").as_string(), get_parameter("base_frame_id").as_string(), + tf2_ros::fromMsg(laser_scan->header.stamp)); + tf2::convert(odom_to_base_transform, base_pose_in_odom); } catch (const tf2::TransformException& error) { RCLCPP_ERROR(get_logger(), "Could not transform from odom to base: %s", error.what()); return; @@ -343,7 +340,7 @@ void NdtAmclNode::laser_callback(sensor_msgs::msg::LaserScan::ConstSharedPtr las if (new_estimate.has_value()) { const auto& [base_pose_in_map, _] = new_estimate.value(); - last_known_odom_transform_in_map_ = base_pose_in_map * base_pose_in_odom.inverse(); + last_known_odom_transform_in_map_ = base_pose_in_map * base_pose_in_odom.value.inverse(); last_known_estimate_ = new_estimate; const auto num_particles = diff --git a/beluga_amcl/src/ndt_amcl_node_3d.cpp b/beluga_amcl/src/ndt_amcl_node_3d.cpp index e26efd82bf..e87b70c344 100644 --- a/beluga_amcl/src/ndt_amcl_node_3d.cpp +++ b/beluga_amcl/src/ndt_amcl_node_3d.cpp @@ -356,18 +356,15 @@ void NdtAmclNode3D::laser_callback(sensor_msgs::msg::PointCloud2::ConstSharedPtr return; } - auto base_pose_in_odom = Sophus::SE3d{}; + auto base_pose_in_odom = beluga::TimeStamped{}; auto laser_pose_in_base = Sophus::SE3d{}; try { // Use the lookupTransform overload with no timeout since we're not using a dedicated // tf thread. The message filter we are using avoids the need for it. - tf2::convert( - tf_buffer_ - ->lookupTransform( - get_parameter("odom_frame_id").as_string(), get_parameter("base_frame_id").as_string(), - tf2_ros::fromMsg(laser_scan->header.stamp)) - .transform, - base_pose_in_odom); + const auto odom_to_base_transform = tf_buffer_->lookupTransform( + get_parameter("odom_frame_id").as_string(), get_parameter("base_frame_id").as_string(), + tf2_ros::fromMsg(laser_scan->header.stamp)); + tf2::convert(odom_to_base_transform, base_pose_in_odom); tf2::convert( tf_buffer_ ->lookupTransform( @@ -393,7 +390,6 @@ void NdtAmclNode3D::laser_callback(sensor_msgs::msg::PointCloud2::ConstSharedPtr for (; iter_x != iter_x.end() && iter_y != iter_y.end() && iter_z != iter_z.end(); ++iter_x, ++iter_y, ++iter_z) { measurement.emplace_back(laser_pose_in_base * Eigen::Vector3d{*iter_x, *iter_y, *iter_z}); }; - RCLCPP_WARN_THROTTLE(get_logger(), *get_clock(), 2000, "Processing %ld points.", measurement.size()); const auto new_estimate = std::visit( [base_pose_in_odom, measurement = measurement](auto& particle_filter) { @@ -408,7 +404,7 @@ void NdtAmclNode3D::laser_callback(sensor_msgs::msg::PointCloud2::ConstSharedPtr if (new_estimate.has_value()) { const auto& [base_pose_in_map, _] = new_estimate.value(); - last_known_odom_transform_in_map_ = base_pose_in_map * base_pose_in_odom.inverse(); + last_known_odom_transform_in_map_ = base_pose_in_map * base_pose_in_odom.value.inverse(); last_known_estimate_ = new_estimate; const auto num_particles = diff --git a/beluga_amcl/src/ros2_common.cpp b/beluga_amcl/src/ros2_common.cpp index a5cc70bd38..8100c8b60b 100644 --- a/beluga_amcl/src/ros2_common.cpp +++ b/beluga_amcl/src/ros2_common.cpp @@ -270,6 +270,36 @@ BaseAMCLNode::BaseAMCLNode( this->declare_parameter("alpha5", rclcpp::ParameterValue(0.2), descriptor); } + { + auto descriptor = rcl_interfaces::msg::ParameterDescriptor(); + descriptor.description = "Orientation noise from translational velocity for the Ackermann drive model."; + descriptor.floating_point_range.resize(1); + descriptor.floating_point_range[0].from_value = 0; + descriptor.floating_point_range[0].to_value = std::numeric_limits::max(); + descriptor.floating_point_range[0].step = 0; + this->declare_parameter("alpha6", rclcpp::ParameterValue(0.2), descriptor); + } + + { + auto descriptor = rcl_interfaces::msg::ParameterDescriptor(); + descriptor.description = "Orientation noise from rotational velocity for the Ackermann drive model."; + descriptor.floating_point_range.resize(1); + descriptor.floating_point_range[0].from_value = 0; + descriptor.floating_point_range[0].to_value = std::numeric_limits::max(); + descriptor.floating_point_range[0].step = 0; + this->declare_parameter("alpha7", rclcpp::ParameterValue(0.2), descriptor); + } + + { + auto descriptor = rcl_interfaces::msg::ParameterDescriptor(); + descriptor.description = "Length of the robot for the Ackermann drive model."; + descriptor.floating_point_range.resize(1); + descriptor.floating_point_range[0].from_value = 0; + descriptor.floating_point_range[0].to_value = std::numeric_limits::max(); + descriptor.floating_point_range[0].step = 0; + this->declare_parameter("wheelbase", rclcpp::ParameterValue(0.5), descriptor); + } + { auto descriptor = rcl_interfaces::msg::ParameterDescriptor(); descriptor.description = "Rotational movement required before performing a filter update."; diff --git a/beluga_ros/include/beluga_ros/amcl.hpp b/beluga_ros/include/beluga_ros/amcl.hpp index ff36df64d3..8c54f00dc2 100644 --- a/beluga_ros/include/beluga_ros/amcl.hpp +++ b/beluga_ros/include/beluga_ros/amcl.hpp @@ -33,6 +33,7 @@ #include #include #include +#include #include #include @@ -107,7 +108,8 @@ class Amcl { using motion_model_variant = std::variant< beluga::DifferentialDriveModel2d, // beluga::OmnidirectionalDriveModel, // - beluga::StationaryModel>; + beluga::StationaryModel, // + beluga::AckermannDriveModel2d>; /// Sensor model variant type for runtime selection support. using sensor_model_variant = std::variant< @@ -218,7 +220,7 @@ class Amcl { * * \param base_pose_in_odom Base pose in the odometry frame. */ - void update(Sophus::SE2d base_pose_in_odom); + void update(beluga::TimeStamped base_pose_in_odom); /// Update particles based on motion and sensor information. /** @@ -233,7 +235,7 @@ class Amcl { * \return An optional pair containing the estimated pose and covariance after the update, * or std::nullopt if no update was performed. */ - auto update(Sophus::SE2d base_pose_in_odom, beluga_ros::LaserScan laser_scan) + auto update(beluga::TimeStamped base_pose_in_odom, beluga_ros::LaserScan laser_scan) -> std::optional>; /// Force a manual update of the particles on the next iteration of the filter. @@ -253,7 +255,7 @@ class Amcl { beluga::any_policy update_policy_; beluga::any_policy resample_policy_; - beluga::RollingWindow control_action_window_; + beluga::RollingWindow, 2> control_action_window_; bool force_update_{true}; }; diff --git a/beluga_ros/include/beluga_ros/tf2_sophus.hpp b/beluga_ros/include/beluga_ros/tf2_sophus.hpp index bc3f81e865..d444ce8ebd 100644 --- a/beluga_ros/include/beluga_ros/tf2_sophus.hpp +++ b/beluga_ros/include/beluga_ros/tf2_sophus.hpp @@ -17,17 +17,22 @@ #include #include +#include #include #include #include #include #if BELUGA_ROS_VERSION == 2 +#include +#include #include #include #elif BELUGA_ROS_VERSION == 1 +#include #include #include +#include #else #error BELUGA_ROS_VERSION is not defined or invalid #endif @@ -192,6 +197,12 @@ inline void fromMsg(const beluga_ros::msg::Transform& msg, Sophus::SE3& }}; } +/// Converts a Transform message to a TimeStamped Sophus SE3d with current time +inline void fromMsg(const beluga_ros::msg::Transform& msg, beluga::TimeStamped& out) { + Sophus::SE3d pose; + fromMsg(msg, pose); // Use existing Transform -> SE3d conversion + out = beluga::TimeStamped{pose, std::chrono::system_clock::now()}; +} /// Converts a Pose message type to a Sophus SE2 type. /** * This function is a specialization of the fromMsg template defined in tf2/convert.h. @@ -372,6 +383,20 @@ inline void fromMsg(const beluga_ros::msg::Pose& message, Sophus::SE3& p tf2::fromMsg(message, pose); } +/// Converts TransformStamped to TimeStamped Sophus types +// Handle the exact type that lookupTransform returns +inline void fromMsg(const geometry_msgs::msg::TransformStamped& msg, beluga::TimeStamped>& out) { + Sophus::SE2 pose; + fromMsg(msg.transform, pose); + out = beluga::TimeStamped>{pose, tf2_ros::fromMsg(msg.header.stamp)}; +} + +inline void fromMsg(const geometry_msgs::msg::TransformStamped& msg, beluga::TimeStamped>& out) { + Sophus::SE3 pose; + fromMsg(msg.transform, pose); + out = beluga::TimeStamped>{pose, tf2_ros::fromMsg(msg.header.stamp)}; +} + } // namespace Sophus #endif // BELUGA_ROS_TF2_SOPHUS_HPP diff --git a/beluga_ros/src/amcl.cpp b/beluga_ros/src/amcl.cpp index f92b7d4fb0..e753f03c14 100644 --- a/beluga_ros/src/amcl.cpp +++ b/beluga_ros/src/amcl.cpp @@ -50,23 +50,24 @@ void Amcl::update_map(beluga_ros::OccupancyGrid map) { std::visit([&](auto& sensor_model) { sensor_model.update_map(std::move(map)); }, sensor_model_); } -void Amcl::update(Sophus::SE2d base_pose_in_odom) { +void Amcl::update(beluga::TimeStamped base_pose_timestamp_in_odom) { if (!particles_.empty()) { std::visit( [&, this](auto& policy, auto& motion_model) { - particles_ |= beluga::actions::propagate(policy, motion_model(control_action_window_ << base_pose_in_odom)); + particles_ |= + beluga::actions::propagate(policy, motion_model(control_action_window_ << base_pose_timestamp_in_odom)); }, execution_policy_, motion_model_); } } -auto Amcl::update(Sophus::SE2d base_pose_in_odom, beluga_ros::LaserScan laser_scan) +auto Amcl::update(beluga::TimeStamped base_pose_timestamp_in_odom, beluga_ros::LaserScan laser_scan) -> std::optional> { if (particles_.empty()) { return std::nullopt; } - if (!update_policy_(base_pose_in_odom) && !force_update_) { + if (!update_policy_(base_pose_timestamp_in_odom) && !force_update_) { return std::nullopt; } @@ -80,10 +81,10 @@ auto Amcl::update(Sophus::SE2d base_pose_in_odom, beluga_ros::LaserScan laser_sc std::visit( [&, this](auto& policy, auto& motion_model, auto& sensor_model) { - particles_ |= - beluga::actions::propagate(policy, motion_model(control_action_window_ << base_pose_in_odom)) | // - beluga::actions::reweight(policy, sensor_model(std::move(measurement))) | // - beluga::actions::normalize(policy); + particles_ |= beluga::actions::propagate( + policy, motion_model(control_action_window_ << base_pose_timestamp_in_odom)) | // + beluga::actions::reweight(policy, sensor_model(std::move(measurement))) | // + beluga::actions::normalize(policy); }, execution_policy_, motion_model_, sensor_model_); diff --git a/beluga_ros/test/test_amcl.cpp b/beluga_ros/test/test_amcl.cpp index fa1742e15f..8fdeb389f6 100644 --- a/beluga_ros/test/test_amcl.cpp +++ b/beluga_ros/test/test_amcl.cpp @@ -102,7 +102,7 @@ TEST(TestAmcl, InitializeFromPose) { TEST(TestAmcl, UpdateWithNoParticles) { auto amcl = make_amcl(); ASSERT_EQ(amcl.particles().size(), 0); - auto estimate = amcl.update(Sophus::SE2d{}, make_dummy_laser_scan()); + auto estimate = amcl.update(beluga::TimeStamped{}, make_dummy_laser_scan()); ASSERT_FALSE(estimate.has_value()); } @@ -111,7 +111,7 @@ TEST(TestAmcl, UpdateWithParticles) { ASSERT_EQ(amcl.particles().size(), 0); amcl.initialize_from_map(); ASSERT_EQ(amcl.particles().size(), 50UL); - auto estimate = amcl.update(Sophus::SE2d{}, make_dummy_laser_scan()); + auto estimate = amcl.update(beluga::TimeStamped{}, make_dummy_laser_scan()); ASSERT_TRUE(estimate.has_value()); } @@ -120,9 +120,9 @@ TEST(TestAmcl, UpdateWithParticlesWithMotion) { ASSERT_EQ(amcl.particles().size(), 0); amcl.initialize_from_map(); ASSERT_EQ(amcl.particles().size(), 50UL); - auto estimate = amcl.update(Sophus::SE2d{}, make_dummy_laser_scan()); + auto estimate = amcl.update(beluga::TimeStamped{}, make_dummy_laser_scan()); ASSERT_TRUE(estimate.has_value()); - estimate = amcl.update(Sophus::SE2d{0.0, {1.0, 0.0}}, make_dummy_laser_scan()); + estimate = amcl.update(beluga::TimeStamped{Sophus::SE2d{0.0, {1.0, 0.0}}}, make_dummy_laser_scan()); ASSERT_TRUE(estimate.has_value()); } @@ -131,9 +131,9 @@ TEST(TestAmcl, UpdateWithParticlesNoMotion) { ASSERT_EQ(amcl.particles().size(), 0); amcl.initialize_from_map(); ASSERT_EQ(amcl.particles().size(), 50UL); - auto estimate = amcl.update(Sophus::SE2d{}, make_dummy_laser_scan()); + auto estimate = amcl.update(beluga::TimeStamped{}, make_dummy_laser_scan()); ASSERT_TRUE(estimate.has_value()); - estimate = amcl.update(Sophus::SE2d{}, make_dummy_laser_scan()); + estimate = amcl.update(beluga::TimeStamped{}, make_dummy_laser_scan()); ASSERT_FALSE(estimate.has_value()); } @@ -142,10 +142,10 @@ TEST(TestAmcl, UpdateWithParticlesForced) { ASSERT_EQ(amcl.particles().size(), 0); amcl.initialize_from_map(); ASSERT_EQ(amcl.particles().size(), 50UL); - auto estimate = amcl.update(Sophus::SE2d{}, make_dummy_laser_scan()); + auto estimate = amcl.update(beluga::TimeStamped{}, make_dummy_laser_scan()); ASSERT_TRUE(estimate.has_value()); amcl.force_update(); - estimate = amcl.update(Sophus::SE2d{}, make_dummy_laser_scan()); + estimate = amcl.update(beluga::TimeStamped{}, make_dummy_laser_scan()); ASSERT_TRUE(estimate.has_value()); } diff --git a/beluga_system_tests/test/test_system.cpp b/beluga_system_tests/test/test_system.cpp index c1b181579a..e4c8a559a0 100644 --- a/beluga_system_tests/test/test_system.cpp +++ b/beluga_system_tests/test/test_system.cpp @@ -136,7 +136,7 @@ void particle_filter_test( std::size_t update_count = 0; for (auto [measurement, odom, ground_truth] : datapoints) { - const auto estimate = filter.update(std::move(odom), std::move(measurement)); + const auto estimate = filter.update(beluga::TimeStamped{std::move(odom)}, std::move(measurement)); if (!estimate.has_value()) { continue;