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:
codetiger
2025-12-22 13:56:49 +05:30
parent 9f339d3d29
commit d6acdd4a88
11 changed files with 2362 additions and 29 deletions
+215
View File
@@ -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);
}
}
+147
View File
@@ -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);
}
}
+700
View File
@@ -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());
}
}
+73
View File
@@ -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};
+403
View File
@@ -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));
}
}
+7
View File
@@ -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
View File
@@ -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
}
}
+225 -8
View File
@@ -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
}
}
+1 -1
View File
@@ -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;
}
}
}
+8 -5
View File
@@ -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;