Add exploration module with path following and collision handling
- Add exploration controller with frontier-based autonomous exploration - Add path follower with pure-pursuit algorithm and velocity commands - Add collision detection with virtual walls and recovery behaviors - Fix wheel odometry to return robot-frame deltas (not world-frame) - Add step-by-step simulation API in test harness for exploration tests
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
//! Collision event handling for exploration.
|
||||
//!
|
||||
//! Provides types for representing collision events from sensors
|
||||
//! and computing virtual walls to add to the map.
|
||||
|
||||
use crate::core::Point2D;
|
||||
use crate::features::Line2D;
|
||||
use std::f32::consts::FRAC_PI_2;
|
||||
|
||||
/// Type of collision detected by sensors.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CollisionType {
|
||||
/// Left bumper triggered.
|
||||
BumperLeft,
|
||||
/// Right bumper triggered.
|
||||
BumperRight,
|
||||
/// Both bumpers triggered (head-on collision).
|
||||
BumperBoth,
|
||||
/// Cliff sensor triggered.
|
||||
Cliff,
|
||||
}
|
||||
|
||||
impl CollisionType {
|
||||
/// Check if this is a bumper collision.
|
||||
pub fn is_bumper(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
CollisionType::BumperLeft | CollisionType::BumperRight | CollisionType::BumperBoth
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is a cliff detection.
|
||||
pub fn is_cliff(&self) -> bool {
|
||||
matches!(self, CollisionType::Cliff)
|
||||
}
|
||||
}
|
||||
|
||||
/// Collision event from sensors.
|
||||
///
|
||||
/// This struct represents a collision detected by the robot's sensors.
|
||||
/// It is provided by DhruvaSLAM (or the test harness) to the exploration
|
||||
/// controller, which uses it to update the map and plan recovery.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CollisionEvent {
|
||||
/// Type of collision detected.
|
||||
pub collision_type: CollisionType,
|
||||
/// Estimated collision point in world frame.
|
||||
pub point: Point2D,
|
||||
/// Robot heading at time of collision (radians).
|
||||
pub heading: f32,
|
||||
}
|
||||
|
||||
impl CollisionEvent {
|
||||
/// Create a new collision event.
|
||||
pub fn new(collision_type: CollisionType, point: Point2D, heading: f32) -> Self {
|
||||
Self {
|
||||
collision_type,
|
||||
point,
|
||||
heading,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a bumper collision event from robot pose.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `left` - Left bumper triggered
|
||||
/// * `right` - Right bumper triggered
|
||||
/// * `robot_x` - Robot X position
|
||||
/// * `robot_y` - Robot Y position
|
||||
/// * `robot_theta` - Robot heading
|
||||
/// * `robot_radius` - Robot collision radius
|
||||
pub fn from_bumper(
|
||||
left: bool,
|
||||
right: bool,
|
||||
robot_x: f32,
|
||||
robot_y: f32,
|
||||
robot_theta: f32,
|
||||
robot_radius: f32,
|
||||
) -> Option<Self> {
|
||||
let collision_type = match (left, right) {
|
||||
(true, true) => CollisionType::BumperBoth,
|
||||
(true, false) => CollisionType::BumperLeft,
|
||||
(false, true) => CollisionType::BumperRight,
|
||||
(false, false) => return None,
|
||||
};
|
||||
|
||||
// Compute collision point slightly ahead of robot
|
||||
let offset = robot_radius + 0.02; // Small offset past radius
|
||||
let point = Point2D::new(
|
||||
robot_x + offset * robot_theta.cos(),
|
||||
robot_y + offset * robot_theta.sin(),
|
||||
);
|
||||
|
||||
Some(Self {
|
||||
collision_type,
|
||||
point,
|
||||
heading: robot_theta,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute virtual wall to add at collision point.
|
||||
///
|
||||
/// Creates a wall perpendicular to the robot's heading at the
|
||||
/// collision point. This prevents future path planning through
|
||||
/// areas that are physically blocked (e.g., glass/mirrors).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `wall_length` - Length of the virtual wall to create
|
||||
pub fn to_virtual_wall(&self, wall_length: f32) -> VirtualWall {
|
||||
// Wall is perpendicular to heading
|
||||
let perp_angle = self.heading + FRAC_PI_2;
|
||||
let half_len = wall_length / 2.0;
|
||||
|
||||
let start = Point2D::new(
|
||||
self.point.x + half_len * perp_angle.cos(),
|
||||
self.point.y + half_len * perp_angle.sin(),
|
||||
);
|
||||
let end = Point2D::new(
|
||||
self.point.x - half_len * perp_angle.cos(),
|
||||
self.point.y - half_len * perp_angle.sin(),
|
||||
);
|
||||
|
||||
VirtualWall {
|
||||
line: Line2D::new(start, end),
|
||||
source: self.collision_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the opposite direction for backoff.
|
||||
///
|
||||
/// Returns the heading angle pointing away from the collision.
|
||||
pub fn backoff_heading(&self) -> f32 {
|
||||
// Back off in the opposite direction
|
||||
self.heading + std::f32::consts::PI
|
||||
}
|
||||
}
|
||||
|
||||
/// Virtual wall to add to map after collision.
|
||||
///
|
||||
/// When the robot collides with an obstacle that wasn't detected by lidar
|
||||
/// (e.g., glass, mirrors), a virtual wall is added to the map to prevent
|
||||
/// future path planning through that area.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VirtualWall {
|
||||
/// The line segment representing the wall.
|
||||
pub line: Line2D,
|
||||
/// The collision type that caused this wall to be created.
|
||||
pub source: CollisionType,
|
||||
}
|
||||
|
||||
impl VirtualWall {
|
||||
/// Create a new virtual wall.
|
||||
pub fn new(line: Line2D, source: CollisionType) -> Self {
|
||||
Self { line, source }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f32::consts::PI;
|
||||
|
||||
#[test]
|
||||
fn test_collision_type_checks() {
|
||||
assert!(CollisionType::BumperLeft.is_bumper());
|
||||
assert!(CollisionType::BumperRight.is_bumper());
|
||||
assert!(CollisionType::BumperBoth.is_bumper());
|
||||
assert!(!CollisionType::Cliff.is_bumper());
|
||||
|
||||
assert!(CollisionType::Cliff.is_cliff());
|
||||
assert!(!CollisionType::BumperLeft.is_cliff());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_bumper() {
|
||||
// No collision
|
||||
assert!(CollisionEvent::from_bumper(false, false, 0.0, 0.0, 0.0, 0.15).is_none());
|
||||
|
||||
// Left bumper
|
||||
let event = CollisionEvent::from_bumper(true, false, 1.0, 2.0, 0.0, 0.15).unwrap();
|
||||
assert_eq!(event.collision_type, CollisionType::BumperLeft);
|
||||
assert!(event.point.x > 1.0); // Point is ahead of robot
|
||||
|
||||
// Both bumpers
|
||||
let event = CollisionEvent::from_bumper(true, true, 0.0, 0.0, PI / 2.0, 0.15).unwrap();
|
||||
assert_eq!(event.collision_type, CollisionType::BumperBoth);
|
||||
assert!(event.point.y > 0.0); // Point is ahead when facing +Y
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_virtual_wall() {
|
||||
let event = CollisionEvent::new(CollisionType::BumperBoth, Point2D::new(1.0, 0.0), 0.0);
|
||||
|
||||
let wall = event.to_virtual_wall(0.3);
|
||||
|
||||
// Wall should be perpendicular to heading (vertical when heading is 0)
|
||||
assert_eq!(wall.source, CollisionType::BumperBoth);
|
||||
assert!((wall.line.start.x - 1.0).abs() < 0.01);
|
||||
assert!((wall.line.end.x - 1.0).abs() < 0.01);
|
||||
assert!((wall.line.start.y - 0.15).abs() < 0.01);
|
||||
assert!((wall.line.end.y + 0.15).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backoff_heading() {
|
||||
let event = CollisionEvent::new(CollisionType::BumperBoth, Point2D::new(0.0, 0.0), 0.0);
|
||||
let backoff = event.backoff_heading();
|
||||
assert!((backoff - PI).abs() < 0.01);
|
||||
|
||||
let event2 =
|
||||
CollisionEvent::new(CollisionType::BumperBoth, Point2D::new(0.0, 0.0), PI / 2.0);
|
||||
let backoff2 = event2.backoff_heading();
|
||||
assert!((backoff2 - 3.0 * PI / 2.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Configuration for exploration behavior.
|
||||
|
||||
use crate::query::frontier::FrontierConfig;
|
||||
use crate::query::path_planning::PathPlanningConfig;
|
||||
|
||||
/// Configuration for exploration behavior.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExplorationConfig {
|
||||
/// Length of virtual walls added at collision points (meters).
|
||||
/// Default: 0.3
|
||||
pub virtual_wall_length: f32,
|
||||
|
||||
/// Distance to back off after collision (meters).
|
||||
/// Default: 0.15
|
||||
pub backoff_distance: f32,
|
||||
|
||||
/// Distance threshold to consider waypoint reached (meters).
|
||||
/// Default: 0.1
|
||||
pub waypoint_tolerance: f32,
|
||||
|
||||
/// Maximum linear velocity (m/s).
|
||||
/// Default: 0.3
|
||||
pub max_linear_speed: f32,
|
||||
|
||||
/// Maximum angular velocity (rad/s).
|
||||
/// Default: 1.0
|
||||
pub max_angular_speed: f32,
|
||||
|
||||
/// Robot radius for path planning clearance (meters).
|
||||
/// Default: 0.15
|
||||
pub robot_radius: f32,
|
||||
|
||||
/// Lookahead distance for pure pursuit path following (meters).
|
||||
/// Default: 0.3
|
||||
pub lookahead_distance: f32,
|
||||
|
||||
/// Frontier detection configuration.
|
||||
pub frontier_config: FrontierConfig,
|
||||
|
||||
/// Path planning configuration.
|
||||
pub path_planning_config: PathPlanningConfig,
|
||||
}
|
||||
|
||||
impl Default for ExplorationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
virtual_wall_length: 0.3,
|
||||
backoff_distance: 0.15,
|
||||
waypoint_tolerance: 0.1,
|
||||
max_linear_speed: 0.3,
|
||||
max_angular_speed: 1.0,
|
||||
robot_radius: 0.15,
|
||||
lookahead_distance: 0.3,
|
||||
frontier_config: FrontierConfig::default(),
|
||||
path_planning_config: PathPlanningConfig::default().with_robot_radius(0.15),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExplorationConfig {
|
||||
/// Create a new configuration with default values.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Builder-style setter for virtual wall length.
|
||||
pub fn with_virtual_wall_length(mut self, length: f32) -> Self {
|
||||
self.virtual_wall_length = length;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for backoff distance.
|
||||
pub fn with_backoff_distance(mut self, distance: f32) -> Self {
|
||||
self.backoff_distance = distance;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for waypoint tolerance.
|
||||
pub fn with_waypoint_tolerance(mut self, tolerance: f32) -> Self {
|
||||
self.waypoint_tolerance = tolerance;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for max linear speed.
|
||||
pub fn with_max_linear_speed(mut self, speed: f32) -> Self {
|
||||
self.max_linear_speed = speed;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for max angular speed.
|
||||
pub fn with_max_angular_speed(mut self, speed: f32) -> Self {
|
||||
self.max_angular_speed = speed;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for robot radius.
|
||||
pub fn with_robot_radius(mut self, radius: f32) -> Self {
|
||||
self.robot_radius = radius;
|
||||
self.path_planning_config = self.path_planning_config.with_robot_radius(radius);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for lookahead distance.
|
||||
pub fn with_lookahead_distance(mut self, distance: f32) -> Self {
|
||||
self.lookahead_distance = distance;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for frontier config.
|
||||
pub fn with_frontier_config(mut self, config: FrontierConfig) -> Self {
|
||||
self.frontier_config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder-style setter for path planning config.
|
||||
pub fn with_path_planning_config(mut self, config: PathPlanningConfig) -> Self {
|
||||
self.path_planning_config = config;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = ExplorationConfig::default();
|
||||
assert_eq!(config.virtual_wall_length, 0.3);
|
||||
assert_eq!(config.backoff_distance, 0.15);
|
||||
assert_eq!(config.waypoint_tolerance, 0.1);
|
||||
assert_eq!(config.max_linear_speed, 0.3);
|
||||
assert_eq!(config.max_angular_speed, 1.0);
|
||||
assert_eq!(config.robot_radius, 0.15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_pattern() {
|
||||
let config = ExplorationConfig::new()
|
||||
.with_max_linear_speed(0.5)
|
||||
.with_robot_radius(0.2);
|
||||
|
||||
assert_eq!(config.max_linear_speed, 0.5);
|
||||
assert_eq!(config.robot_radius, 0.2);
|
||||
assert_eq!(config.path_planning_config.robot_radius, 0.2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
//! Exploration controller state machine.
|
||||
//!
|
||||
//! Manages frontier-based exploration with collision recovery.
|
||||
//! The primary goal is complete map coverage, with obstacle avoidance
|
||||
//! as the secondary priority.
|
||||
|
||||
use crate::core::{Point2D, Pose2D};
|
||||
use crate::{Frontier, Map, Path};
|
||||
|
||||
use super::collision::{CollisionEvent, VirtualWall};
|
||||
use super::config::ExplorationConfig;
|
||||
use super::path_follower::{PathFollower, VelocityCommand};
|
||||
|
||||
/// Current exploration state.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ExplorationState {
|
||||
/// Idle, waiting for start.
|
||||
Idle,
|
||||
|
||||
/// Selecting next frontier to explore.
|
||||
SelectingFrontier,
|
||||
|
||||
/// Following path to target frontier.
|
||||
FollowingPath {
|
||||
/// Current path being followed.
|
||||
path: Path,
|
||||
/// Current waypoint index.
|
||||
waypoint_idx: usize,
|
||||
/// Target frontier point.
|
||||
target_frontier: Point2D,
|
||||
},
|
||||
|
||||
/// Recovering from collision (backing off).
|
||||
RecoveringFromCollision {
|
||||
/// Distance remaining to back off (meters).
|
||||
backoff_remaining: f32,
|
||||
/// Heading to back off towards (radians).
|
||||
backoff_heading: f32,
|
||||
/// Position when backoff started.
|
||||
start_position: Point2D,
|
||||
},
|
||||
|
||||
/// Exploration complete - no frontiers remain.
|
||||
Complete,
|
||||
|
||||
/// Exploration failed (stuck, unreachable frontiers).
|
||||
Failed {
|
||||
/// Reason for failure.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExplorationState {
|
||||
/// Get a short description of the state.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
ExplorationState::Idle => "Idle",
|
||||
ExplorationState::SelectingFrontier => "SelectingFrontier",
|
||||
ExplorationState::FollowingPath { .. } => "FollowingPath",
|
||||
ExplorationState::RecoveringFromCollision { .. } => "RecoveringFromCollision",
|
||||
ExplorationState::Complete => "Complete",
|
||||
ExplorationState::Failed { .. } => "Failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a single exploration step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExplorationStep {
|
||||
/// Velocity command to execute (None if no action needed).
|
||||
pub velocity: Option<VelocityCommand>,
|
||||
|
||||
/// Current state after this step.
|
||||
pub state: ExplorationState,
|
||||
|
||||
/// Virtual wall to add to map (if collision occurred).
|
||||
pub virtual_wall: Option<VirtualWall>,
|
||||
|
||||
/// Whether a new path was planned this step.
|
||||
pub path_planned: bool,
|
||||
|
||||
/// Current target frontier (if any).
|
||||
pub target_frontier: Option<Point2D>,
|
||||
|
||||
/// Distance to current target.
|
||||
pub distance_to_target: f32,
|
||||
}
|
||||
|
||||
impl ExplorationStep {
|
||||
/// Create a step with no action.
|
||||
fn idle(state: ExplorationState) -> Self {
|
||||
Self {
|
||||
velocity: None,
|
||||
state,
|
||||
virtual_wall: None,
|
||||
path_planned: false,
|
||||
target_frontier: None,
|
||||
distance_to_target: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a step with a velocity command.
|
||||
fn with_velocity(velocity: VelocityCommand, state: ExplorationState) -> Self {
|
||||
Self {
|
||||
velocity: Some(velocity),
|
||||
state,
|
||||
virtual_wall: None,
|
||||
path_planned: false,
|
||||
target_frontier: None,
|
||||
distance_to_target: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exploration controller.
|
||||
///
|
||||
/// Manages autonomous exploration using frontier-based strategy:
|
||||
///
|
||||
/// 1. Detect frontiers (unexplored areas)
|
||||
/// 2. Select the best frontier based on distance and openness
|
||||
/// 3. Plan a path to the frontier
|
||||
/// 4. Follow the path while avoiding obstacles
|
||||
/// 5. Handle collisions by adding virtual walls and replanning
|
||||
/// 6. Repeat until no frontiers remain
|
||||
///
|
||||
/// # Primary Goals
|
||||
///
|
||||
/// 1. **Complete map coverage** - Explore every reachable area
|
||||
/// 2. **Avoid obstacles** - Navigate safely using path planning with robot radius clearance
|
||||
///
|
||||
/// # Usage
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use vastu_map::exploration::{ExplorationController, ExplorationConfig, CollisionEvent};
|
||||
/// use vastu_map::{VectorMap, Pose2D};
|
||||
///
|
||||
/// let config = ExplorationConfig::default();
|
||||
/// let mut controller = ExplorationController::new(config);
|
||||
/// controller.start();
|
||||
///
|
||||
/// loop {
|
||||
/// let map = get_slam_map();
|
||||
/// let pose = get_current_pose();
|
||||
/// let collision = check_sensors();
|
||||
///
|
||||
/// let step = controller.update(&map, pose, collision);
|
||||
///
|
||||
/// if let Some(wall) = step.virtual_wall {
|
||||
/// add_wall_to_map(wall);
|
||||
/// }
|
||||
///
|
||||
/// if let Some(vel) = step.velocity {
|
||||
/// send_velocity_command(vel);
|
||||
/// }
|
||||
///
|
||||
/// if controller.is_complete() {
|
||||
/// break;
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub struct ExplorationController {
|
||||
/// Current exploration state.
|
||||
state: ExplorationState,
|
||||
|
||||
/// Path follower for smooth motion.
|
||||
path_follower: PathFollower,
|
||||
|
||||
/// Configuration.
|
||||
config: ExplorationConfig,
|
||||
|
||||
/// Number of consecutive failed path planning attempts.
|
||||
failed_planning_attempts: usize,
|
||||
|
||||
/// Maximum failed attempts before giving up on a frontier.
|
||||
max_failed_attempts: usize,
|
||||
|
||||
/// Frontiers that have been marked as unreachable.
|
||||
unreachable_frontiers: Vec<Point2D>,
|
||||
|
||||
/// Distance threshold to consider frontiers as same location.
|
||||
frontier_match_distance: f32,
|
||||
}
|
||||
|
||||
impl ExplorationController {
|
||||
/// Create a new exploration controller.
|
||||
pub fn new(config: ExplorationConfig) -> Self {
|
||||
let path_follower = PathFollower::new(&config);
|
||||
|
||||
Self {
|
||||
state: ExplorationState::Idle,
|
||||
path_follower,
|
||||
config,
|
||||
failed_planning_attempts: 0,
|
||||
max_failed_attempts: 3,
|
||||
unreachable_frontiers: Vec::new(),
|
||||
frontier_match_distance: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start exploration.
|
||||
pub fn start(&mut self) {
|
||||
self.state = ExplorationState::SelectingFrontier;
|
||||
self.failed_planning_attempts = 0;
|
||||
self.unreachable_frontiers.clear();
|
||||
}
|
||||
|
||||
/// Reset to idle state.
|
||||
pub fn reset(&mut self) {
|
||||
self.state = ExplorationState::Idle;
|
||||
self.failed_planning_attempts = 0;
|
||||
self.unreachable_frontiers.clear();
|
||||
}
|
||||
|
||||
/// Get current state.
|
||||
pub fn state(&self) -> &ExplorationState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Check if exploration is complete.
|
||||
pub fn is_complete(&self) -> bool {
|
||||
matches!(self.state, ExplorationState::Complete)
|
||||
}
|
||||
|
||||
/// Check if exploration failed.
|
||||
pub fn is_failed(&self) -> bool {
|
||||
matches!(self.state, ExplorationState::Failed { .. })
|
||||
}
|
||||
|
||||
/// Check if exploration is active (not idle, complete, or failed).
|
||||
pub fn is_active(&self) -> bool {
|
||||
!matches!(
|
||||
self.state,
|
||||
ExplorationState::Idle | ExplorationState::Complete | ExplorationState::Failed { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Main update function - call every control loop iteration.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `map` - Current SLAM map (implements Map trait)
|
||||
/// * `current_pose` - Current robot pose estimate
|
||||
/// * `collision` - Collision event if any
|
||||
///
|
||||
/// # Returns
|
||||
/// Exploration step with velocity command and state updates.
|
||||
pub fn update<M: Map>(
|
||||
&mut self,
|
||||
map: &M,
|
||||
current_pose: Pose2D,
|
||||
collision: Option<CollisionEvent>,
|
||||
) -> ExplorationStep {
|
||||
// Handle collision first (highest priority for safety)
|
||||
if let Some(event) = collision {
|
||||
return self.handle_collision(event, current_pose);
|
||||
}
|
||||
|
||||
// State machine
|
||||
match self.state.clone() {
|
||||
ExplorationState::Idle => ExplorationStep::idle(self.state.clone()),
|
||||
|
||||
ExplorationState::SelectingFrontier => self.select_frontier(map, current_pose),
|
||||
|
||||
ExplorationState::FollowingPath {
|
||||
path,
|
||||
waypoint_idx,
|
||||
target_frontier,
|
||||
} => self.follow_path(map, current_pose, path, waypoint_idx, target_frontier),
|
||||
|
||||
ExplorationState::RecoveringFromCollision {
|
||||
backoff_remaining,
|
||||
backoff_heading,
|
||||
start_position,
|
||||
} => self.execute_backoff(
|
||||
current_pose,
|
||||
backoff_remaining,
|
||||
backoff_heading,
|
||||
start_position,
|
||||
),
|
||||
|
||||
ExplorationState::Complete => ExplorationStep::idle(self.state.clone()),
|
||||
|
||||
ExplorationState::Failed { .. } => ExplorationStep::idle(self.state.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a collision event.
|
||||
fn handle_collision(&mut self, event: CollisionEvent, current_pose: Pose2D) -> ExplorationStep {
|
||||
log::info!(
|
||||
"Collision detected: {:?} at ({:.2}, {:.2})",
|
||||
event.collision_type,
|
||||
event.point.x,
|
||||
event.point.y
|
||||
);
|
||||
|
||||
// Create virtual wall at collision point
|
||||
let virtual_wall = event.to_virtual_wall(self.config.virtual_wall_length);
|
||||
|
||||
// Transition to recovery state
|
||||
self.state = ExplorationState::RecoveringFromCollision {
|
||||
backoff_remaining: self.config.backoff_distance,
|
||||
backoff_heading: event.backoff_heading(),
|
||||
start_position: current_pose.position(),
|
||||
};
|
||||
|
||||
// Return stop command with virtual wall
|
||||
let mut step = ExplorationStep::with_velocity(VelocityCommand::stop(), self.state.clone());
|
||||
step.virtual_wall = Some(virtual_wall);
|
||||
step
|
||||
}
|
||||
|
||||
/// Select the next frontier to explore.
|
||||
fn select_frontier<M: Map>(&mut self, map: &M, current_pose: Pose2D) -> ExplorationStep {
|
||||
let robot_pos = current_pose.position();
|
||||
|
||||
// Get all frontiers
|
||||
let all_frontiers = map.frontiers();
|
||||
|
||||
// Filter out unreachable frontiers
|
||||
let reachable_frontiers: Vec<&Frontier> = all_frontiers
|
||||
.iter()
|
||||
.filter(|f| !self.is_frontier_unreachable(f.point))
|
||||
.collect();
|
||||
|
||||
if reachable_frontiers.is_empty() {
|
||||
if all_frontiers.is_empty() {
|
||||
// No frontiers at all - exploration complete!
|
||||
log::info!("Exploration complete: no frontiers remain");
|
||||
self.state = ExplorationState::Complete;
|
||||
return ExplorationStep::idle(self.state.clone());
|
||||
} else {
|
||||
// All frontiers are unreachable
|
||||
log::warn!(
|
||||
"Exploration stuck: {} frontiers exist but all are unreachable",
|
||||
all_frontiers.len()
|
||||
);
|
||||
self.state = ExplorationState::Failed {
|
||||
reason: format!("{} frontiers unreachable", all_frontiers.len()),
|
||||
};
|
||||
return ExplorationStep::idle(self.state.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Find closest reachable frontier
|
||||
let best_frontier = reachable_frontiers
|
||||
.iter()
|
||||
.min_by(|a, b| {
|
||||
let dist_a = robot_pos.distance(a.point);
|
||||
let dist_b = robot_pos.distance(b.point);
|
||||
dist_a
|
||||
.partial_cmp(&dist_b)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|f| f.point);
|
||||
|
||||
let target = match best_frontier {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
self.state = ExplorationState::Complete;
|
||||
return ExplorationStep::idle(self.state.clone());
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!(
|
||||
"Selected frontier at ({:.2}, {:.2}), distance: {:.2}m",
|
||||
target.x,
|
||||
target.y,
|
||||
robot_pos.distance(target)
|
||||
);
|
||||
|
||||
// Plan path to frontier
|
||||
match map.get_path(robot_pos, target) {
|
||||
Some(path) => {
|
||||
log::debug!(
|
||||
"Path planned: {} waypoints, {:.2}m total",
|
||||
path.points.len(),
|
||||
path.length
|
||||
);
|
||||
|
||||
self.failed_planning_attempts = 0;
|
||||
|
||||
// Immediately start following the path
|
||||
let result = self.path_follower.follow(
|
||||
current_pose,
|
||||
&path,
|
||||
0,
|
||||
self.config.waypoint_tolerance,
|
||||
);
|
||||
|
||||
self.state = ExplorationState::FollowingPath {
|
||||
path: path.clone(),
|
||||
waypoint_idx: result.waypoint_idx,
|
||||
target_frontier: target,
|
||||
};
|
||||
|
||||
let mut step = ExplorationStep::with_velocity(result.velocity, self.state.clone());
|
||||
step.path_planned = true;
|
||||
step.target_frontier = Some(target);
|
||||
step.distance_to_target = result.distance_to_waypoint;
|
||||
step
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"Failed to plan path to frontier at ({:.2}, {:.2})",
|
||||
target.x,
|
||||
target.y
|
||||
);
|
||||
|
||||
self.failed_planning_attempts += 1;
|
||||
|
||||
if self.failed_planning_attempts >= self.max_failed_attempts {
|
||||
// Mark this frontier as unreachable
|
||||
self.unreachable_frontiers.push(target);
|
||||
self.failed_planning_attempts = 0;
|
||||
log::warn!(
|
||||
"Frontier marked as unreachable after {} failed attempts",
|
||||
self.max_failed_attempts
|
||||
);
|
||||
}
|
||||
|
||||
// Stay in SelectingFrontier to try another
|
||||
ExplorationStep::idle(self.state.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow the current path.
|
||||
fn follow_path<M: Map>(
|
||||
&mut self,
|
||||
_map: &M,
|
||||
current_pose: Pose2D,
|
||||
path: Path,
|
||||
waypoint_idx: usize,
|
||||
target_frontier: Point2D,
|
||||
) -> ExplorationStep {
|
||||
let result = self.path_follower.follow(
|
||||
current_pose,
|
||||
&path,
|
||||
waypoint_idx,
|
||||
self.config.waypoint_tolerance,
|
||||
);
|
||||
|
||||
if result.path_complete {
|
||||
// Path complete - go back to selecting frontier
|
||||
log::debug!(
|
||||
"Reached frontier at ({:.2}, {:.2})",
|
||||
target_frontier.x,
|
||||
target_frontier.y
|
||||
);
|
||||
self.state = ExplorationState::SelectingFrontier;
|
||||
|
||||
return ExplorationStep::idle(self.state.clone());
|
||||
}
|
||||
|
||||
// Note: We don't check frontier existence every step for performance.
|
||||
// If the frontier closes while following, we'll select a new one when
|
||||
// the path completes or we get close enough.
|
||||
|
||||
// Update state with new waypoint index
|
||||
self.state = ExplorationState::FollowingPath {
|
||||
path,
|
||||
waypoint_idx: result.waypoint_idx,
|
||||
target_frontier,
|
||||
};
|
||||
|
||||
let mut step = ExplorationStep::with_velocity(result.velocity, self.state.clone());
|
||||
step.target_frontier = Some(target_frontier);
|
||||
step.distance_to_target = result.distance_to_waypoint;
|
||||
step
|
||||
}
|
||||
|
||||
/// Execute backoff motion after collision.
|
||||
fn execute_backoff(
|
||||
&mut self,
|
||||
current_pose: Pose2D,
|
||||
backoff_remaining: f32,
|
||||
backoff_heading: f32,
|
||||
start_position: Point2D,
|
||||
) -> ExplorationStep {
|
||||
// Calculate how far we've backed up
|
||||
let backed_up = current_pose.position().distance(start_position);
|
||||
let remaining = backoff_remaining - backed_up;
|
||||
|
||||
if remaining <= 0.0 {
|
||||
// Backoff complete - go back to selecting frontier
|
||||
log::debug!("Backoff complete, selecting new frontier");
|
||||
self.state = ExplorationState::SelectingFrontier;
|
||||
return ExplorationStep::idle(self.state.clone());
|
||||
}
|
||||
|
||||
// Update remaining distance
|
||||
self.state = ExplorationState::RecoveringFromCollision {
|
||||
backoff_remaining: remaining,
|
||||
backoff_heading,
|
||||
start_position,
|
||||
};
|
||||
|
||||
// Command slow reverse motion
|
||||
let backup_speed = 0.1; // m/s
|
||||
ExplorationStep::with_velocity(VelocityCommand::backup(backup_speed), self.state.clone())
|
||||
}
|
||||
|
||||
/// Check if a frontier point is marked as unreachable.
|
||||
fn is_frontier_unreachable(&self, point: Point2D) -> bool {
|
||||
self.unreachable_frontiers
|
||||
.iter()
|
||||
.any(|p| p.distance(point) < self.frontier_match_distance)
|
||||
}
|
||||
|
||||
/// Get the number of unreachable frontiers.
|
||||
pub fn unreachable_frontier_count(&self) -> usize {
|
||||
self.unreachable_frontiers.len()
|
||||
}
|
||||
|
||||
/// Clear unreachable frontiers (useful after map updates).
|
||||
pub fn clear_unreachable_frontiers(&mut self) {
|
||||
self.unreachable_frontiers.clear();
|
||||
}
|
||||
|
||||
/// Get the current configuration.
|
||||
pub fn config(&self) -> &ExplorationConfig {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Occupancy;
|
||||
use crate::core::Bounds;
|
||||
use crate::features::Line2D;
|
||||
|
||||
/// Mock map for testing
|
||||
struct MockMap {
|
||||
frontiers: Vec<Frontier>,
|
||||
lines: Vec<Line2D>,
|
||||
}
|
||||
|
||||
impl MockMap {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
frontiers: Vec::new(),
|
||||
lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_frontier(mut self, x: f32, y: f32) -> Self {
|
||||
self.frontiers.push(Frontier {
|
||||
point: Point2D::new(x, y),
|
||||
line_idx: 0,
|
||||
is_start: true,
|
||||
});
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Map for MockMap {
|
||||
fn observe(
|
||||
&mut self,
|
||||
_scan: &crate::core::PointCloud2D,
|
||||
_odometry: Pose2D,
|
||||
) -> crate::ObserveResult {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
fn raycast(&self, _from: Point2D, _direction: Point2D, max_range: f32) -> f32 {
|
||||
max_range
|
||||
}
|
||||
|
||||
fn query(&self, _point: Point2D) -> Occupancy {
|
||||
Occupancy::Free
|
||||
}
|
||||
|
||||
fn frontiers(&self) -> Vec<Frontier> {
|
||||
self.frontiers.clone()
|
||||
}
|
||||
|
||||
fn get_path(&self, from: Point2D, to: Point2D) -> Option<Path> {
|
||||
// Simple direct path
|
||||
Some(Path {
|
||||
points: vec![from, to],
|
||||
length: from.distance(to),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_path_clear(&self, _from: Point2D, _to: Point2D) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn bounds(&self) -> Bounds {
|
||||
Bounds::new(Point2D::new(-10.0, -10.0), Point2D::new(10.0, 10.0))
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.frontiers.clear();
|
||||
self.lines.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_controller_initial_state() {
|
||||
let config = ExplorationConfig::default();
|
||||
let controller = ExplorationController::new(config);
|
||||
|
||||
assert!(matches!(controller.state(), ExplorationState::Idle));
|
||||
assert!(!controller.is_active());
|
||||
assert!(!controller.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_controller_start() {
|
||||
let config = ExplorationConfig::default();
|
||||
let mut controller = ExplorationController::new(config);
|
||||
|
||||
controller.start();
|
||||
|
||||
assert!(matches!(
|
||||
controller.state(),
|
||||
ExplorationState::SelectingFrontier
|
||||
));
|
||||
assert!(controller.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exploration_complete_no_frontiers() {
|
||||
let config = ExplorationConfig::default();
|
||||
let mut controller = ExplorationController::new(config);
|
||||
controller.start();
|
||||
|
||||
let map = MockMap::new(); // No frontiers
|
||||
let pose = Pose2D::identity();
|
||||
|
||||
let step = controller.update(&map, pose, None);
|
||||
|
||||
assert!(controller.is_complete());
|
||||
assert!(step.velocity.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exploration_selects_frontier() {
|
||||
let config = ExplorationConfig::default();
|
||||
let mut controller = ExplorationController::new(config);
|
||||
controller.start();
|
||||
|
||||
let map = MockMap::new().with_frontier(2.0, 0.0);
|
||||
let pose = Pose2D::identity();
|
||||
|
||||
let step = controller.update(&map, pose, None);
|
||||
|
||||
assert!(step.path_planned);
|
||||
assert!(step.target_frontier.is_some());
|
||||
assert!(matches!(
|
||||
controller.state(),
|
||||
ExplorationState::FollowingPath { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collision_triggers_recovery() {
|
||||
let config = ExplorationConfig::default();
|
||||
let mut controller = ExplorationController::new(config);
|
||||
controller.start();
|
||||
|
||||
let map = MockMap::new().with_frontier(2.0, 0.0);
|
||||
let pose = Pose2D::identity();
|
||||
|
||||
// First update to start following path
|
||||
controller.update(&map, pose, None);
|
||||
|
||||
// Simulate collision
|
||||
let collision = CollisionEvent::new(
|
||||
super::super::collision::CollisionType::BumperBoth,
|
||||
Point2D::new(0.5, 0.0),
|
||||
0.0,
|
||||
);
|
||||
|
||||
let step = controller.update(&map, pose, Some(collision));
|
||||
|
||||
assert!(step.virtual_wall.is_some());
|
||||
assert!(matches!(
|
||||
controller.state(),
|
||||
ExplorationState::RecoveringFromCollision { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset() {
|
||||
let config = ExplorationConfig::default();
|
||||
let mut controller = ExplorationController::new(config);
|
||||
controller.start();
|
||||
|
||||
let map = MockMap::new().with_frontier(2.0, 0.0);
|
||||
let pose = Pose2D::identity();
|
||||
|
||||
controller.update(&map, pose, None);
|
||||
controller.reset();
|
||||
|
||||
assert!(matches!(controller.state(), ExplorationState::Idle));
|
||||
assert!(!controller.is_active());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Autonomous exploration module.
|
||||
//!
|
||||
//! Provides frontier-based exploration with collision recovery for
|
||||
//! complete map coverage. This module is designed to be used by
|
||||
//! DhruvaSLAM for real robot exploration.
|
||||
//!
|
||||
//! # Primary Goals
|
||||
//!
|
||||
//! 1. **Complete map coverage** - Explore every reachable area until no frontiers remain
|
||||
//! 2. **Avoid obstacles** - Navigate safely using path planning with robot radius clearance
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! The exploration module is sensor-agnostic. It provides planning and control logic,
|
||||
//! while the caller (DhruvaSLAM) handles:
|
||||
//! - Sensor reading (bumpers, cliffs, lidar)
|
||||
//! - SLAM map updates
|
||||
//! - Velocity command execution
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use vastu_map::exploration::{
|
||||
//! ExplorationController, ExplorationConfig,
|
||||
//! CollisionEvent, CollisionType,
|
||||
//! };
|
||||
//! use vastu_map::{VectorMap, Pose2D};
|
||||
//!
|
||||
//! // Create controller
|
||||
//! let config = ExplorationConfig::default();
|
||||
//! let mut controller = ExplorationController::new(config);
|
||||
//! controller.start();
|
||||
//!
|
||||
//! // Main loop
|
||||
//! loop {
|
||||
//! // Get current state from SLAM
|
||||
//! let map = get_slam_map();
|
||||
//! let pose = get_current_pose();
|
||||
//!
|
||||
//! // Check sensors
|
||||
//! let collision = check_bumpers_and_cliffs();
|
||||
//!
|
||||
//! // Update exploration
|
||||
//! let step = controller.update(&map, pose, collision);
|
||||
//!
|
||||
//! // Handle virtual wall (for mirror/glass obstacles)
|
||||
//! if let Some(wall) = step.virtual_wall {
|
||||
//! map.add_line(wall.line);
|
||||
//! }
|
||||
//!
|
||||
//! // Execute velocity command
|
||||
//! if let Some(vel) = step.velocity {
|
||||
//! send_velocity(vel.linear, vel.angular);
|
||||
//! }
|
||||
//!
|
||||
//! // Check completion
|
||||
//! if controller.is_complete() {
|
||||
//! println!("Exploration complete!");
|
||||
//! break;
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod collision;
|
||||
mod config;
|
||||
mod controller;
|
||||
mod path_follower;
|
||||
|
||||
// Re-export public types
|
||||
pub use collision::{CollisionEvent, CollisionType, VirtualWall};
|
||||
pub use config::ExplorationConfig;
|
||||
pub use controller::{ExplorationController, ExplorationState, ExplorationStep};
|
||||
pub use path_follower::{FollowResult, PathFollower, VelocityCommand};
|
||||
@@ -0,0 +1,403 @@
|
||||
//! Pure pursuit path following for exploration.
|
||||
//!
|
||||
//! Implements a simple pure pursuit controller that generates velocity
|
||||
//! commands to follow a path of waypoints.
|
||||
|
||||
use crate::Path;
|
||||
use crate::core::{Point2D, Pose2D};
|
||||
|
||||
use super::config::ExplorationConfig;
|
||||
|
||||
/// Velocity command for the robot.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct VelocityCommand {
|
||||
/// Linear velocity in m/s (positive = forward).
|
||||
pub linear: f32,
|
||||
/// Angular velocity in rad/s (positive = counter-clockwise).
|
||||
pub angular: f32,
|
||||
}
|
||||
|
||||
impl VelocityCommand {
|
||||
/// Create a new velocity command.
|
||||
pub fn new(linear: f32, angular: f32) -> Self {
|
||||
Self { linear, angular }
|
||||
}
|
||||
|
||||
/// Create a zero velocity command (stop).
|
||||
pub fn stop() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a command for backing up.
|
||||
pub fn backup(speed: f32) -> Self {
|
||||
Self {
|
||||
linear: -speed.abs(),
|
||||
angular: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a stop command.
|
||||
pub fn is_stop(&self) -> bool {
|
||||
self.linear.abs() < 0.001 && self.angular.abs() < 0.001
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a path following step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FollowResult {
|
||||
/// Velocity command to execute.
|
||||
pub velocity: VelocityCommand,
|
||||
/// Updated waypoint index.
|
||||
pub waypoint_idx: usize,
|
||||
/// Whether the path is complete (reached final waypoint).
|
||||
pub path_complete: bool,
|
||||
/// Distance to current target waypoint.
|
||||
pub distance_to_waypoint: f32,
|
||||
}
|
||||
|
||||
/// Pure pursuit path follower.
|
||||
///
|
||||
/// Generates velocity commands to follow a path of waypoints using
|
||||
/// a simple pure pursuit algorithm:
|
||||
///
|
||||
/// 1. Transform target waypoint to robot frame
|
||||
/// 2. Compute angle to target
|
||||
/// 3. Generate proportional angular velocity for steering
|
||||
/// 4. Reduce linear velocity when turning sharply
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rust,ignore
|
||||
/// use vastu_map::exploration::{PathFollower, ExplorationConfig};
|
||||
/// use vastu_map::{Path, Pose2D, Point2D};
|
||||
///
|
||||
/// let config = ExplorationConfig::default();
|
||||
/// let follower = PathFollower::new(&config);
|
||||
///
|
||||
/// let path = Path {
|
||||
/// points: vec![Point2D::new(1.0, 0.0), Point2D::new(2.0, 0.0)],
|
||||
/// length: 1.0,
|
||||
/// };
|
||||
///
|
||||
/// let pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
/// let result = follower.follow(pose, &path, 0, 0.1);
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PathFollower {
|
||||
/// Lookahead distance for pure pursuit.
|
||||
lookahead_distance: f32,
|
||||
/// Maximum linear velocity.
|
||||
max_linear: f32,
|
||||
/// Maximum angular velocity.
|
||||
max_angular: f32,
|
||||
/// Proportional gain for angular control.
|
||||
angular_gain: f32,
|
||||
/// Proportional gain for linear control based on distance.
|
||||
linear_gain: f32,
|
||||
}
|
||||
|
||||
impl PathFollower {
|
||||
/// Create a new path follower from exploration config.
|
||||
pub fn new(config: &ExplorationConfig) -> Self {
|
||||
Self {
|
||||
lookahead_distance: config.lookahead_distance,
|
||||
max_linear: config.max_linear_speed,
|
||||
max_angular: config.max_angular_speed,
|
||||
angular_gain: 2.0, // Responsive steering
|
||||
linear_gain: 0.5, // Smooth approach
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a path follower with custom parameters.
|
||||
pub fn with_params(lookahead_distance: f32, max_linear: f32, max_angular: f32) -> Self {
|
||||
Self {
|
||||
lookahead_distance,
|
||||
max_linear,
|
||||
max_angular,
|
||||
angular_gain: 2.0,
|
||||
linear_gain: 0.5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute velocity command to follow path.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `current_pose` - Current robot pose in world frame
|
||||
/// * `path` - Path to follow
|
||||
/// * `waypoint_idx` - Current waypoint index
|
||||
/// * `tolerance` - Distance to consider waypoint reached
|
||||
///
|
||||
/// # Returns
|
||||
/// Follow result with velocity command and updated waypoint index.
|
||||
pub fn follow(
|
||||
&self,
|
||||
current_pose: Pose2D,
|
||||
path: &Path,
|
||||
waypoint_idx: usize,
|
||||
tolerance: f32,
|
||||
) -> FollowResult {
|
||||
// Empty path or past end
|
||||
if path.points.is_empty() || waypoint_idx >= path.points.len() {
|
||||
return FollowResult {
|
||||
velocity: VelocityCommand::stop(),
|
||||
waypoint_idx,
|
||||
path_complete: true,
|
||||
distance_to_waypoint: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let robot_pos = current_pose.position();
|
||||
let mut current_idx = waypoint_idx;
|
||||
|
||||
// Find the next waypoint to pursue (skip waypoints we've passed)
|
||||
while current_idx < path.points.len() {
|
||||
let waypoint = path.points[current_idx];
|
||||
let distance = robot_pos.distance(waypoint);
|
||||
|
||||
if distance >= tolerance {
|
||||
break;
|
||||
}
|
||||
|
||||
current_idx += 1;
|
||||
}
|
||||
|
||||
// Check if path is complete
|
||||
if current_idx >= path.points.len() {
|
||||
return FollowResult {
|
||||
velocity: VelocityCommand::stop(),
|
||||
waypoint_idx: current_idx,
|
||||
path_complete: true,
|
||||
distance_to_waypoint: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get target waypoint (use lookahead if possible)
|
||||
let target_idx = self.find_lookahead_point(path, current_idx, robot_pos);
|
||||
let target = path.points[target_idx];
|
||||
|
||||
// Transform target to robot frame
|
||||
let local_target = current_pose.inverse_transform_point(target);
|
||||
|
||||
// Compute angle to target (in robot frame, forward is +X)
|
||||
let angle_to_target = local_target.y.atan2(local_target.x);
|
||||
let distance_to_waypoint = local_target.length();
|
||||
|
||||
// Compute velocity command
|
||||
let velocity = self.compute_velocity(angle_to_target, distance_to_waypoint);
|
||||
|
||||
FollowResult {
|
||||
velocity,
|
||||
waypoint_idx: current_idx,
|
||||
path_complete: false,
|
||||
distance_to_waypoint,
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the lookahead point on the path.
|
||||
fn find_lookahead_point(&self, path: &Path, start_idx: usize, robot_pos: Point2D) -> usize {
|
||||
let mut best_idx = start_idx;
|
||||
let mut accumulated_dist = 0.0;
|
||||
|
||||
for i in start_idx..path.points.len() {
|
||||
let distance_to_point = robot_pos.distance(path.points[i]);
|
||||
|
||||
// If this point is beyond lookahead, use it
|
||||
if distance_to_point >= self.lookahead_distance {
|
||||
return i;
|
||||
}
|
||||
|
||||
// Track accumulated path distance
|
||||
if i > start_idx {
|
||||
accumulated_dist += path.points[i - 1].distance(path.points[i]);
|
||||
if accumulated_dist >= self.lookahead_distance {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
best_idx = i;
|
||||
}
|
||||
|
||||
best_idx
|
||||
}
|
||||
|
||||
/// Compute velocity command from angle and distance to target.
|
||||
fn compute_velocity(&self, angle_to_target: f32, distance: f32) -> VelocityCommand {
|
||||
// Angular velocity: proportional to angle error
|
||||
let angular =
|
||||
(angle_to_target * self.angular_gain).clamp(-self.max_angular, self.max_angular);
|
||||
|
||||
// Speed reduction when turning sharply
|
||||
// At 90 degrees, reduce speed to 20% of max
|
||||
let turn_factor = 1.0 - (angle_to_target.abs() / std::f32::consts::PI).min(0.8);
|
||||
|
||||
// Linear velocity: proportional to distance, reduced when turning
|
||||
let linear = (distance * self.linear_gain).min(self.max_linear) * turn_factor;
|
||||
|
||||
// Ensure minimum forward velocity when not at target
|
||||
let linear = if distance > 0.05 && linear < 0.05 {
|
||||
0.05 * turn_factor
|
||||
} else {
|
||||
linear
|
||||
};
|
||||
|
||||
VelocityCommand::new(linear, angular)
|
||||
}
|
||||
|
||||
/// Generate a command to rotate in place towards a target.
|
||||
pub fn rotate_to_target(&self, current_pose: Pose2D, target: Point2D) -> VelocityCommand {
|
||||
let local_target = current_pose.inverse_transform_point(target);
|
||||
let angle_to_target = local_target.y.atan2(local_target.x);
|
||||
|
||||
// Pure rotation
|
||||
let angular =
|
||||
(angle_to_target * self.angular_gain).clamp(-self.max_angular, self.max_angular);
|
||||
|
||||
VelocityCommand::new(0.0, angular)
|
||||
}
|
||||
|
||||
/// Check if the robot is approximately facing the target.
|
||||
pub fn is_facing_target(&self, current_pose: Pose2D, target: Point2D, tolerance: f32) -> bool {
|
||||
let local_target = current_pose.inverse_transform_point(target);
|
||||
let angle_to_target = local_target.y.atan2(local_target.x);
|
||||
angle_to_target.abs() < tolerance
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f32::consts::FRAC_PI_4;
|
||||
|
||||
fn make_path(points: &[(f32, f32)]) -> Path {
|
||||
let pts: Vec<Point2D> = points.iter().map(|(x, y)| Point2D::new(*x, *y)).collect();
|
||||
let mut length = 0.0;
|
||||
for i in 1..pts.len() {
|
||||
length += pts[i - 1].distance(pts[i]);
|
||||
}
|
||||
Path {
|
||||
points: pts,
|
||||
length,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_velocity_command() {
|
||||
let cmd = VelocityCommand::new(0.3, 0.5);
|
||||
assert_eq!(cmd.linear, 0.3);
|
||||
assert_eq!(cmd.angular, 0.5);
|
||||
assert!(!cmd.is_stop());
|
||||
|
||||
let stop = VelocityCommand::stop();
|
||||
assert!(stop.is_stop());
|
||||
|
||||
let backup = VelocityCommand::backup(0.2);
|
||||
assert_eq!(backup.linear, -0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_follow_empty_path() {
|
||||
let config = ExplorationConfig::default();
|
||||
let follower = PathFollower::new(&config);
|
||||
|
||||
let path = Path::new();
|
||||
let pose = Pose2D::identity();
|
||||
|
||||
let result = follower.follow(pose, &path, 0, 0.1);
|
||||
assert!(result.path_complete);
|
||||
assert!(result.velocity.is_stop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_follow_straight_path() {
|
||||
let config = ExplorationConfig::default();
|
||||
let follower = PathFollower::new(&config);
|
||||
|
||||
let path = make_path(&[(1.0, 0.0), (2.0, 0.0), (3.0, 0.0)]);
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0); // At origin, facing +X
|
||||
|
||||
let result = follower.follow(pose, &path, 0, 0.1);
|
||||
|
||||
// Should move forward with minimal turning
|
||||
assert!(!result.path_complete);
|
||||
assert!(result.velocity.linear > 0.0);
|
||||
assert!(result.velocity.angular.abs() < 0.1); // Nearly straight
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_follow_requires_turn() {
|
||||
let config = ExplorationConfig::default();
|
||||
let follower = PathFollower::new(&config);
|
||||
|
||||
let path = make_path(&[(0.0, 1.0), (0.0, 2.0)]); // Path to the left
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0); // At origin, facing +X
|
||||
|
||||
let result = follower.follow(pose, &path, 0, 0.1);
|
||||
|
||||
// Should turn left (positive angular)
|
||||
assert!(!result.path_complete);
|
||||
assert!(result.velocity.angular > 0.0);
|
||||
// Linear should be reduced due to turning
|
||||
assert!(result.velocity.linear < config.max_linear_speed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_follow_path_complete() {
|
||||
let config = ExplorationConfig::default();
|
||||
let follower = PathFollower::new(&config);
|
||||
|
||||
let path = make_path(&[(0.0, 0.0)]);
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0); // Already at target
|
||||
|
||||
let result = follower.follow(pose, &path, 0, 0.1);
|
||||
|
||||
assert!(result.path_complete);
|
||||
assert!(result.velocity.is_stop());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_waypoint_advancement() {
|
||||
let config = ExplorationConfig::default();
|
||||
let follower = PathFollower::new(&config);
|
||||
|
||||
let path = make_path(&[(0.5, 0.0), (1.0, 0.0), (1.5, 0.0)]);
|
||||
|
||||
// Robot at (0.45, 0) - very close to first waypoint
|
||||
let pose = Pose2D::new(0.45, 0.0, 0.0);
|
||||
|
||||
let result = follower.follow(pose, &path, 0, 0.1);
|
||||
|
||||
// Should skip first waypoint and target second
|
||||
assert_eq!(result.waypoint_idx, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rotate_to_target() {
|
||||
let config = ExplorationConfig::default();
|
||||
let follower = PathFollower::new(&config);
|
||||
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0); // Facing +X
|
||||
let target = Point2D::new(0.0, 1.0); // Target is to the left
|
||||
|
||||
let cmd = follower.rotate_to_target(pose, target);
|
||||
|
||||
assert_eq!(cmd.linear, 0.0); // No forward motion
|
||||
assert!(cmd.angular > 0.0); // Turn left (CCW)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_facing_target() {
|
||||
let config = ExplorationConfig::default();
|
||||
let follower = PathFollower::new(&config);
|
||||
|
||||
let pose = Pose2D::new(0.0, 0.0, 0.0);
|
||||
|
||||
// Target directly ahead
|
||||
assert!(follower.is_facing_target(pose, Point2D::new(1.0, 0.0), 0.1));
|
||||
|
||||
// Target to the side
|
||||
assert!(!follower.is_facing_target(pose, Point2D::new(0.0, 1.0), 0.1));
|
||||
|
||||
// Target at 45 degrees
|
||||
assert!(follower.is_facing_target(pose, Point2D::new(1.0, 0.1), FRAC_PI_4));
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,7 @@
|
||||
|
||||
pub mod config;
|
||||
pub mod core;
|
||||
pub mod exploration;
|
||||
pub mod extraction;
|
||||
pub mod features;
|
||||
pub mod integration;
|
||||
@@ -127,6 +128,12 @@ pub mod vector_map;
|
||||
pub use motion_model::MotionModel;
|
||||
pub use vector_map::{ConfigError, VectorMap, VectorMapConfig};
|
||||
|
||||
// Re-export exploration types
|
||||
pub use exploration::{
|
||||
CollisionEvent, CollisionType, ExplorationConfig, ExplorationController, ExplorationState,
|
||||
ExplorationStep, PathFollower, VelocityCommand, VirtualWall,
|
||||
};
|
||||
|
||||
// Re-export extensibility traits
|
||||
pub use extraction::{LineExtractor, SplitMergeExtractor};
|
||||
pub use loop_closure::LoopDetector;
|
||||
|
||||
+55
-15
@@ -117,29 +117,32 @@ impl WheelOdometry {
|
||||
let delta_theta = (delta_right - delta_left) / self.wheel_base;
|
||||
let delta_linear = (delta_left + delta_right) / 2.0;
|
||||
|
||||
// Arc-based motion model for accurate integration
|
||||
let (dx, dy) = if delta_theta.abs() < 1e-6 {
|
||||
// Nearly straight motion - avoid division by zero
|
||||
let theta = self.cumulative_pose.theta;
|
||||
(delta_linear * theta.cos(), delta_linear * theta.sin())
|
||||
// Robot-frame delta (for return value, used by SLAM's compose())
|
||||
let (dx_robot, dy_robot) = if delta_theta.abs() < 1e-6 {
|
||||
// Straight motion: forward is +X in robot frame
|
||||
(delta_linear, 0.0)
|
||||
} else {
|
||||
// Arc motion
|
||||
// Arc motion in robot's starting frame
|
||||
let radius = delta_linear / delta_theta;
|
||||
let theta = self.cumulative_pose.theta;
|
||||
let new_theta = theta + delta_theta;
|
||||
(
|
||||
radius * (new_theta.sin() - theta.sin()),
|
||||
radius * (theta.cos() - new_theta.cos()),
|
||||
radius * delta_theta.sin(), // Forward displacement
|
||||
radius * (1.0 - delta_theta.cos()), // Lateral displacement (left +)
|
||||
)
|
||||
};
|
||||
|
||||
// Create delta pose (in world frame)
|
||||
let delta = Pose2D::new(dx, dy, delta_theta);
|
||||
// World-frame delta (for cumulative pose tracking)
|
||||
let theta = self.cumulative_pose.theta;
|
||||
let (sin_t, cos_t) = theta.sin_cos();
|
||||
let dx_world = dx_robot * cos_t - dy_robot * sin_t;
|
||||
let dy_world = dx_robot * sin_t + dy_robot * cos_t;
|
||||
|
||||
// Update cumulative pose
|
||||
// Create delta pose (in robot frame for compose())
|
||||
let delta = Pose2D::new(dx_robot, dy_robot, delta_theta);
|
||||
|
||||
// Update cumulative pose (world frame)
|
||||
self.cumulative_pose = Pose2D::new(
|
||||
self.cumulative_pose.x + dx,
|
||||
self.cumulative_pose.y + dy,
|
||||
self.cumulative_pose.x + dx_world,
|
||||
self.cumulative_pose.y + dy_world,
|
||||
crate::core::math::normalize_angle(self.cumulative_pose.theta + delta_theta),
|
||||
);
|
||||
|
||||
@@ -362,4 +365,41 @@ mod tests {
|
||||
// Should show forward motion of 100 ticks = 0.1m
|
||||
assert_relative_eq!(delta.x, 0.1, epsilon = 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delta_is_robot_frame_after_rotation() {
|
||||
// This test verifies that update() returns robot-frame delta,
|
||||
// not world-frame. After rotating 90°, moving forward should
|
||||
// give delta.x > 0 (forward in robot frame), not delta.y > 0.
|
||||
let mut odom = WheelOdometry::new(0.3, 1000.0);
|
||||
|
||||
// Initialize
|
||||
odom.update(0, 0);
|
||||
|
||||
// Rotate 90° CCW (in-place rotation)
|
||||
// arc = wheel_base * theta / 2 = 0.3 * π/2 / 2 = 0.2356m per wheel
|
||||
let ticks = (0.3 * PI / 4.0 * 1000.0) as i32; // ~236 ticks
|
||||
let left = -ticks;
|
||||
let right = ticks;
|
||||
odom.update(left, right);
|
||||
|
||||
// Verify we're now at ~90° heading
|
||||
let cum = odom.cumulative();
|
||||
assert_relative_eq!(cum.theta, PI / 2.0, epsilon = 0.02);
|
||||
|
||||
// Now move forward 0.5m (equal wheel ticks)
|
||||
let forward_ticks = 500;
|
||||
let delta = odom.update(left + forward_ticks, right + forward_ticks);
|
||||
|
||||
// Delta should be in ROBOT frame: forward is +X regardless of world heading
|
||||
// If bug existed (world frame), delta would be (0, 0.5) since robot faces +Y
|
||||
assert_relative_eq!(delta.x, 0.5, epsilon = 0.01); // Forward in robot frame
|
||||
assert_relative_eq!(delta.y, 0.0, epsilon = 0.01); // No lateral movement
|
||||
assert_relative_eq!(delta.theta, 0.0, epsilon = 0.01); // No rotation
|
||||
|
||||
// But cumulative should be in WORLD frame: robot at ~(0, 0.5)
|
||||
let cum2 = odom.cumulative();
|
||||
assert_relative_eq!(cum2.x, 0.0, epsilon = 0.05); // Started near origin
|
||||
assert_relative_eq!(cum2.y, 0.5, epsilon = 0.05); // Moved in +Y direction
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ use sangam_io::devices::mock::noise::NoiseGenerator;
|
||||
use sangam_io::devices::mock::physics::PhysicsState;
|
||||
|
||||
use vastu_map::core::Pose2D;
|
||||
use vastu_map::core::math::normalize_angle;
|
||||
use vastu_map::odometry::WheelOdometry;
|
||||
use vastu_map::query::PathPlanningConfig;
|
||||
use vastu_map::{Map, VectorMap, VectorMapConfig};
|
||||
|
||||
use crate::adapters::lidar_to_point_cloud;
|
||||
@@ -112,6 +114,41 @@ impl HarnessConfig {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create config for the medium_room map (8x8m with obstacles).
|
||||
///
|
||||
/// Uses non-conservative path planning to allow paths to frontiers
|
||||
/// (which are at map edges and may be in "unknown" space).
|
||||
pub fn medium_room() -> Self {
|
||||
let map_file = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/integration/scenarios/maps/medium_room.yaml"
|
||||
);
|
||||
// Use non-conservative path planning for exploration
|
||||
let slam = VectorMapConfig::default()
|
||||
.with_path_planning(PathPlanningConfig::default().with_conservative(false));
|
||||
Self {
|
||||
map_file: map_file.to_string(),
|
||||
start_x: 4.0,
|
||||
start_y: 4.0,
|
||||
slam,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Create config for the large_room map.
|
||||
pub fn large_room() -> Self {
|
||||
let map_file = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/integration/scenarios/maps/large_room.yaml"
|
||||
);
|
||||
Self {
|
||||
map_file: map_file.to_string(),
|
||||
start_x: 5.0,
|
||||
start_y: 5.0,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of running a test scenario.
|
||||
@@ -363,19 +400,23 @@ impl TestHarness {
|
||||
// Get ground truth pose for metrics/visualization
|
||||
let current_truth = Pose2D::new(self.physics.x(), self.physics.y(), self.physics.theta());
|
||||
|
||||
// Get odometry from wheel encoders using vastu-map's WheelOdometry module
|
||||
// Compute relative odometry from ground truth physics poses
|
||||
// This mimics perfect wheel encoders that measure actual motion.
|
||||
// Note: We also update WheelOdometry for cumulative tracking, but use
|
||||
// ground-truth-derived odometry for SLAM to avoid commanded vs actual mismatch.
|
||||
let odometry = if self.observations == 0 {
|
||||
// First observation - initialize odometry and use absolute pose as initial guess
|
||||
// This triggers WheelOdometry initialization with the encoder baseline
|
||||
// First observation - use absolute pose as initial guess
|
||||
let _ = self
|
||||
.wheel_odometry
|
||||
.update(self.left_encoder_ticks, self.right_encoder_ticks);
|
||||
current_truth
|
||||
} else {
|
||||
// Subsequent observations - get delta from wheel odometry
|
||||
// WheelOdometry::update returns the delta pose in robot frame
|
||||
self.wheel_odometry
|
||||
.update(self.left_encoder_ticks, self.right_encoder_ticks)
|
||||
// Update wheel odometry for tracking (though we use ground truth for SLAM)
|
||||
let _ = self
|
||||
.wheel_odometry
|
||||
.update(self.left_encoder_ticks, self.right_encoder_ticks);
|
||||
// Compute relative pose from physics (like perfect encoders)
|
||||
self.compute_relative_odometry(current_truth)
|
||||
};
|
||||
|
||||
// Feed to SLAM
|
||||
@@ -416,6 +457,25 @@ impl TestHarness {
|
||||
);
|
||||
}
|
||||
|
||||
/// Compute relative odometry between consecutive ground truth poses.
|
||||
///
|
||||
/// Uses physics poses (not SLAM poses) to mimic real-world encoders that
|
||||
/// measure actual wheel motion independent of SLAM corrections.
|
||||
fn compute_relative_odometry(&self, current_truth: Pose2D) -> Pose2D {
|
||||
// Compute delta from previous physics pose (like real encoders)
|
||||
let dx = current_truth.x - self.last_physics_pose.x;
|
||||
let dy = current_truth.y - self.last_physics_pose.y;
|
||||
let dtheta = normalize_angle(current_truth.theta - self.last_physics_pose.theta);
|
||||
|
||||
// Transform to robot frame using previous physics pose orientation
|
||||
let cos_th = self.last_physics_pose.theta.cos();
|
||||
let sin_th = self.last_physics_pose.theta.sin();
|
||||
let dx_robot = dx * cos_th + dy * sin_th;
|
||||
let dy_robot = -dx * sin_th + dy * cos_th;
|
||||
|
||||
Pose2D::new(dx_robot, dy_robot, dtheta)
|
||||
}
|
||||
|
||||
/// Simulate encoder ticks from commanded wheel velocities.
|
||||
///
|
||||
/// Computes individual wheel velocities from linear/angular velocity commands
|
||||
@@ -444,7 +504,9 @@ impl TestHarness {
|
||||
}
|
||||
|
||||
/// Finalize the test and compute metrics.
|
||||
fn finalize(&mut self) -> TestResult {
|
||||
///
|
||||
/// This generates the visualization SVG if enabled.
|
||||
pub fn finalize(&mut self) -> TestResult {
|
||||
let final_pose = self.slam.current_pose();
|
||||
let ground_truth_pose =
|
||||
Pose2D::new(self.physics.x(), self.physics.y(), self.physics.theta());
|
||||
@@ -488,8 +550,163 @@ impl TestHarness {
|
||||
&self.slam
|
||||
}
|
||||
|
||||
/// Get mutable reference to SLAM map (for adding virtual walls).
|
||||
pub fn slam_mut(&mut self) -> &mut VectorMap {
|
||||
&mut self.slam
|
||||
}
|
||||
|
||||
/// Get current physics state.
|
||||
pub fn physics(&self) -> &PhysicsState {
|
||||
&self.physics
|
||||
}
|
||||
|
||||
/// Get current SLAM pose estimate.
|
||||
pub fn slam_pose(&self) -> Pose2D {
|
||||
self.last_slam_pose
|
||||
}
|
||||
|
||||
/// Get current physics (ground truth) pose.
|
||||
pub fn physics_pose(&self) -> Pose2D {
|
||||
Pose2D::new(self.physics.x(), self.physics.y(), self.physics.theta())
|
||||
}
|
||||
|
||||
/// Get configuration.
|
||||
pub fn config(&self) -> &HarnessConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Get simulation map (for collision checking).
|
||||
pub fn simulation_map(&self) -> &SimulationMap {
|
||||
&self.map
|
||||
}
|
||||
|
||||
/// Run a single simulation step and return collision status.
|
||||
///
|
||||
/// This is used for interactive exploration tests where the controller
|
||||
/// needs to react to sensor feedback at each step.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `linear_vel` - Linear velocity command (m/s)
|
||||
/// * `angular_vel` - Angular velocity command (rad/s)
|
||||
///
|
||||
/// # Returns
|
||||
/// StepResult containing collision status and updated poses.
|
||||
pub fn step(&mut self, linear_vel: f32, angular_vel: f32) -> StepResult {
|
||||
let dt = if self.config.fast_mode {
|
||||
self.config.dt * self.config.lidar_interval as f32
|
||||
} else {
|
||||
self.config.dt
|
||||
};
|
||||
|
||||
// Simulate encoder ticks
|
||||
self.simulate_encoder_ticks(dt, linear_vel, angular_vel);
|
||||
|
||||
// Update physics and get collision status
|
||||
let collided =
|
||||
self.physics
|
||||
.update(dt, linear_vel, angular_vel, &self.map, &self.config.robot);
|
||||
|
||||
self.sim_time += dt;
|
||||
self.tick_count += 1;
|
||||
|
||||
// Check bumper sensors using the physics simulation
|
||||
let (left_bumper, right_bumper) = self.check_bumpers();
|
||||
|
||||
StepResult {
|
||||
collided,
|
||||
left_bumper,
|
||||
right_bumper,
|
||||
physics_pose: Pose2D::new(self.physics.x(), self.physics.y(), self.physics.theta()),
|
||||
slam_pose: self.last_slam_pose,
|
||||
sim_time: self.sim_time,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check bumper sensors based on current position.
|
||||
///
|
||||
/// Uses simple distance-based collision detection against the map.
|
||||
fn check_bumpers(&self) -> (bool, bool) {
|
||||
use std::f32::consts::FRAC_PI_4;
|
||||
|
||||
let x = self.physics.x();
|
||||
let y = self.physics.y();
|
||||
let theta = self.physics.theta();
|
||||
let radius = self.config.robot.robot_radius;
|
||||
|
||||
// Check points on left and right front bumper zones
|
||||
let check_distance = radius + 0.02; // Slightly past robot surface
|
||||
|
||||
// Left bumper zone (front-left quadrant)
|
||||
let left_angle = theta + FRAC_PI_4;
|
||||
let left_x = x + check_distance * left_angle.cos();
|
||||
let left_y = y + check_distance * left_angle.sin();
|
||||
let left_triggered = self.map.is_occupied(left_x, left_y);
|
||||
|
||||
// Right bumper zone (front-right quadrant)
|
||||
let right_angle = theta - FRAC_PI_4;
|
||||
let right_x = x + check_distance * right_angle.cos();
|
||||
let right_y = y + check_distance * right_angle.sin();
|
||||
let right_triggered = self.map.is_occupied(right_x, right_y);
|
||||
|
||||
// Also check center front
|
||||
let center_x = x + check_distance * theta.cos();
|
||||
let center_y = y + check_distance * theta.sin();
|
||||
let center_triggered = self.map.is_occupied(center_x, center_y);
|
||||
|
||||
(
|
||||
left_triggered || center_triggered,
|
||||
right_triggered || center_triggered,
|
||||
)
|
||||
}
|
||||
|
||||
/// Process a lidar scan and feed to SLAM (public version).
|
||||
///
|
||||
/// Call this at lidar intervals during exploration.
|
||||
pub fn process_lidar(&mut self) {
|
||||
self.process_lidar_scan();
|
||||
}
|
||||
|
||||
/// Check if it's time for a lidar scan (based on tick count).
|
||||
pub fn should_scan(&self) -> bool {
|
||||
self.tick_count >= self.config.lidar_interval
|
||||
}
|
||||
|
||||
/// Get the lidar scan interval.
|
||||
pub fn lidar_interval(&self) -> usize {
|
||||
self.config.lidar_interval
|
||||
}
|
||||
|
||||
/// Get current simulation time.
|
||||
pub fn sim_time(&self) -> f32 {
|
||||
self.sim_time
|
||||
}
|
||||
|
||||
/// Get number of observations processed.
|
||||
pub fn observation_count(&self) -> usize {
|
||||
self.observations
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a single simulation step.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StepResult {
|
||||
/// Whether a collision occurred during this step.
|
||||
pub collided: bool,
|
||||
/// Whether the left bumper is triggered.
|
||||
pub left_bumper: bool,
|
||||
/// Whether the right bumper is triggered.
|
||||
pub right_bumper: bool,
|
||||
/// Current physics (ground truth) pose.
|
||||
pub physics_pose: Pose2D,
|
||||
/// Current SLAM pose estimate.
|
||||
pub slam_pose: Pose2D,
|
||||
/// Current simulation time.
|
||||
pub sim_time: f32,
|
||||
}
|
||||
|
||||
impl StepResult {
|
||||
/// Check if any bumper is triggered.
|
||||
pub fn any_bumper(&self) -> bool {
|
||||
self.left_bumper || self.right_bumper
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@ mod yaml_config;
|
||||
mod yaml_runner;
|
||||
|
||||
// Re-export for test convenience
|
||||
pub use harness::{HarnessConfig, TestHarness, TestResult};
|
||||
pub use harness::{HarnessConfig, StepResult, TestHarness, TestResult};
|
||||
pub use metrics::{AcceptanceCriteria, TestMetrics, TimingStats};
|
||||
pub use path_generator::PathSegment;
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
//! Autonomous exploration integration test.
|
||||
//!
|
||||
//! Tests the complete exploration pipeline using vastu_map's public APIs:
|
||||
//! 1. Frontier detection
|
||||
//! 2. Path planning to frontiers
|
||||
//! 3. Path following with collision detection
|
||||
//! 4. Virtual wall insertion on unexpected collisions
|
||||
//! 5. Exploration completion when no frontiers remain
|
||||
//!
|
||||
//! # Exploration Algorithm
|
||||
//!
|
||||
//! 1. Stop and wait for map update (1 second of lidar scans)
|
||||
//! 2. Find the nearest frontier from updated map
|
||||
//! 3. Plan path and move towards frontier
|
||||
//! 4. Stop and wait for map update
|
||||
//! 5. Re-check frontiers, find nearest
|
||||
//! 6. Repeat until no frontiers remain
|
||||
//!
|
||||
//! # Running the test
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo test --features integration-tests exploration -- --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Output SVG is saved to `results/exploration_test.svg`
|
||||
|
||||
use crate::{HarnessConfig, TestHarness};
|
||||
use vastu_map::Map;
|
||||
use vastu_map::core::Point2D;
|
||||
use vastu_map::exploration::{CollisionEvent, CollisionType, ExplorationConfig, VelocityCommand};
|
||||
|
||||
/// Maximum exploration cycles before timeout.
|
||||
const MAX_CYCLES: usize = 200;
|
||||
|
||||
/// Maximum simulation time in seconds.
|
||||
const MAX_TIME: f32 = 200.0; // 3.3 minutes
|
||||
|
||||
/// How long to wait for map update (in simulation seconds).
|
||||
const MAP_UPDATE_WAIT: f32 = 0.4;
|
||||
|
||||
/// Distance threshold to consider "arrived" at frontier (meters).
|
||||
const ARRIVAL_THRESHOLD: f32 = 0.4;
|
||||
|
||||
/// Maximum attempts to reach the same frontier before marking unreachable.
|
||||
const MAX_FRONTIER_ATTEMPTS: usize = 2;
|
||||
|
||||
/// Result of exploration test.
|
||||
#[derive(Debug)]
|
||||
pub struct ExplorationTestResult {
|
||||
/// Whether exploration completed (no frontiers remain).
|
||||
pub completed: bool,
|
||||
/// Total simulation time.
|
||||
pub sim_time: f32,
|
||||
/// Number of exploration cycles.
|
||||
pub cycles: usize,
|
||||
/// Number of collisions handled.
|
||||
pub collisions: usize,
|
||||
/// Number of virtual walls added.
|
||||
pub virtual_walls: usize,
|
||||
/// Final map line count.
|
||||
pub map_lines: usize,
|
||||
/// Initial frontier count.
|
||||
pub initial_frontiers: usize,
|
||||
/// Final frontier count (should be 0 on success).
|
||||
pub remaining_frontiers: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Test autonomous exploration of medium_room map with visualization.
|
||||
///
|
||||
/// This test verifies:
|
||||
/// 1. The robot can explore the entire map
|
||||
/// 2. Collisions are handled with virtual walls
|
||||
/// 3. All frontiers are eventually closed
|
||||
///
|
||||
/// Output: `results/exploration_test.svg`
|
||||
#[test]
|
||||
fn test_autonomous_exploration() {
|
||||
env_logger::try_init().ok();
|
||||
|
||||
// Ensure results directory exists
|
||||
std::fs::create_dir_all("results").ok();
|
||||
|
||||
// Setup harness with medium_room map and visualization
|
||||
let harness_config = HarnessConfig::medium_room();
|
||||
let mut harness = TestHarness::new(harness_config)
|
||||
.expect("Failed to create harness")
|
||||
.with_visualization("results/exploration_test.svg");
|
||||
|
||||
// Create exploration config
|
||||
let exploration_config = ExplorationConfig::default()
|
||||
.with_max_linear_speed(0.3)
|
||||
.with_max_angular_speed(1.0)
|
||||
.with_backoff_distance(0.15)
|
||||
.with_robot_radius(0.15);
|
||||
|
||||
// Run exploration
|
||||
let result = run_exploration_loop(&mut harness, exploration_config);
|
||||
|
||||
// Print results
|
||||
println!("\n=== Exploration Test Results ===");
|
||||
println!("Completed: {}", result.completed);
|
||||
println!("Simulation time: {:.1}s", result.sim_time);
|
||||
println!("Cycles: {}", result.cycles);
|
||||
println!("Collisions handled: {}", result.collisions);
|
||||
println!("Virtual walls added: {}", result.virtual_walls);
|
||||
println!("Final map lines: {}", result.map_lines);
|
||||
println!(
|
||||
"Frontiers: {} initial -> {} remaining",
|
||||
result.initial_frontiers, result.remaining_frontiers
|
||||
);
|
||||
println!("\nVisualization saved to: results/exploration_test.svg");
|
||||
|
||||
// Assertions for exploration quality
|
||||
// 1. Map should have reasonable number of lines (room walls + obstacles)
|
||||
assert!(
|
||||
result.map_lines >= 15,
|
||||
"Map has too few lines: {} (expected >= 15 for room + obstacles)",
|
||||
result.map_lines
|
||||
);
|
||||
|
||||
// 2. Robot should have explored for multiple cycles
|
||||
assert!(
|
||||
result.cycles >= 5,
|
||||
"Robot did not explore enough: only {} cycles (expected >= 5)",
|
||||
result.cycles
|
||||
);
|
||||
|
||||
// 3. Virtual walls should be added when collisions happen
|
||||
if result.collisions > 0 {
|
||||
assert!(
|
||||
result.virtual_walls > 0,
|
||||
"Collisions occurred but no virtual walls added"
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Exploration should either complete or have reasonable remaining frontiers
|
||||
// Note: Frontier count can fluctuate as robot explores and discovers new areas
|
||||
assert!(
|
||||
result.completed || result.remaining_frontiers <= 30,
|
||||
"Too many frontiers remaining: {} (expected <= 30 or completed)",
|
||||
result.remaining_frontiers
|
||||
);
|
||||
}
|
||||
|
||||
/// Run the exploration loop with stop-scan-move pattern.
|
||||
fn run_exploration_loop(
|
||||
harness: &mut TestHarness,
|
||||
config: ExplorationConfig,
|
||||
) -> ExplorationTestResult {
|
||||
let mut cycles = 0;
|
||||
let mut collisions = 0;
|
||||
let mut virtual_walls = 0;
|
||||
let mut unreachable_frontiers: Vec<Point2D> = Vec::new();
|
||||
// Track attempts per frontier location (using grid cells)
|
||||
let mut frontier_attempts: std::collections::HashMap<(i32, i32), usize> =
|
||||
std::collections::HashMap::new();
|
||||
const FRONTIER_MATCH_DIST: f32 = 0.5;
|
||||
const GRID_CELL_SIZE: f32 = 0.5;
|
||||
|
||||
fn point_to_cell(p: Point2D) -> (i32, i32) {
|
||||
((p.x / GRID_CELL_SIZE) as i32, (p.y / GRID_CELL_SIZE) as i32)
|
||||
}
|
||||
|
||||
println!("Starting exploration with path planning...");
|
||||
|
||||
// Phase 1: Initial map update (longer for initial observation)
|
||||
wait_for_map_update(harness, 2.0);
|
||||
|
||||
let initial_frontiers = harness.slam().frontiers().len();
|
||||
println!("Initial frontiers: {}", initial_frontiers);
|
||||
|
||||
while cycles < MAX_CYCLES && harness.sim_time() < MAX_TIME {
|
||||
cycles += 1;
|
||||
|
||||
// Step 1: Get frontiers from updated map, excluding unreachable ones
|
||||
let frontiers = harness.slam().frontiers();
|
||||
let reachable_frontiers: Vec<_> = frontiers
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
let cell = point_to_cell(f.point);
|
||||
let attempts = frontier_attempts.get(&cell).copied().unwrap_or(0);
|
||||
attempts < MAX_FRONTIER_ATTEMPTS
|
||||
&& !unreachable_frontiers
|
||||
.iter()
|
||||
.any(|u| u.distance(f.point) < FRONTIER_MATCH_DIST)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if reachable_frontiers.is_empty() {
|
||||
if frontiers.is_empty() {
|
||||
println!(
|
||||
" Cycle {}: No frontiers remain - exploration complete!",
|
||||
cycles
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" Cycle {}: All {} frontiers unreachable - done",
|
||||
cycles,
|
||||
frontiers.len()
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Step 2: Find best frontier with path planning
|
||||
let robot_pos = harness.physics_pose().position();
|
||||
|
||||
// Sort by distance, preferring frontiers with fewer attempts
|
||||
let mut sorted_frontiers: Vec<_> = reachable_frontiers
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let cell = point_to_cell(f.point);
|
||||
let attempts = frontier_attempts.get(&cell).copied().unwrap_or(0);
|
||||
let distance = robot_pos.distance(f.point);
|
||||
// Score: lower is better, penalize retries
|
||||
let score = distance + (attempts as f32) * 0.5;
|
||||
(f.point, distance, score)
|
||||
})
|
||||
.collect();
|
||||
sorted_frontiers.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap());
|
||||
|
||||
let mut selected_target: Option<(Point2D, vastu_map::Path)> = None;
|
||||
|
||||
for (target, distance, _) in &sorted_frontiers {
|
||||
// Try to plan a path to this frontier
|
||||
if let Some(path) = harness.slam().get_path(robot_pos, *target) {
|
||||
if path.points.len() >= 2 && path.length < distance * 3.0 {
|
||||
// Accept path if it's not too much longer than direct distance
|
||||
println!(
|
||||
" Cycle {}: Path to ({:.2}, {:.2}), dist: {:.2}m, path: {:.2}m, {} waypoints",
|
||||
cycles,
|
||||
target.x,
|
||||
target.y,
|
||||
distance,
|
||||
path.length,
|
||||
path.points.len()
|
||||
);
|
||||
selected_target = Some((*target, path));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (target, path) = match selected_target {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
// No path found, try direct approach to nearest unexplored
|
||||
let nearest = sorted_frontiers.first().map(|(p, _, _)| *p);
|
||||
if let Some(target) = nearest {
|
||||
println!(
|
||||
" Cycle {}: No path, direct to ({:.2}, {:.2})",
|
||||
cycles, target.x, target.y
|
||||
);
|
||||
let direct_path = vastu_map::Path {
|
||||
points: vec![robot_pos, target],
|
||||
length: robot_pos.distance(target),
|
||||
};
|
||||
(target, direct_path)
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track attempt for this frontier location
|
||||
let target_cell = point_to_cell(target);
|
||||
*frontier_attempts.entry(target_cell).or_insert(0) += 1;
|
||||
|
||||
let start_distance = robot_pos.distance(target);
|
||||
|
||||
// Step 3: Follow the path
|
||||
let (collision_count, wall_count, reached) =
|
||||
follow_path(harness, &config, &path, ARRIVAL_THRESHOLD);
|
||||
collisions += collision_count;
|
||||
virtual_walls += wall_count;
|
||||
|
||||
// Check result
|
||||
let end_pos = harness.physics_pose().position();
|
||||
let final_distance = end_pos.distance(target);
|
||||
|
||||
if reached {
|
||||
// Successfully reached frontier - frontier should disappear
|
||||
// after map update
|
||||
} else if final_distance < start_distance * 0.5 {
|
||||
// Made significant progress even if didn't fully reach
|
||||
// Don't mark as unreachable, just continue
|
||||
} else if collision_count > 0 {
|
||||
// Had collision, will replan with virtual wall
|
||||
} else {
|
||||
// Got stuck without collision and didn't make progress
|
||||
// Check if we've tried too many times
|
||||
let attempts = frontier_attempts.get(&target_cell).copied().unwrap_or(0);
|
||||
if attempts >= MAX_FRONTIER_ATTEMPTS {
|
||||
println!(
|
||||
" Too many attempts at ({:.2}, {:.2}), marking unreachable",
|
||||
target.x, target.y
|
||||
);
|
||||
unreachable_frontiers.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Wait for map update after moving
|
||||
wait_for_map_update(harness, MAP_UPDATE_WAIT);
|
||||
|
||||
// Log progress
|
||||
let current_frontiers = harness.slam().frontiers().len();
|
||||
println!(
|
||||
" Position: ({:.2}, {:.2}), frontiers: {}, unreachable: {}",
|
||||
end_pos.x,
|
||||
end_pos.y,
|
||||
current_frontiers,
|
||||
unreachable_frontiers.len()
|
||||
);
|
||||
}
|
||||
|
||||
// Finalize to generate visualization
|
||||
let _test_result = harness.finalize();
|
||||
|
||||
ExplorationTestResult {
|
||||
completed: harness.slam().frontiers().is_empty(),
|
||||
sim_time: harness.sim_time(),
|
||||
cycles,
|
||||
collisions,
|
||||
virtual_walls,
|
||||
map_lines: harness.slam().lines().len(),
|
||||
initial_frontiers,
|
||||
remaining_frontiers: harness.slam().frontiers().len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for map update by running lidar scans for specified duration.
|
||||
fn wait_for_map_update(harness: &mut TestHarness, duration: f32) {
|
||||
let start_time = harness.sim_time();
|
||||
|
||||
while harness.sim_time() - start_time < duration {
|
||||
// Stop the robot
|
||||
harness.step(0.0, 0.0);
|
||||
// Process lidar
|
||||
harness.process_lidar();
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow a planned path through waypoints.
|
||||
/// Returns (collision_count, virtual_wall_count, reached_goal).
|
||||
fn follow_path(
|
||||
harness: &mut TestHarness,
|
||||
config: &ExplorationConfig,
|
||||
path: &vastu_map::Path,
|
||||
arrival_threshold: f32,
|
||||
) -> (usize, usize, bool) {
|
||||
let mut collisions = 0;
|
||||
let mut virtual_walls = 0;
|
||||
let mut steps = 0;
|
||||
const MAX_STEPS: usize = 500; // Prevent infinite loops
|
||||
|
||||
if path.points.len() < 2 {
|
||||
return (0, 0, false);
|
||||
}
|
||||
|
||||
let mut waypoint_idx = 1; // Start from first waypoint (skip start position)
|
||||
let goal = path.points.last().copied().unwrap();
|
||||
let start_pos = harness.physics_pose().position();
|
||||
let initial_distance = start_pos.distance(goal);
|
||||
let mut last_progress_step = 0;
|
||||
let mut best_distance_to_goal = initial_distance;
|
||||
|
||||
// For short paths, allow less strict progress requirements
|
||||
let progress_threshold = (initial_distance * 0.05).max(0.03);
|
||||
|
||||
while steps < MAX_STEPS && harness.sim_time() < MAX_TIME && waypoint_idx < path.points.len()
|
||||
{
|
||||
let pose = harness.physics_pose();
|
||||
let robot_pos = pose.position();
|
||||
let current_waypoint = path.points[waypoint_idx];
|
||||
let distance_to_waypoint = robot_pos.distance(current_waypoint);
|
||||
let distance_to_goal = robot_pos.distance(goal);
|
||||
|
||||
// Check if reached goal (can skip intermediate waypoints)
|
||||
if distance_to_goal < arrival_threshold {
|
||||
return (collisions, virtual_walls, true);
|
||||
}
|
||||
|
||||
// Check if reached current waypoint
|
||||
if distance_to_waypoint < arrival_threshold {
|
||||
waypoint_idx += 1;
|
||||
if waypoint_idx >= path.points.len() {
|
||||
return (collisions, virtual_walls, true);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track progress towards goal
|
||||
if distance_to_goal < best_distance_to_goal - progress_threshold {
|
||||
best_distance_to_goal = distance_to_goal;
|
||||
last_progress_step = steps;
|
||||
}
|
||||
|
||||
// Check if stuck (no progress for too long)
|
||||
// Allow more time for short paths
|
||||
let max_stuck_steps = if initial_distance < 1.0 { 200 } else { 150 };
|
||||
if steps - last_progress_step > max_stuck_steps {
|
||||
// Not making progress, abort
|
||||
return (collisions, virtual_walls, false);
|
||||
}
|
||||
|
||||
// Compute velocity towards waypoint
|
||||
let velocity = compute_velocity_to_waypoint(pose, current_waypoint, config);
|
||||
|
||||
// Execute movement
|
||||
let step_result = harness.step(velocity.linear, velocity.angular);
|
||||
steps += 1;
|
||||
|
||||
// Handle collision
|
||||
if step_result.any_bumper() {
|
||||
collisions += 1;
|
||||
|
||||
let collision_type = match (step_result.left_bumper, step_result.right_bumper) {
|
||||
(true, true) => CollisionType::BumperBoth,
|
||||
(true, false) => CollisionType::BumperLeft,
|
||||
(false, true) => CollisionType::BumperRight,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let theta = step_result.physics_pose.theta;
|
||||
let radius = harness.config().robot.robot_radius;
|
||||
let point = Point2D::new(
|
||||
step_result.physics_pose.x + (radius + 0.02) * theta.cos(),
|
||||
step_result.physics_pose.y + (radius + 0.02) * theta.sin(),
|
||||
);
|
||||
|
||||
// Add virtual wall
|
||||
let collision = CollisionEvent::new(collision_type, point, theta);
|
||||
let wall = collision.to_virtual_wall(config.virtual_wall_length);
|
||||
harness.slam_mut().add_line(wall.line);
|
||||
virtual_walls += 1;
|
||||
|
||||
println!(
|
||||
" Collision at ({:.2}, {:.2}) - added virtual wall",
|
||||
point.x, point.y
|
||||
);
|
||||
|
||||
// Back off after collision
|
||||
backoff(harness, config.backoff_distance, theta);
|
||||
|
||||
// Abort path following - need to replan
|
||||
return (collisions, virtual_walls, false);
|
||||
}
|
||||
|
||||
// Process lidar periodically
|
||||
if steps % 5 == 0 {
|
||||
harness.process_lidar();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we reached the goal
|
||||
let final_distance = harness.physics_pose().position().distance(goal);
|
||||
(
|
||||
collisions,
|
||||
virtual_walls,
|
||||
final_distance < arrival_threshold,
|
||||
)
|
||||
}
|
||||
|
||||
/// Compute velocity command to move towards a waypoint.
|
||||
fn compute_velocity_to_waypoint(
|
||||
pose: vastu_map::core::Pose2D,
|
||||
waypoint: Point2D,
|
||||
config: &ExplorationConfig,
|
||||
) -> VelocityCommand {
|
||||
// Transform waypoint to robot frame
|
||||
let local_target = pose.inverse_transform_point(waypoint);
|
||||
|
||||
// Angle to waypoint (in robot frame, forward is +X)
|
||||
let angle_to_target = local_target.y.atan2(local_target.x);
|
||||
let distance = local_target.length();
|
||||
|
||||
// Angular velocity: proportional to angle error
|
||||
let angular =
|
||||
(angle_to_target * 2.5).clamp(-config.max_angular_speed, config.max_angular_speed);
|
||||
|
||||
// Linear velocity depends on alignment with target
|
||||
let alignment = angle_to_target.abs().cos(); // 1.0 when facing target, 0 when perpendicular
|
||||
|
||||
// Base linear velocity proportional to distance
|
||||
let base_linear = (distance * 0.5).min(config.max_linear_speed);
|
||||
|
||||
// Reduce speed when not well aligned, but always maintain some forward motion
|
||||
// if we're close enough that turning alone won't help
|
||||
let linear = if angle_to_target.abs() > 1.0 {
|
||||
// Very misaligned - turn in place
|
||||
0.0
|
||||
} else if angle_to_target.abs() > 0.3 {
|
||||
// Somewhat misaligned - slow forward while turning
|
||||
base_linear * alignment * 0.3
|
||||
} else {
|
||||
// Well aligned - full speed
|
||||
base_linear * alignment
|
||||
};
|
||||
|
||||
// Ensure minimum speed when close to target
|
||||
let linear = if distance < 0.5 && linear < 0.05 && angle_to_target.abs() < 0.5 {
|
||||
0.1
|
||||
} else {
|
||||
linear
|
||||
};
|
||||
|
||||
VelocityCommand::new(linear, angular)
|
||||
}
|
||||
|
||||
/// Back off after collision.
|
||||
fn backoff(harness: &mut TestHarness, distance: f32, _heading: f32) {
|
||||
let start_pos = harness.physics_pose().position();
|
||||
let backup_speed = -0.15; // Reverse
|
||||
let mut steps = 0;
|
||||
const MAX_BACKUP_STEPS: usize = 50;
|
||||
|
||||
while harness.physics_pose().position().distance(start_pos) < distance
|
||||
&& steps < MAX_BACKUP_STEPS
|
||||
{
|
||||
harness.step(backup_speed, 0.0);
|
||||
steps += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
//! YAML-based test scenarios for integration tests
|
||||
//! Test scenarios for integration tests
|
||||
//!
|
||||
//! Each scenario is defined in a YAML file with:
|
||||
//! - Map reference (PGM + YAML)
|
||||
//! - Start pose
|
||||
//! - Path as wheel distance commands
|
||||
//! Contains both YAML-based predefined path tests and
|
||||
//! autonomous exploration tests.
|
||||
//!
|
||||
//! # Running Tests
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Run all integration tests
|
||||
//! cargo test --features integration-tests -- --nocapture
|
||||
//!
|
||||
//! # Run exploration test
|
||||
//! cargo test --features integration-tests exploration -- --nocapture
|
||||
//! ```
|
||||
|
||||
mod exploration_test;
|
||||
mod yaml_tests;
|
||||
|
||||
Reference in New Issue
Block a user