New navigation controller implementing frontier-based exploration for the VacuumTiger robot vacuum. Key features: Multi-threaded architecture: - Sensor thread (~100Hz): UDP sensor reading, odometry, safety checks - Mapping thread (~5-10Hz): VastuSLAM scan matching, grid updates - Exploration thread (~20Hz): Frontier detection, path planning Path planning: - Theta* any-angle pathfinding (smoother than A*) - SIMD-optimized cost map with obstacle inflation - Path smoother for differential-drive kinematics Frontier exploration: - Flood-fill clustering of frontier cells - Normalized scoring (distance, size, info gain) - Blacklisting and recovery for stuck situations Integration: - SangamIO TCP/UDP client for robot communication - VastuSLAM for SLAM and occupancy grid mapping - SVG visualization with trajectory and frontier markers
44 KiB
DhruvaNav - Autonomous Exploration Proposal
Overview
DhruvaNav is a navigation controller for the VacuumTiger robot vacuum. Phase 1 (predefined path testing with map recording) is complete. This proposal focuses on implementing autonomous exploration using frontier-based exploration.
Current Status (Phase 1 Complete)
- SangamIO TCP/UDP client with sensor streaming
- Wheel encoder odometry with VastuSLAM pose correction
- Rectangular path executor with proportional control
- Occupancy grid mapping using VastuSLAM
- Safety stops on bumper/cliff triggers
- Map saving to
.vastuand.svgformats
Architecture
┌────────────────────────────────────────────────────────────────────┐
│ DhruvaNav Application │
├────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────────────┐ │
│ │ Explorer │ │ Path Planner │ │ Motion Controller │ │
│ │ (Frontier-based)│ │ (A*/RRT) │ │ (Velocity Control) │ │
│ └────────┬────────┘ └────────┬────────┘ └─────────┬──────────┘ │
│ │ │ │ │
│ ┌────────▼────────────────────▼─────────────────────▼──────────┐ │
│ │ State Machine │ │
│ │ INIT → EXPLORING → NAVIGATING → EXPLORING → ... → DONE │ │
│ └──────────────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼────────────────────────────────┐ │
│ │ Mapper (VastuSLAM) │ │
│ │ • Scan matching for pose correction │ │
│ │ • Occupancy grid updates │ │
│ │ • Frontier detection from grid │ │
│ └──────────────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼────────────────────────────────┐ │
│ │ SangamIO Client │ │
│ │ • TCP: Commands (velocity, stop, actuators) │ │
│ │ • UDP: Sensors (lidar @ 5Hz, encoders @ 110Hz) │ │
│ └───────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
│
│ Network (Port 5555)
▼
┌────────────────────────────────────────────────────────────────────┐
│ SangamIO Daemon │
│ (Hardware Abstraction Layer) │
└────────────────────────────────────────────────────────────────────┘
Frontier-Based Exploration Algorithm
Concept
Frontier-based exploration autonomously maps unknown environments by:
- Identifying frontiers - boundaries between explored (free) and unexplored (unknown) space
- Selecting the best frontier based on distance, size, and information gain
- Planning a collision-free path to the selected frontier
- Navigating to the frontier while updating the map
- Repeating until no frontiers remain (full coverage)
Algorithm Flow
┌──────────────────────────────────────────────────────────────────┐
│ EXPLORATION LOOP │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ Update Map │◄────────────────────────────────────────────┐ │
│ │ (lidar scan)│ │ │
│ └──────┬──────┘ │ │
│ ▼ │ │
│ ┌──────────────┐ No frontiers ┌────────────────────┐ │ │
│ │ Detect │───────────────────►│ DONE: Full │ │ │
│ │ Frontiers │ │ Coverage! │ │ │
│ └──────┬───────┘ └────────────────────┘ │ │
│ │ Frontiers exist │ │
│ ▼ │ │
│ ┌──────────────┐ │ │
│ │ Score & │ │ │
│ │ Select │ │ │
│ │ Frontier │ │ │
│ └──────┬───────┘ │ │
│ ▼ │ │
│ ┌──────────────┐ No path found ┌────────────────────┐ │ │
│ │ Plan Path │───────────────────►│ Mark frontier as │───┘ │
│ │ to Frontier │ │ unreachable │ │
│ └──────┬───────┘ └────────────────────┘ │
│ │ Path found │
│ ▼ │
│ ┌──────────────┐ │
│ │ Navigate │ │
│ │ Along Path │────────────────────────────────────────────────┘
│ └──────────────┘ (continuous map updates during navigation)
│ │
└──────────────────────────────────────────────────────────────────┘
Component Design
1. Frontier Detector
Identifies frontiers from the occupancy grid by finding free cells adjacent to unknown cells.
/// A frontier is a connected region of free cells adjacent to unknown space
pub struct Frontier {
/// Cells that make up this frontier
pub cells: Vec<GridCoord>,
/// Centroid in world coordinates (navigation target)
pub centroid: WorldPoint,
/// Size in number of cells
pub size: usize,
/// Average distance from robot (for scoring)
pub distance: f32,
}
pub struct FrontierDetector {
/// Minimum cells to consider a valid frontier
min_frontier_size: usize, // default: 5
/// Grid resolution for distance calculation
resolution: f32,
}
impl FrontierDetector {
/// Detect all frontiers in the current map
pub fn detect(&self, grid: &GridStorage, robot_pose: Pose2D) -> Vec<Frontier>;
/// Check if a cell is a frontier cell (free with unknown neighbor)
fn is_frontier_cell(&self, grid: &GridStorage, coord: GridCoord) -> bool;
/// Group adjacent frontier cells into connected regions
fn cluster_frontiers(&self, frontier_cells: Vec<GridCoord>) -> Vec<Frontier>;
}
Frontier Detection Algorithm:
for each cell in grid:
if cell is FREE and has UNKNOWN neighbor:
mark as frontier cell
cluster frontier cells using flood-fill:
for each unvisited frontier cell:
BFS to find all connected frontier cells
create Frontier with cells, compute centroid
filter frontiers by minimum size
compute distance from robot to each frontier centroid
2. Frontier Scorer
Ranks frontiers to determine exploration priority.
pub struct FrontierScorer {
/// Weight for distance (closer = better)
distance_weight: f32, // default: 1.0
/// Weight for size (larger = more information gain)
size_weight: f32, // default: 0.5
/// Weight for heading alignment (facing frontier = better)
alignment_weight: f32, // default: 0.3
}
impl FrontierScorer {
/// Score a frontier (higher = better target)
pub fn score(&self, frontier: &Frontier, robot_pose: Pose2D) -> f32 {
let distance_score = 1.0 / (1.0 + frontier.distance);
let size_score = (frontier.size as f32).ln();
let alignment_score = self.compute_alignment(frontier, robot_pose);
self.distance_weight * distance_score
+ self.size_weight * size_score
+ self.alignment_weight * alignment_score
}
/// Select the best frontier
pub fn select_best(&self, frontiers: &[Frontier], robot_pose: Pose2D) -> Option<&Frontier>;
}
Scoring Factors:
| Factor | Rationale |
|---|---|
| Distance | Closer frontiers reduce travel time |
| Size | Larger frontiers reveal more unknown area |
| Alignment | Frontiers in current heading require less rotation |
| Reachability | Penalize previously failed frontiers |
3. Cost Map
Pre-computes traversability and wall-distance costs for efficient path planning.
/// Cost map for path planning with obstacle inflation and wall distance penalty
pub struct CostMap {
/// Grid dimensions
width: usize,
height: usize,
/// Resolution in meters per cell
resolution: f32,
/// Cost values: 0 = free, 255 = lethal obstacle
costs: Vec<u8>,
/// Distance to nearest obstacle (in cells) for each cell
distance_field: Vec<f32>,
}
/// Cost thresholds
pub const COST_FREE: u8 = 0; // Safe to traverse
pub const COST_NEAR_OBSTACLE: u8 = 50; // Close to wall, penalized
pub const COST_INSCRIBED: u8 = 254; // Within robot radius, avoid
pub const COST_LETHAL: u8 = 255; // Obstacle or unknown, blocked
impl CostMap {
/// Build cost map from occupancy grid
pub fn from_grid(
grid: &GridStorage,
robot_radius: f32, // 0.17m
safety_margin: f32, // 0.05m extra clearance
wall_penalty_dist: f32, // 0.30m - penalize within this distance
) -> Self;
/// Update cost map incrementally when grid changes
pub fn update_region(&mut self, grid: &GridStorage, min: GridCoord, max: GridCoord);
/// Get traversal cost at a grid coordinate
pub fn cost(&self, coord: GridCoord) -> u8;
/// Check if line-of-sight is clear (for Theta*)
pub fn line_of_sight(&self, from: GridCoord, to: GridCoord) -> bool;
/// Get distance to nearest obstacle (for wall avoidance)
pub fn obstacle_distance(&self, coord: GridCoord) -> f32;
}
Cost Map Layers:
┌─────────────────────────────────────────────────────────────────┐
│ COST MAP COMPUTATION │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. LETHAL LAYER (cost = 255) │
│ • All cells with CellType: Wall, Cliff, Bump, Unknown │
│ │
│ 2. INSCRIBED LAYER (cost = 254) │
│ • Cells within robot_radius of any lethal cell │
│ • Robot center cannot enter these cells │
│ │
│ 3. INFLATION LAYER (cost = 50-253) │
│ • Cells within safety_margin beyond inscribed radius │
│ • Exponential decay: cost = 50 * exp(-distance/decay) │
│ │
│ 4. WALL PENALTY LAYER (cost = 1-49) │
│ • Cells within wall_penalty_dist of obstacles │
│ • Linear penalty: closer to wall = higher cost │
│ • Encourages paths through center of corridors │
│ │
│ 5. FREE LAYER (cost = 0) │
│ • All remaining traversable cells │
│ │
└─────────────────────────────────────────────────────────────────┘
Distance Field Computation (Brushfire Algorithm):
1. Initialize distance_field with 0 for obstacles, ∞ for others
2. Add all obstacle cells to queue
3. BFS expansion:
for each cell in queue:
for each neighbor:
new_dist = distance_field[cell] + 1
if new_dist < distance_field[neighbor]:
distance_field[neighbor] = new_dist
add neighbor to queue
4. Convert cell distances to meters: dist_m = dist_cells * resolution
4. Path Planner (Theta* Algorithm)
Plans any-angle paths that avoid walls with safe clearance.
Why Theta* over A*:
- A* produces grid-constrained paths (45°/90° turns only)
- Theta* allows any-angle paths → smoother, shorter
- Better suited for differential drive robots
- Wall distance penalty ensures safe clearance
pub struct PathPlanner {
/// Pre-computed cost map
cost_map: CostMap,
/// Robot radius (for collision checking)
robot_radius: f32,
/// Maximum iterations before giving up
max_iterations: usize,
/// Wall penalty weight in path cost
wall_penalty_weight: f32,
}
impl PathPlanner {
/// Create planner with cost map
pub fn new(cost_map: CostMap, robot_radius: f32) -> Self;
/// Plan path using Theta* algorithm
pub fn plan(
&self,
start: Pose2D,
goal: WorldPoint,
) -> Option<PlannedPath>;
/// Check if direct line-of-sight exists (no obstacles)
fn line_of_sight(&self, from: GridCoord, to: GridCoord) -> bool;
/// Compute path cost including wall penalty
fn path_cost(&self, from: GridCoord, to: GridCoord) -> f32;
/// Update cost map when occupancy grid changes
pub fn update_cost_map(&mut self, grid: &GridStorage);
}
pub struct PlannedPath {
/// Waypoints in world coordinates
pub waypoints: Vec<WorldPoint>,
/// Total path length in meters
pub length: f32,
/// Total path cost (includes wall penalties)
pub cost: f32,
}
Theta* Algorithm:
Theta* extends A* with line-of-sight checks for any-angle paths:
function theta_star(start, goal):
open_set = priority_queue()
open_set.push(start, heuristic(start, goal))
g_score[start] = 0
parent[start] = start
while not open_set.empty():
current = open_set.pop()
if current == goal:
return reconstruct_path(goal)
for neighbor in current.neighbors_8():
if cost_map.cost(neighbor) >= COST_INSCRIBED:
continue // Skip blocked cells
// KEY DIFFERENCE FROM A*: Check line-of-sight to grandparent
if line_of_sight(parent[current], neighbor):
// Path 2: Direct from grandparent (any-angle)
new_g = g_score[parent[current]] +
path_cost(parent[current], neighbor)
new_parent = parent[current]
else:
// Path 1: Through current (grid-constrained)
new_g = g_score[current] + path_cost(current, neighbor)
new_parent = current
if new_g < g_score[neighbor]:
g_score[neighbor] = new_g
parent[neighbor] = new_parent
f_score = new_g + heuristic(neighbor, goal)
open_set.push(neighbor, f_score)
return None // No path found
function path_cost(from, to):
distance = euclidean_distance(from, to)
// Sample points along line to accumulate wall penalty
wall_penalty = integrate_wall_penalty(from, to)
return distance + wall_penalty_weight * wall_penalty
function line_of_sight(from, to):
// Bresenham's line algorithm
for each cell on line from→to:
if cost_map.cost(cell) >= COST_INSCRIBED:
return false
return true
Wall Avoidance via Path Cost:
Path cost = distance + wall_penalty_weight × wall_penalty
wall_penalty for segment (A → B):
penalty = 0
for each cell along line A→B:
dist_to_wall = cost_map.obstacle_distance(cell)
if dist_to_wall < wall_penalty_dist:
penalty += (wall_penalty_dist - dist_to_wall) / wall_penalty_dist
return penalty
Effect: Paths through corridor centers are cheaper than hugging walls
5. Path Smoother (Differential Drive)
Post-processes Theta* paths for differential drive kinematics.
pub struct PathSmoother {
/// Maximum linear velocity (m/s)
max_linear_vel: f32,
/// Maximum angular velocity (rad/s)
max_angular_vel: f32,
/// Minimum turning radius (m) = max_linear_vel / max_angular_vel
min_turn_radius: f32,
/// Lookahead distance for smoothing
lookahead: f32,
}
impl PathSmoother {
/// Smooth path for differential drive constraints
pub fn smooth(&self, path: &PlannedPath) -> SmoothedPath;
/// Insert arc segments at sharp turns
fn insert_turn_arcs(&self, waypoints: &[WorldPoint]) -> Vec<PathSegment>;
/// Check if turn angle exceeds threshold requiring in-place rotation
fn needs_point_turn(&self, angle: f32) -> bool;
}
pub enum PathSegment {
/// Straight line segment
Line { start: WorldPoint, end: WorldPoint },
/// Arc segment for smooth turns
Arc { center: WorldPoint, radius: f32, start_angle: f32, end_angle: f32 },
/// In-place rotation (for sharp turns > 90°)
PointTurn { position: WorldPoint, from_angle: f32, to_angle: f32 },
}
pub struct SmoothedPath {
/// Path segments (lines, arcs, point turns)
pub segments: Vec<PathSegment>,
/// Estimated traversal time
pub estimated_time: f32,
}
Smoothing Algorithm:
1. Analyze turn angles at each waypoint
2. For each turn:
- If angle < 30°: Keep as-is (Theta* already smooth)
- If 30° < angle < 90°: Insert arc with min_turn_radius
- If angle > 90°: Insert point turn (rotate in place)
3. Verify smoothed path still avoids obstacles
4. Return segments with velocity profiles
Differential Drive Constraints:
┌─────────────────────────────────────────────────────────────────┐
│ DIFFERENTIAL DRIVE KINEMATICS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Minimum turn radius: R_min = v_max / ω_max │
│ │
│ For CRL-200S robot: │
│ v_max = 0.2 m/s │
│ ω_max = 0.5 rad/s │
│ R_min = 0.2 / 0.5 = 0.4 m │
│ │
│ Sharp turns (> 90°): │
│ - Cannot achieve with continuous motion at R_min │
│ - Must stop, rotate in place, then continue │
│ │
└─────────────────────────────────────────────────────────────────┘
6. Path Follower
Follows planned paths with obstacle avoidance.
pub struct PathFollower {
/// Planned path waypoints
path: Vec<WorldPoint>,
/// Current waypoint index
current_index: usize,
/// Lookahead distance for pure pursuit
lookahead_distance: f32, // default: 0.3m
/// Waypoint reached tolerance
waypoint_tolerance: f32, // default: 0.1m
}
impl PathFollower {
/// Set a new path to follow
pub fn set_path(&mut self, path: Vec<WorldPoint>);
/// Compute velocity command using pure pursuit
pub fn compute_velocity(&self, robot_pose: Pose2D) -> (f32, f32);
/// Check if current path segment is blocked by new obstacle
pub fn is_path_blocked(&self, grid: &GridStorage) -> bool;
/// Check if path is complete
pub fn is_complete(&self) -> bool;
}
Pure Pursuit Controller:
1. Find lookahead point on path at distance L from robot
2. Compute curvature to reach lookahead point
3. Convert curvature to (linear, angular) velocity
4. Apply velocity limits
7. Exploration State Machine
Orchestrates the exploration process.
pub enum ExplorationState {
/// Initial state, waiting to start
Init,
/// Scanning environment, detecting frontiers
Scanning,
/// Planning path to selected frontier
Planning,
/// Navigating to frontier
Navigating,
/// Recovering from navigation failure
Recovering,
/// Exploration complete, no frontiers remain
Complete,
/// Safety stop (bumper/cliff triggered)
SafetyStop,
}
pub struct Explorer {
state: ExplorationState,
frontier_detector: FrontierDetector,
frontier_scorer: FrontierScorer,
path_planner: PathPlanner,
path_follower: PathFollower,
/// Currently targeted frontier
current_target: Option<Frontier>,
/// Frontiers that couldn't be reached
blacklisted_frontiers: HashSet<GridCoord>,
/// Statistics
stats: ExplorationStats,
}
pub struct ExplorationStats {
pub frontiers_visited: usize,
pub total_distance: f32,
pub coverage_percent: f32,
pub time_elapsed: Duration,
}
State Transitions:
┌────────┐
│ Init │
└───┬────┘
│ Start exploration
▼
┌─────────┐◄─────────────────────────────────────┐
│Scanning │ │
└────┬────┘ │
│ │
├─── No frontiers ──► [Complete] │
│ │
│ Frontiers found │
▼ │
┌──────────┐ │
│ Planning │ │
└────┬─────┘ │
│ │
├─── No path ──► Blacklist frontier ─────────┤
│ │
│ Path found │
▼ │
┌────────────┐ │
│ Navigating │ │
└─────┬──────┘ │
│ │
├─── Reached frontier ──────────────────────┘
│
├─── Path blocked ──► [Recovering] ──► [Scanning]
│
└─── Bumper/Cliff ──► [SafetyStop]
Configuration
# dhruva.toml
[connection]
robot_ip = "127.0.0.1"
port = 5555
timeout_ms = 5000
[robot]
wheel_base = 0.233
ticks_per_meter = 4464.0
max_linear_vel = 0.2
max_angular_vel = 0.5
robot_radius = 0.17
[exploration]
# Frontier detection
min_frontier_size = 5 # Minimum cells for valid frontier
frontier_update_interval = 2.0 # Seconds between frontier updates
# Frontier scoring weights
distance_weight = 1.0
size_weight = 0.5
alignment_weight = 0.3
# Termination
max_exploration_time = 600 # Maximum time in seconds (0 = unlimited)
return_to_start = true # Navigate back to start position when done
[path_planning]
# Cost map parameters
safety_margin = 0.05 # Extra clearance beyond robot radius (meters)
wall_penalty_distance = 0.30 # Penalize paths within this distance of walls (meters)
wall_penalty_weight = 2.0 # Weight for wall proximity penalty in path cost
# Theta* algorithm
max_iterations = 50000 # Maximum search iterations before giving up
max_path_length = 20.0 # Maximum path length (meters)
# Path smoothing for differential drive
enable_smoothing = true # Post-process paths for smooth turns
sharp_turn_threshold = 1.57 # Angle (rad) above which to insert point turn (~90°)
arc_turn_threshold = 0.52 # Angle (rad) above which to insert arc (~30°)
[navigation]
# Pure pursuit path following
lookahead_distance = 0.3 # Lookahead for path following (meters)
waypoint_tolerance = 0.10 # Distance to consider waypoint reached (meters)
goal_tolerance = 0.15 # Distance to consider goal reached (meters)
# Replanning
replan_on_obstacle = true # Replan if path becomes blocked
replan_interval = 5.0 # Seconds between replan checks during navigation
[output]
map_path = "output/map.vastu"
svg_path = "output/map.svg"
save_interval = 30 # Auto-save every N seconds (0 = disabled)
Project Structure
dhruva-nav/
├── Cargo.toml
├── dhruva.toml # Configuration
├── build.rs # Protobuf compilation
├── proto/
│ └── sangamio.proto # Symlink to sangam-io/proto
└── src/
├── main.rs # Entry point, main loop
├── config.rs # Configuration loading
├── error.rs # Error types
│
├── client.rs # SangamIO TCP/UDP client
├── odometry.rs # Wheel encoder odometry
├── mapper.rs # VastuSLAM integration
│
├── planning/ # Path planning module
│ ├── mod.rs # Module exports
│ ├── cost_map.rs # Cost map with obstacle inflation
│ ├── theta_star.rs # Theta* path planner
│ └── smoother.rs # Differential drive path smoother
│
├── exploration/ # Exploration module
│ ├── mod.rs # Module exports
│ ├── frontier.rs # Frontier detection & clustering
│ ├── scorer.rs # Frontier scoring & selection
│ ├── follower.rs # Pure pursuit path following
│ └── explorer.rs # Exploration state machine
│
└── path.rs # Predefined path (kept for testing)
Implementation Phases
Phase 2A: Cost Map & Distance Field
Goal: Build cost map with obstacle inflation and wall distance penalties
Tasks:
- Implement
CostMapstructure with cost layers - Implement Brushfire algorithm for distance field computation
- Add obstacle inflation (inscribed + safety margin)
- Add wall penalty layer for path cost calculation
- Implement
line_of_sight()using Bresenham's algorithm
Validation:
- Distance field correctly computed from obstacles
- Inscribed radius blocks cells within robot footprint
- Wall penalty decreases with distance from obstacles
- Line-of-sight correctly detects obstacles
Phase 2B: Theta* Path Planner
Goal: Plan any-angle paths with wall avoidance
Tasks:
- Implement Theta* algorithm with priority queue
- Integrate wall penalty into path cost function
- Add path cost computation along line segments
- Implement goal region detection (not just single cell)
Validation:
- Paths are any-angle (not grid-constrained)
- Paths prefer corridor centers over wall-hugging
- Returns None for unreachable goals
- Performance: <500ms for 10m paths
Phase 2C: Path Smoother
Goal: Post-process paths for differential drive kinematics
Tasks:
- Implement turn angle analysis at waypoints
- Insert arc segments for medium turns (30-90°)
- Insert point turns for sharp turns (>90°)
- Verify smoothed path clearance from obstacles
Validation:
- Arcs respect minimum turning radius (0.4m)
- Point turns inserted for sharp corners
- Smoothed paths remain collision-free
Phase 2D: Path Follower
Goal: Follow planned paths with pure pursuit
Tasks:
- Implement
PathFollowerwith pure pursuit algorithm - Handle different segment types (line, arc, point turn)
- Add path blocking detection
- Integrate with existing velocity control
Validation:
- Robot follows paths smoothly
- Handles transitions between segment types
- Detects when obstacles block planned path
- Reaches waypoints within tolerance
Phase 2E: Frontier Detection
Goal: Detect and cluster frontiers from occupancy grid
Tasks:
- Implement frontier cell detection (Floor + Unknown neighbor)
- Implement BFS clustering for connected frontiers
- Compute frontier centroids and sizes
- Add frontier visualization to SVG output
Validation:
- Frontiers correctly identified at free/unknown boundaries
- Clusters are properly formed (no fragmentation)
- Centroid calculation is accurate
Phase 2F: Exploration Integration
Goal: Full autonomous exploration with return-to-start
Tasks:
- Implement
Explorerstate machine - Implement
FrontierScorerwith configurable weights - Add frontier blacklisting for unreachable targets
- Implement return-to-start navigation on completion
- Add exploration statistics tracking
Validation:
- Explores entire environment autonomously
- Handles recovery from blocked paths
- Returns to starting position when done
- Terminates when no frontiers remain
Testing Strategy
Unit Tests
#[cfg(test)]
mod tests {
// Frontier detection
#[test]
fn test_frontier_cell_detection() { ... }
#[test]
fn test_frontier_clustering() { ... }
// Path planning
#[test]
fn test_astar_simple_path() { ... }
#[test]
fn test_astar_around_obstacle() { ... }
#[test]
fn test_no_path_to_enclosed_goal() { ... }
// Path following
#[test]
fn test_pure_pursuit_straight_line() { ... }
#[test]
fn test_pure_pursuit_curve() { ... }
}
Integration Tests with Mock Device
# Terminal 1: Start SangamIO mock
cd sangam-io
cargo run --release -- --config mock.toml
# Terminal 2: Run exploration
cd dhruva-nav
cargo run --release
Test Scenarios:
| Scenario | Map | Expected Behavior |
|---|---|---|
| Empty room | empty_room.yaml |
Quick full coverage |
| Single obstacle | room_with_box.yaml |
Navigate around obstacle |
| Multiple obstacles | large_room_obstacles.yaml |
Complex frontier selection |
| Maze | maze.yaml |
Backtracking, recovery |
| Narrow passages | narrow_corridors.yaml |
Path planning through tight spaces |
Success Criteria
Functional Requirements
- Detects frontiers correctly from occupancy grid
- Plans collision-free paths to frontiers
- Follows paths with smooth motion
- Handles path blocking and replanning
- Explores until no frontiers remain
- Achieves >90% coverage in test environments
- Stops safely on bumper/cliff triggers
- Saves map periodically and on completion
Performance Requirements
- Frontier detection < 100ms for 10m x 10m map
- Path planning < 500ms for 10m path
- Control loop maintains 20Hz update rate
- Memory usage < 50MB for typical room
Robustness Requirements
- Recovers from navigation failures
- Handles dynamic obstacles (replanning)
- Continues after safety stop and resume
- Handles connection loss gracefully
SIMD Optimizations
DhruvaNav uses std::simd (portable SIMD) following VastuSLAM's patterns for high-performance algorithms.
VastuSLAM SIMD Patterns (Reference)
VastuSLAM provides these SIMD primitives that DhruvaNav can reuse:
// Core SIMD types (LANES = 4 for ARM NEON / SSE)
use std::simd::{f32x4, i32x4, u8x16};
// VastuSLAM SIMD utilities
use vastu_slam::core::simd::{
PointCloud, // SoA point storage
RotationMatrix4, // Pre-computed rotation
transform_points_simd4, // Batch point transformation
world_to_grid_simd4, // Batch coordinate conversion
distance_squared_simd4, // Batch distance calculation
is_valid_coords_simd4, // Batch bounds checking
};
Key Patterns:
- Structure-of-Arrays (SoA): Store X and Y separately for SIMD loads
- Padding to LANES: Always pad arrays to multiples of 4 (f32x4) or 16 (u8x16)
- Pre-computed constants: Broadcast scalars to SIMD vectors outside loops
- Scratch buffers: Reuse allocations for repeated operations
DhruvaNav SIMD Opportunities
| Component | SIMD Potential | Approach |
|---|---|---|
| Cost Map - Distance Field | HIGH | Batch distance comparisons during Brushfire |
| Cost Map - Obstacle Inflation | HIGH | SIMD threshold checks for inflation radius |
| Frontier Detection | HIGH | u8x16 comparison for Floor+Unknown neighbors |
| Path Cost Integration | MEDIUM | Batch wall distance lookups along path |
| Theta* Heuristic | LOW | Single point, limited benefit |
| Path Follower | LOW | Single robot pose |
1. SIMD Distance Field (Brushfire)
use std::simd::{f32x4, cmp::SimdPartialOrd};
const LANES: usize = 4;
/// SIMD-optimized distance field propagation
pub fn propagate_distances_simd(
distances: &mut [f32],
width: usize,
height: usize,
) {
let sqrt2 = f32x4::splat(1.414);
let one = f32x4::splat(1.0);
// Process 4 cells at a time (row-wise)
for y in 1..height-1 {
let row_start = y * width;
for x in (1..width-1).step_by(LANES) {
if x + LANES > width - 1 { break; }
// Load current distances
let curr = f32x4::from_slice(&distances[row_start + x..]);
// Load neighbor distances (requires careful indexing)
let north = f32x4::from_slice(&distances[row_start - width + x..]);
let south = f32x4::from_slice(&distances[row_start + width + x..]);
// Compute minimum of (neighbor + cost)
let from_north = north + one;
let from_south = south + one;
let min_dist = curr.simd_min(from_north).simd_min(from_south);
// Store updated distances
min_dist.copy_to_slice(&mut distances[row_start + x..]);
}
}
}
2. SIMD Frontier Detection
use std::simd::{u8x16, cmp::SimdPartialEq};
/// Batch check for frontier cells (Floor with Unknown neighbor)
pub fn detect_frontier_cells_simd(
cell_types: &[u8],
width: usize,
frontier_mask: &mut [bool],
) {
let floor_vec = u8x16::splat(CellType::Floor as u8);
let unknown_vec = u8x16::splat(CellType::Unknown as u8);
// Process 16 cells at a time
for chunk_start in (0..cell_types.len()).step_by(16) {
if chunk_start + 16 > cell_types.len() { break; }
let cells = u8x16::from_slice(&cell_types[chunk_start..]);
// Check if cell is Floor
let is_floor = cells.simd_eq(floor_vec);
// For each Floor cell, check neighbors for Unknown
// (neighbor checking done separately due to irregular access)
let floor_mask = is_floor.to_bitmask();
for i in 0..16 {
if floor_mask & (1 << i) != 0 {
let idx = chunk_start + i;
// Check 4 neighbors for Unknown
frontier_mask[idx] = has_unknown_neighbor(cell_types, idx, width);
}
}
}
}
3. SIMD Path Cost Integration
/// SIMD-optimized wall penalty along path segment
pub fn integrate_wall_penalty_simd(
distance_field: &[f32],
path_xs: &[f32], // X coordinates along path (padded to LANES)
path_ys: &[f32], // Y coordinates along path
width: usize,
resolution: f32,
penalty_distance: f32,
) -> f32 {
let n = path_xs.len();
debug_assert!(n % LANES == 0);
let inv_res = f32x4::splat(1.0 / resolution);
let penalty_dist = f32x4::splat(penalty_distance);
let zero = f32x4::splat(0.0);
let mut total_penalty = f32x4::splat(0.0);
for i in (0..n).step_by(LANES) {
let px = f32x4::from_slice(&path_xs[i..]);
let py = f32x4::from_slice(&path_ys[i..]);
// Convert to grid indices
let gx = (px * inv_res).floor();
let gy = (py * inv_res).floor();
// Gather distances (scalar fallback for irregular access)
let mut dists = [0.0f32; LANES];
for j in 0..LANES {
let idx = (gy.to_array()[j] as usize) * width + (gx.to_array()[j] as usize);
dists[j] = distance_field.get(idx).copied().unwrap_or(0.0);
}
let dist_vec = f32x4::from_array(dists);
// Compute penalty: max(0, penalty_distance - distance) / penalty_distance
let penalty = (penalty_dist - dist_vec).simd_max(zero) / penalty_dist;
total_penalty += penalty;
}
// Horizontal sum
total_penalty.reduce_sum()
}
4. SIMD Centroid Calculation
/// SIMD-optimized frontier centroid calculation
pub fn compute_centroid_simd(
xs: &[i32], // Grid X coordinates (padded)
ys: &[i32], // Grid Y coordinates (padded)
count: usize,
) -> (f32, f32) {
let n = xs.len();
debug_assert!(n % LANES == 0);
let mut sum_x = i32x4::splat(0);
let mut sum_y = i32x4::splat(0);
for i in (0..n).step_by(LANES) {
sum_x += i32x4::from_slice(&xs[i..]);
sum_y += i32x4::from_slice(&ys[i..]);
}
let total_x = sum_x.reduce_sum() as f32;
let total_y = sum_y.reduce_sum() as f32;
(total_x / count as f32, total_y / count as f32)
}
5. Scratch Buffers Pattern
Following VastuSLAM's pattern, reuse allocations:
/// Pre-allocated buffers for SIMD operations
pub struct PlannerScratch {
/// World coordinates (padded to LANES)
pub world_xs: Vec<f32>,
pub world_ys: Vec<f32>,
/// Grid coordinates
pub grid_xs: Vec<i32>,
pub grid_ys: Vec<i32>,
/// Distance values
pub distances: Vec<f32>,
/// Valid flags
pub valid: Vec<bool>,
}
impl PlannerScratch {
pub fn new(capacity: usize) -> Self {
let padded = capacity.div_ceil(LANES) * LANES;
Self {
world_xs: vec![0.0; padded],
world_ys: vec![0.0; padded],
grid_xs: vec![0; padded],
grid_ys: vec![0; padded],
distances: vec![0.0; padded],
valid: vec![false; padded],
}
}
pub fn ensure_capacity(&mut self, capacity: usize) {
let padded = capacity.div_ceil(LANES) * LANES;
if self.world_xs.len() < padded {
self.world_xs.resize(padded, 0.0);
self.world_ys.resize(padded, 0.0);
self.grid_xs.resize(padded, 0);
self.grid_ys.resize(padded, 0);
self.distances.resize(padded, 0.0);
self.valid.resize(padded, false);
}
}
}
Performance Targets with SIMD
| Operation | Scalar Target | SIMD Target | Speedup |
|---|---|---|---|
| Distance field (100x100) | 50ms | 15ms | 3x |
| Frontier detection | 20ms | 5ms | 4x |
| Path cost (100 samples) | 2ms | 0.5ms | 4x |
| Centroid (500 cells) | 0.5ms | 0.1ms | 5x |
Implementation Notes
- Nightly Rust required: Uses
#![feature(portable_simd)] - Fallback:
std::simdauto-generates scalar code when SIMD unavailable - ARM NEON: Primary target (Allwinner A33 in CRL-200S robot)
- Testing: Compare SIMD vs scalar results in unit tests (like VastuSLAM)
Dependencies
[dependencies]
vastu-slam = { path = "../vastu-slam" }
prost = "0.13"
toml = "0.9"
serde = { version = "1", features = ["derive"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "2"
# NEW for exploration
priority-queue = "2" # A* open set
rustc-hash = "2" # Fast HashSet for visited nodes
[build-dependencies]
prost-build = "0.13"
Open Questions
-
Frontier Refresh Rate: How often should frontiers be recalculated during navigation?
- Option A: Every lidar scan (most accurate, highest CPU)
- Option B: Fixed interval (1-2 seconds)
- Option C: Only when reaching waypoints
-
Unknown Space Treatment: Should the robot plan paths through unknown space?
- Option A: Only through known-free (safest)
- Option B: Allow unknown if no free path (more aggressive)
-
Coverage Metric: How to measure exploration completeness?
- Option A: Percentage of grid cells explored
- Option B: Ratio of explored area to bounding box
- Option C: No more frontiers above minimum size
-
Return to Start: Should robot return to starting position after exploration?
- Could be useful for docking in vacuum cleaner context
References
- Yamauchi, B. (1997). "A frontier-based approach for autonomous exploration"
- VastuSLAM documentation:
vastu-slam/README.md - SangamIO protocol:
sangam-io/proto/sangamio.proto